From 75baa672eda48b438b7319060a7a3795fdad02be Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Tue, 31 Dec 2024 22:11:36 +0100 Subject: [PATCH 1/9] feat: initial support for webhooks I'm doing this for my Rails app, so include ActiveSupport stuff --- lib/twitch/client.rb | 3 + lib/twitch/client/webhooks.rb | 38 +++++++++ lib/twitch/webhook_handler.rb | 140 ++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 lib/twitch/client/webhooks.rb create mode 100644 lib/twitch/webhook_handler.rb diff --git a/lib/twitch/client.rb b/lib/twitch/client.rb index c29f85d..1d41bf9 100644 --- a/lib/twitch/client.rb +++ b/lib/twitch/client.rb @@ -31,6 +31,7 @@ require_relative 'user_follow' require_relative 'redemption' require_relative 'video' +require_relative 'webhook_handler' module Twitch # Core class for requests @@ -109,6 +110,8 @@ def get_videos(options = {}) require_relative 'client/custom_rewards' include CustomRewards + require_relative 'client/webhooks' + ## https://dev.twitch.tv/docs/api/reference#get-channel-information def get_channels(options = {}) initialize_response Channel, get('channels', options) diff --git a/lib/twitch/client/webhooks.rb b/lib/twitch/client/webhooks.rb new file mode 100644 index 0000000..a6425c2 --- /dev/null +++ b/lib/twitch/client/webhooks.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Twitch + class Client + # Module for EventSub webhook functionality + module Webhooks + ## https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription + def create_webhook_subscription(type:, condition:, callback_url:, secret:) + initialize_response nil, post('eventsub/subscriptions', { + type: type, + version: '1', + condition: condition, + transport: { + method: 'webhook', + callback: callback_url, + secret: secret + } + }) + end + + ## https://dev.twitch.tv/docs/api/reference#get-eventsub-subscriptions + def get_webhook_subscriptions(status: nil, type: nil) + params = {} + params[:status] = status if status + params[:type] = type if type + + initialize_response nil, get('eventsub/subscriptions', params) + end + + ## https://dev.twitch.tv/docs/api/reference#delete-eventsub-subscription + def delete_webhook_subscription(id:) + initialize_response nil, delete('eventsub/subscriptions', { id: id }) + end + end + + include Webhooks + end +end diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb new file mode 100644 index 0000000..33c0140 --- /dev/null +++ b/lib/twitch/webhook_handler.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Twitch + # Module for handling Twitch EventSub webhook notifications + module WebhookHandler + extend ActiveSupport::Concern + + HMAC_PREFIX = 'sha256=' + HEADER_MESSAGE_ID = 'twitch-eventsub-message-id' + HEADER_MESSAGE_TIMESTAMP = 'twitch-eventsub-message-timestamp' + HEADER_MESSAGE_SIGNATURE = 'twitch-eventsub-message-signature' + HEADER_MESSAGE_TYPE = 'twitch-eventsub-message-type' + HEADER_SUBSCRIPTION_TYPE = 'twitch-eventsub-subscription-type' + + included do + before_action :verify_twitch_signature! + skip_before_action :verify_authenticity_token + + class_attribute :subscription_types, default: [] + class_attribute :twitch_client + + Twitch::WebhookHandler.register_handler(self) + end + + class_methods do + def subscribe_to(type, **condition) + subscription_types << { + type: type, + condition: condition + } + end + + def register_subscriptions(client) + self.class.subscription_types.each do |subscription| + client.create_webhook_subscription( + type: subscription[:type], + condition: subscription[:condition], + callback_url: webhook_callback_url, + secret: webhook_secret + ) + rescue Twitch::APIError => e + Rails.logger.error "Failed to register webhook subscription: #{e.message}" + end + end + end + + class << self + def handlers + @handlers ||= [] + end + + def register_handler(handler_class) + handlers << handler_class + puts "Registered handler: #{handler_class}" + end + + def setup_client(client) + puts "Setting up client for #{handlers.count} handlers" + handlers.each do |handler_class| + puts "Configuring handler: #{handler_class}" + handler_class.twitch_client = client + handler_class.register_subscriptions(client) + end + end + end + + def receive + case request.headers[HEADER_MESSAGE_TYPE]&.downcase + when 'webhook_callback_verification' + handle_verification + when 'notification' + handle_notification + when 'revocation' + handle_revocation + else + head :no_content + end + end + + private + + def verify_twitch_signature! + message = build_hmac_message + hmac = "#{HMAC_PREFIX}#{generate_hmac(webhook_secret, message)}" + return if secure_compare(hmac, request.headers[HEADER_MESSAGE_SIGNATURE]) + + head :forbidden + end + + def build_hmac_message + request.headers[HEADER_MESSAGE_ID] + + request.headers[HEADER_MESSAGE_TIMESTAMP] + + request.raw_post + end + + def generate_hmac(secret, message) + OpenSSL::HMAC.hexdigest('sha256', secret, message) + end + + def secure_compare(a, b) + return false unless b + + ActiveSupport::SecurityUtils.secure_compare(a, b) + end + + def handle_verification + render plain: JSON.parse(request.raw_post)['challenge'] + end + + def handle_notification + event_data = JSON.parse(request.raw_post) + event_type = request.headers[HEADER_SUBSCRIPTION_TYPE] + + process_event(event_type, event_data) + head :no_content + end + + def handle_revocation + event_data = JSON.parse(request.raw_post) + process_revocation(event_data) + head :no_content + end + + def webhook_secret + raise NotImplementedError, 'Define webhook_secret in your controller' + end + + def webhook_callback_url + raise NotImplementedError, 'Define webhook_callback_url in your controller' + end + + def process_event(type, data) + raise NotImplementedError, 'Define process_event in your controller' + end + + def process_revocation(data) + raise NotImplementedError, 'Define process_revocation in your controller' + end + end +end From 2929a7c8aeca10fc09827f59168f91f94f094e44 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Wed, 1 Jan 2025 16:24:17 +0100 Subject: [PATCH 2/9] fix: support for different event versions --- lib/twitch/client/webhooks.rb | 4 ++-- lib/twitch/webhook_handler.rb | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/twitch/client/webhooks.rb b/lib/twitch/client/webhooks.rb index a6425c2..2f44ed9 100644 --- a/lib/twitch/client/webhooks.rb +++ b/lib/twitch/client/webhooks.rb @@ -5,10 +5,10 @@ class Client # Module for EventSub webhook functionality module Webhooks ## https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription - def create_webhook_subscription(type:, condition:, callback_url:, secret:) + def create_webhook_subscription(type:, condition:, callback_url:, secret:, version: '1') initialize_response nil, post('eventsub/subscriptions', { type: type, - version: '1', + version: version, condition: condition, transport: { method: 'webhook', diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index 33c0140..8d174f0 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -23,17 +23,20 @@ module WebhookHandler end class_methods do - def subscribe_to(type, **condition) + def subscribe_to(type, version: '1', **condition) subscription_types << { type: type, + version: version.to_s, condition: condition } end + # rubocop:disable Metrics/MethodLength def register_subscriptions(client) - self.class.subscription_types.each do |subscription| + subscription_types.each do |subscription| client.create_webhook_subscription( type: subscription[:type], + version: subscription[:version], condition: subscription[:condition], callback_url: webhook_callback_url, secret: webhook_secret From a62f477424bc2850ce97f37411b6183d515578a8 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 03:00:32 +0100 Subject: [PATCH 3/9] fix? --- lib/twitch/client.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/twitch/client.rb b/lib/twitch/client.rb index 1d41bf9..6692d20 100644 --- a/lib/twitch/client.rb +++ b/lib/twitch/client.rb @@ -111,6 +111,7 @@ def get_videos(options = {}) include CustomRewards require_relative 'client/webhooks' + include Webhooks ## https://dev.twitch.tv/docs/api/reference#get-channel-information def get_channels(options = {}) From 4c3e5c8aacd3748e2fc7b6654b9a52c3a2a0889f Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 03:11:55 +0100 Subject: [PATCH 4/9] debugging --- lib/twitch/client/webhooks.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/twitch/client/webhooks.rb b/lib/twitch/client/webhooks.rb index 2f44ed9..a368288 100644 --- a/lib/twitch/client/webhooks.rb +++ b/lib/twitch/client/webhooks.rb @@ -6,7 +6,7 @@ class Client module Webhooks ## https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription def create_webhook_subscription(type:, condition:, callback_url:, secret:, version: '1') - initialize_response nil, post('eventsub/subscriptions', { + response = initialize_response nil, post('eventsub/subscriptions', { type: type, version: version, condition: condition, @@ -16,6 +16,9 @@ def create_webhook_subscription(type:, condition:, callback_url:, secret:, versi secret: secret } }) + + puts response.data + puts response.raw end ## https://dev.twitch.tv/docs/api/reference#get-eventsub-subscriptions From ead2bf81583e250288f6e8581df3e2262b2e364a Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 03:22:31 +0100 Subject: [PATCH 5/9] debug --- lib/twitch/webhook_handler.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index 8d174f0..6695478 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -41,8 +41,19 @@ def register_subscriptions(client) callback_url: webhook_callback_url, secret: webhook_secret ) + + hash = { + type: subscription[:type], + version: subscription[:version], + condition: subscription[:condition], + callback_url: webhook_callback_url, + secret: webhook_secret + } + + puts hash rescue Twitch::APIError => e Rails.logger.error "Failed to register webhook subscription: #{e.message}" + puts e end end end From 721b15070366631cfaec1b63de8cd3fa0b8d26e8 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 03:27:51 +0100 Subject: [PATCH 6/9] debug --- lib/twitch/webhook_handler.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index 6695478..6e2dbba 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -34,26 +34,25 @@ def subscribe_to(type, version: '1', **condition) # rubocop:disable Metrics/MethodLength def register_subscriptions(client) subscription_types.each do |subscription| - client.create_webhook_subscription( + hash = { type: subscription[:type], version: subscription[:version], condition: subscription[:condition], callback_url: webhook_callback_url, secret: webhook_secret - ) + } - hash = { + puts hash + + client.create_webhook_subscription( type: subscription[:type], version: subscription[:version], condition: subscription[:condition], callback_url: webhook_callback_url, secret: webhook_secret - } - - puts hash + ) rescue Twitch::APIError => e Rails.logger.error "Failed to register webhook subscription: #{e.message}" - puts e end end end From 2b2c83cfd6fe099613fa2c44c3c816c60f11e409 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 11:35:39 +0100 Subject: [PATCH 7/9] stringify condition --- lib/twitch/webhook_handler.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index 6e2dbba..f30354c 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -24,10 +24,12 @@ module WebhookHandler class_methods do def subscribe_to(type, version: '1', **condition) + stringified_condition = condition.transform_values(&:to_s) + subscription_types << { type: type, version: version.to_s, - condition: condition + condition: stringified_condition } end From b113c6005651b1994f6b10efa2fb08bec179a7a3 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 13:09:51 +0100 Subject: [PATCH 8/9] add config --- lib/twitch-api.rb | 3 +++ lib/twitch/client/webhooks.rb | 24 ++++++++++++------- lib/twitch/webhook_config.rb | 34 +++++++++++++++++++++++++++ lib/twitch/webhook_event.rb | 44 +++++++++++++++++++++++++++++++++++ lib/twitch/webhook_handler.rb | 33 ++++++-------------------- 5 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 lib/twitch/webhook_config.rb create mode 100644 lib/twitch/webhook_event.rb diff --git a/lib/twitch-api.rb b/lib/twitch-api.rb index 3ed7336..d15f1e0 100644 --- a/lib/twitch-api.rb +++ b/lib/twitch-api.rb @@ -2,3 +2,6 @@ require_relative 'twitch/version' require_relative 'twitch/client' +require_relative 'twitch/webhook_config' +require_relative 'twitch/webhook_event' +require_relative 'twitch/webhook_handler' diff --git a/lib/twitch/client/webhooks.rb b/lib/twitch/client/webhooks.rb index a368288..d6e7342 100644 --- a/lib/twitch/client/webhooks.rb +++ b/lib/twitch/client/webhooks.rb @@ -4,9 +4,15 @@ module Twitch class Client # Module for EventSub webhook functionality module Webhooks - ## https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription - def create_webhook_subscription(type:, condition:, callback_url:, secret:, version: '1') - response = initialize_response nil, post('eventsub/subscriptions', { + # Create a new webhook subscription + # @param type [String] The subscription type (e.g., "channel.follow") + # @param version [String] The version of the subscription type + # @param condition [Hash] Subscription-specific parameters + # @param callback_url [String] URL where notifications will be sent + # @param secret [String] Secret used to verify webhook signatures + # @return [Twitch::Response] Response containing subscription details + def create_webhook_subscription(type:, condition:, callback_url:, secret:, version: 1) + initialize_response nil, post('eventsub/subscriptions', { type: type, version: version, condition: condition, @@ -16,12 +22,12 @@ def create_webhook_subscription(type:, condition:, callback_url:, secret:, versi secret: secret } }) - - puts response.data - puts response.raw end - ## https://dev.twitch.tv/docs/api/reference#get-eventsub-subscriptions + # Get list of webhook subscriptions + # @param status [String, nil] Filter by subscription status + # @param type [String, nil] Filter by subscription type + # @return [Twitch::Response] Response containing list of subscriptions def get_webhook_subscriptions(status: nil, type: nil) params = {} params[:status] = status if status @@ -30,7 +36,9 @@ def get_webhook_subscriptions(status: nil, type: nil) initialize_response nil, get('eventsub/subscriptions', params) end - ## https://dev.twitch.tv/docs/api/reference#delete-eventsub-subscription + # Delete a webhook subscription + # @param id [String] ID of the subscription to delete + # @return [Twitch::Response] Empty response if successful def delete_webhook_subscription(id:) initialize_response nil, delete('eventsub/subscriptions', { id: id }) end diff --git a/lib/twitch/webhook_config.rb b/lib/twitch/webhook_config.rb new file mode 100644 index 0000000..0bc16fb --- /dev/null +++ b/lib/twitch/webhook_config.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Twitch + # Configuration class for Twitch webhooks + class WebhookConfig + class << self + attr_accessor :secret, :callback_url, :client, :logger + + def configure + yield self + end + + def setup_handlers(&on_setup) + # Allow application to do any necessary setup before registering handlers + on_setup&.call + + # Setup handlers with client + WebhookHandler.setup_client(client, logger) + end + + def log(message) + return unless logger + + logger.info(message) + end + + def log_error(message) + return unless logger + + logger.error(message) + end + end + end +end diff --git a/lib/twitch/webhook_event.rb b/lib/twitch/webhook_event.rb new file mode 100644 index 0000000..8897492 --- /dev/null +++ b/lib/twitch/webhook_event.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Twitch + # Represents a webhook event notification from Twitch + class WebhookEvent + # The subscription information + attr_reader :subscription + # The event data + attr_reader :event + # The timestamp of when the notification was sent + attr_reader :timestamp + + def initialize(attributes = {}) + @subscription = attributes['subscription'] + @event = attributes['event'] + @timestamp = Time.parse(attributes['timestamp']) if attributes['timestamp'] + end + + # The type of the event (e.g., "channel.follow") + def type + subscription['type'] + end + + # The version of the subscription + def version + subscription['version'] + end + + # The status of the subscription + def status + subscription['status'] + end + + # The condition parameters for this subscription + def condition + subscription['condition'] + end + + # Whether this is a revocation event + def revoked? + status == 'revoked' + end + end +end diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index f30354c..d34778c 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -33,28 +33,17 @@ def subscribe_to(type, version: '1', **condition) } end - # rubocop:disable Metrics/MethodLength def register_subscriptions(client) subscription_types.each do |subscription| - hash = { - type: subscription[:type], - version: subscription[:version], - condition: subscription[:condition], - callback_url: webhook_callback_url, - secret: webhook_secret - } - - puts hash - client.create_webhook_subscription( type: subscription[:type], version: subscription[:version], condition: subscription[:condition], - callback_url: webhook_callback_url, - secret: webhook_secret + callback_url: WebhookConfig.callback_url, + secret: WebhookConfig.secret ) rescue Twitch::APIError => e - Rails.logger.error "Failed to register webhook subscription: #{e.message}" + WebhookConfig.log_error("Failed to register webhook subscription: #{e.message}") end end end @@ -66,13 +55,13 @@ def handlers def register_handler(handler_class) handlers << handler_class - puts "Registered handler: #{handler_class}" + WebhookConfig.log("Registered Twitch webhook handler: #{handler_class}") end def setup_client(client) - puts "Setting up client for #{handlers.count} handlers" + WebhookConfig.log("Setting up Twitch webhook client for #{handlers.count} handlers") handlers.each do |handler_class| - puts "Configuring handler: #{handler_class}" + WebhookConfig.log("Configuring handler: #{handler_class}") handler_class.twitch_client = client handler_class.register_subscriptions(client) end @@ -96,7 +85,7 @@ def receive def verify_twitch_signature! message = build_hmac_message - hmac = "#{HMAC_PREFIX}#{generate_hmac(webhook_secret, message)}" + hmac = "#{HMAC_PREFIX}#{generate_hmac(WebhookConfig.secret, message)}" return if secure_compare(hmac, request.headers[HEADER_MESSAGE_SIGNATURE]) head :forbidden @@ -136,14 +125,6 @@ def handle_revocation head :no_content end - def webhook_secret - raise NotImplementedError, 'Define webhook_secret in your controller' - end - - def webhook_callback_url - raise NotImplementedError, 'Define webhook_callback_url in your controller' - end - def process_event(type, data) raise NotImplementedError, 'Define process_event in your controller' end From 806b2e4a5852f045bdf079cf5d52b366e1b3c624 Mon Sep 17 00:00:00 2001 From: "Arthur G." Date: Sun, 5 Jan 2025 15:29:27 +0100 Subject: [PATCH 9/9] fixing --- lib/twitch/webhook_config.rb | 2 +- lib/twitch/webhook_handler.rb | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/twitch/webhook_config.rb b/lib/twitch/webhook_config.rb index 0bc16fb..d910f4d 100644 --- a/lib/twitch/webhook_config.rb +++ b/lib/twitch/webhook_config.rb @@ -15,7 +15,7 @@ def setup_handlers(&on_setup) on_setup&.call # Setup handlers with client - WebhookHandler.setup_client(client, logger) + WebhookHandler.setup_client(client) end def log(message) diff --git a/lib/twitch/webhook_handler.rb b/lib/twitch/webhook_handler.rb index d34778c..d716882 100644 --- a/lib/twitch/webhook_handler.rb +++ b/lib/twitch/webhook_handler.rb @@ -24,17 +24,24 @@ module WebhookHandler class_methods do def subscribe_to(type, version: '1', **condition) - stringified_condition = condition.transform_values(&:to_s) + transformed_condition = condition.transform_values do |value| + value.respond_to?(:call) ? value.call : value.to_s + end subscription_types << { type: type, version: version.to_s, - condition: stringified_condition + condition: transformed_condition } end def register_subscriptions(client) subscription_types.each do |subscription| + WebhookConfig.log('Processing subscription:') + WebhookConfig.log("Type: #{subscription[:type]}") + WebhookConfig.log("Version: #{subscription[:version]}") + WebhookConfig.log("Condition: #{subscription[:condition].inspect}") + client.create_webhook_subscription( type: subscription[:type], version: subscription[:version],