Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/twitch-api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 4 additions & 0 deletions lib/twitch/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
require_relative 'user_follow'
require_relative 'redemption'
require_relative 'video'
require_relative 'webhook_handler'

module Twitch
# Core class for requests
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions lib/twitch/client/webhooks.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions lib/twitch/webhook_config.rb
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions lib/twitch/webhook_event.rb
Original file line number Diff line number Diff line change
@@ -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
143 changes: 143 additions & 0 deletions lib/twitch/webhook_handler.rb
Original file line number Diff line number Diff line change
@@ -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