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.rb b/lib/twitch/client.rb index c29f85d..6692d20 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,9 @@ def get_videos(options = {}) require_relative 'client/custom_rewards' include CustomRewards + require_relative 'client/webhooks' + include 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..d6e7342 --- /dev/null +++ b/lib/twitch/client/webhooks.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Twitch + class Client + # Module for EventSub webhook functionality + module Webhooks + # 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, + transport: { + method: 'webhook', + callback: callback_url, + secret: secret + } + }) + end + + # 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 + params[:type] = type if type + + initialize_response nil, get('eventsub/subscriptions', params) + end + + # 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 + end + + include Webhooks + end +end diff --git a/lib/twitch/webhook_config.rb b/lib/twitch/webhook_config.rb new file mode 100644 index 0000000..d910f4d --- /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) + 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 new file mode 100644 index 0000000..d716882 --- /dev/null +++ b/lib/twitch/webhook_handler.rb @@ -0,0 +1,143 @@ +# 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, version: '1', **condition) + 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: 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], + condition: subscription[:condition], + callback_url: WebhookConfig.callback_url, + secret: WebhookConfig.secret + ) + rescue Twitch::APIError => e + WebhookConfig.log_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 + WebhookConfig.log("Registered Twitch webhook handler: #{handler_class}") + end + + def setup_client(client) + WebhookConfig.log("Setting up Twitch webhook client for #{handlers.count} handlers") + handlers.each do |handler_class| + WebhookConfig.log("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(WebhookConfig.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 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