diff --git a/api/eventsub/models.go b/api/eventsub/models.go index 6ab3f71..5a181f5 100644 --- a/api/eventsub/models.go +++ b/api/eventsub/models.go @@ -36,6 +36,10 @@ type Subscription struct { Cost int `json:"cost"` } +func (s Subscription) SubType() SubscriptionType { + return SubscriptionType{Name: s.Type, Version: s.Version} +} + type Status string const ( diff --git a/eventsub/conduit/conduit.go b/eventsub/conduit/conduit.go new file mode 100644 index 0000000..c4dde5c --- /dev/null +++ b/eventsub/conduit/conduit.go @@ -0,0 +1 @@ +package conduit diff --git a/eventsub/events/channel.go b/eventsub/events/channel.go new file mode 100644 index 0000000..b81661d --- /dev/null +++ b/eventsub/events/channel.go @@ -0,0 +1,377 @@ +package events + +import "time" + +type ChannelAdBreakBeginEvent struct { + // Length in seconds of the mid-roll ad break requested + DurationSeconds int `json:"duration_seconds"` + + // The UTC timestamp of when the ad break began, in RFC3339 format. Note that there is potential delay between this event, when the streamer requested the ad break, and when the viewers will see ads. + StartedAt string `json:"started_at"` + + // Indicates if the ad was automatically scheduled via Ads Manager + IsAutomatic bool `json:"is_automatic"` + + // The broadcaster’s user ID for the channel the ad was run on. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster’s user login for the channel the ad was run on. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s user display name for the channel the ad was run on. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The ID of the user that requested the ad. For automatic ads, this will be the ID of the broadcaster. + RequesterUserID string `json:"requester_user_id"` + + // The login of the user that requested the ad. + RequesterUserLogin string `json:"requester_user_login"` + + // The display name of the user that requested the ad. + RequesterUserName string `json:"requester_user_name"` +} + +type ChannelSubscribeEvent struct { + // The user ID for the user who subscribed to the specified channel. + UserID string `json:"user_id"` + + // The user login for the user who subscribed to the specified channel. + UserLogin string `json:"user_login"` + + // The user display name for the user who subscribed to the specified channel. + UserName string `json:"user_name"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The tier of the subscription. Valid values are 1000, 2000, and 3000. + Tier string `json:"tier"` + + // Whether the subscription is a gift. + IsGift bool `json:"is_gift"` +} + +type ChannelCheerEvent struct { + + // Whether the user cheered anonymously or not. + IsAnonymous bool `json:"is_anonymous"` + + // The user ID for the user who cheered on the specified channel. This is null if is_anonymous is true. + UserID *string `json:"user_id"` + + // The user login for the user who cheered on the specified channel. This is null if is_anonymous is true. + UserLogin *string `json:"user_login"` + + // The user display name for the user who cheered on the specified channel. This is null if is_anonymous is true. + UserName *string `json:"user_name"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The message sent with the cheer. + Message string `json:"message"` + + // The number of bits cheered. + Bits int `json:"bits"` +} + +type ChannelUpdateEvent struct { + + // The broadcaster’s user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster’s user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The channel’s stream title. + Title string `json:"title"` + + // The channel’s broadcast language. + Language string `json:"language"` + + // The channel’s category ID. + CategoryID string `json:"category_id"` + + // The category name. + CategoryName string `json:"category_name"` + + // Array of content classification label IDs currently applied on the Channel. + // To retrieve a list of all possible IDs, use the Get Content Classification Labels API endpoint. + ContentClassificationLabels []string `json:"content_classification_labels"` +} + +type ChannelUnbanEvent struct { + + // The user ID for the user who was unbanned on the specified channel. + UserID string `json:"user_id"` + + // The user login for the user who was unbanned on the specified channel. + UserLogin string `json:"user_login"` + + // The user display name for the user who was unbanned on the specified channel. + UserName string `json:"user_name"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the issuer of the unban. + ModeratorUserID string `json:"moderator_user_id"` + + // The user login of the issuer of the unban. + ModeratorUserLogin string `json:"moderator_user_login"` + + // The user name of the issuer of the unban. + ModeratorUserName string `json:"moderator_user_name"` +} + +type ChannelFollowEvent struct { + // The user ID for the user now following the specified channel. + UserID string `json:"user_id"` + + // The user login for the user now following the specified channel. + UserLogin string `json:"user_login"` + + // The user display name for the user now following the specified channel. + UserName string `json:"user_name"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // RFC3339 timestamp of when the follow occurred. + FollowedAt time.Time `json:"followed_at"` +} + +type ChannelRaidEvent struct { + // The broadcaster ID that created the raid. + FromBroadcasterUserID string `json:"from_broadcaster_user_id"` + + // The broadcaster login that created the raid. + FromBroadcasterUserLogin string `json:"from_broadcaster_user_login"` + + // The broadcaster display name that created the raid. + FromBroadcasterUserName string `json:"from_broadcaster_user_name"` + + // The broadcaster ID that received the raid. + ToBroadcasterUserID string `json:"to_broadcaster_user_id"` + + // The broadcaster login that received the raid. + ToBroadcasterUserLogin string `json:"to_broadcaster_user_login"` + + // The broadcaster display name that received the raid. + ToBroadcasterUserName string `json:"to_broadcaster_user_name"` + + // The number of viewers in the raid. + Viewers int `json:"viewers"` +} + +type ChannelModeratorAddEvent struct { + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the new moderator. + UserID string `json:"user_id"` + + // The user login of the new moderator. + UserLogin string `json:"user_login"` + + // The display name of the new moderator. + UserName string `json:"user_name"` +} + +type ChannelModeratorRemoveEvent struct { + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the removed moderator. + UserID string `json:"user_id"` + + // The user login of the removed moderator. + UserLogin string `json:"user_login"` + + // The display name of the removed moderator. + UserName string `json:"user_name"` +} + +type ChannelSubscriptionEndEvent struct { + + // The user ID for the user whose subscription ended. + UserId string `json:"user_id"` + + // The user login for the user whose subscription ended. + UserLogin string `json:"user_login"` + + // The user display name for the user whose subscription ended. + UserName string `json:"user_name"` + + // The broadcaster user ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The tier of the subscription that ended. Valid values are 1000, 2000, and 3000. + Tier string `json:"tier"` + + // Whether the subscription was a gift. + IsGift bool `json:"is_gift"` +} + +type ChannelSubscriptionGiftEvent struct { + // The user ID of the user who sent the subscription gift. Set to null if it was an anonymous subscription gift. + UserId *string `json:"user_id"` + + // The user login of the user who sent the gift. Set to null if it was an anonymous subscription gift. + UserLogin *string `json:"user_login"` + + // The user display name of the user who sent the gift. Set to null if it was an anonymous subscription gift. + UserName *string `json:"user_name"` + + // The broadcaster user ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The number of subscriptions in the subscription gift. + Total int `json:"total"` + + // The tier of subscriptions in the subscription gift. + Tier string `json:"tier"` + + // The number of subscriptions gifted by this user in the channel. + // This value is null for anonymous gifts or if the gifter has opted out of sharing this information. + CumulativeTotal *int `json:"cumulative_total"` + + // Whether the subscription gift was anonymous. + IsAnonymous bool `json:"is_anonymous"` +} + +type Emote struct { + // The index of where the Emote starts in the text. + Begin int `json:"begin"` + + // The index of where the Emote ends in the text. + End int `json:"end"` + + // The emote ID. + Id string `json:"id"` +} + +type Message struct { + // The text of the resubscription chat message. + Text string `json:"text"` + + // An array that includes the emote ID and start and end positions for where the emote appears in the text. + Emotes []Emote `json:"emotes"` +} + +type ChannelSubscriptionMessageEvent struct { + // The user ID of the user who sent a resubscription chat message. + UserId string `json:"user_id"` + + // The user login of the user who sent a resubscription chat message. + UserLogin string `json:"user_login"` + + // The user display name of the user who sent a resubscription chat message. + UserName string `json:"user_name"` + + // The broadcaster user ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The tier of the user’s subscription. + Tier string `json:"tier"` + + // An object that contains the resubscription message and emote information needed to recreate the message. + Message Message `json:"message"` + + // The total number of months the user has been subscribed to the channel. + CumulativeMonths int `json:"cumulative_months"` + + // The number of consecutive months the user’s current subscription has been active. + // This value is null if the user has opted out of sharing this information. + StreakMonths *int `json:"streak_months"` + + // The month duration of the subscription. + DurationMonths int `json:"duration_months"` +} + +// Defines the Shield Mode data that you receive when the channel.shield_mode.begin and channel.shield_mode.end events occur. +type ShieldModeEvent struct { + // An ID that identifies the broadcaster whose Shield Mode status was updated. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster’s login name. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // An ID that identifies the moderator that updated the Shield Mode’s status. If the broadcaster updated the status, this ID will be the same as broadcaster_user_id. + ModeratorUserId string `json:"moderator_user_id"` + + // The moderator’s login name. + ModeratorUserLogin string `json:"moderator_user_login"` + + // The moderator’s display name. + ModeratorUserName string `json:"moderator_user_name"` + + // The UTC timestamp (in RFC3339 format) of when the moderator activated Shield Mode. + // The object includes this field only for channel.shield_mode.begin events. + StartedAt time.Time `json:"started_at"` + + // The UTC timestamp (in RFC3339 format) of when the moderator deactivated Shield Mode. + // The object includes this field only for channel.shield_mode.end events. + EndedAt time.Time `json:"ended_at"` +} diff --git a/eventsub/events/channelpoints.go b/eventsub/events/channelpoints.go new file mode 100644 index 0000000..a8eef29 --- /dev/null +++ b/eventsub/events/channelpoints.go @@ -0,0 +1,313 @@ +package events + +import "time" + +type MaxPerStream struct { + // Is the setting enabled. + IsEnabled bool `json:"is_enabled"` + + // The max per stream limit. + Value int `json:"value"` +} + +type MaxPerUserPerStream struct { + // Is the setting enabled. + IsEnabled bool `json:"is_enabled"` + + // The max per user per stream limit. + Value int `json:"value"` +} + +type Image struct { + // URL for the image at 1x size. + URL1x string `json:"url_1x"` + + // URL for the image at 2x size. + URL2x string `json:"url_2x"` + + // URL for the image at 4x size. + URL4x string `json:"url_4x"` +} + +type GlobalCooldown struct { + // Is the setting enabled. + IsEnabled bool `json:"is_enabled"` + + // The cooldown in seconds. + Seconds int `json:"seconds"` +} + +type ChannelPointsCustomRewardAddEvent struct { + // The reward identifier. + ID string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Is the reward currently enabled. If false, the reward won’t show up to viewers. + IsEnabled bool `json:"is_enabled"` + + // Is the reward currently paused. If true, viewers can’t redeem. + IsPaused bool `json:"is_paused"` + + // Is the reward currently in stock. If false, viewers can’t redeem. + IsInStock bool `json:"is_in_stock"` + + // The reward title. + Title string `json:"title"` + + // The reward cost. + Cost int `json:"cost"` + + // The reward description. + Prompt string `json:"prompt"` + + // Does the viewer need to enter information when redeeming the reward. + IsUserInputRequired bool `json:"is_user_input_required"` + + // Should redemptions be set to fulfilled status immediately when redeemed and skip the request queue instead of the normal unfulfilled status. + ShouldRedemptionsSkipRequestQueue bool `json:"should_redemptions_skip_request_queue"` + + // Whether a maximum per stream is enabled and what the maximum is. + MaxPerStream MaxPerStream `json:"max_per_stream"` + + // Whether a maximum per user per stream is enabled and what the maximum is. + MaxPerUserPerStream MaxPerUserPerStream `json:"max_per_user_per_stream"` + + // Custom background color for the reward. Format: Hex with # prefix. Example: #FA1ED2. + BackgroundColor string `json:"background_color"` + + // Set of custom images of 1x, 2x and 4x sizes for the reward. Can be null if no images have been uploaded. + Image *Image `json:"image"` + + // Set of default images of 1x, 2x and 4x sizes for the reward. + DefaultImage Image `json:"default_image"` + + // Whether a cooldown is enabled and what the cooldown is in seconds. + GlobalCooldown GlobalCooldown `json:"global_cooldown"` + + // Timestamp of the cooldown expiration. null if the reward isn’t on cooldown. + CooldownExpiresAt *time.Time `json:"cooldown_expires_at"` + + // The number of redemptions redeemed during the current live stream. Counts against the max_per_stream limit. + // null if the broadcasters stream isn’t live or max_per_stream isn’t enabled. + RedemptionsRedeemedCurrentStream *int `json:"redemptions_redeemed_current_stream"` +} + +type ChannelPointsCustomRewardUpdateEvent struct { + // The reward identifier. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Is the reward currently enabled. If false, the reward won’t show up to viewers. + IsEnabled bool `json:"is_enabled"` + + // Is the reward currently paused. If true, viewers can’t redeem. + IsPaused bool `json:"is_paused"` + + // Is the reward currently in stock. If false, viewers can’t redeem. + IsInStock bool `json:"is_in_stock"` + + // The reward title. + Title string `json:"title"` + + // The reward cost. + Cost int `json:"cost"` + + // The reward description. + Prompt string `json:"prompt"` + + // Does the viewer need to enter information when redeeming the reward. + IsUserInputRequired bool `json:"is_user_input_required"` + + // Should redemptions be set to fulfilled status immediately when redeemed and skip the request queue instead of the normal unfulfilled status. + ShouldRedemptionsSkipRequestQueue bool `json:"should_redemptions_skip_request_queue"` + + // Whether a maximum per stream is enabled and what the maximum is. + MaxPerStream MaxPerStream `json:"max_per_stream"` + + // Whether a maximum per user per stream is enabled and what the maximum is. + MaxPerUserPerStream MaxPerUserPerStream `json:"max_per_user_per_stream"` + + // Custom background color for the reward. Format: Hex with # prefix. Example: #FA1ED2. + BackgroundColor string `json:"background_color"` + + // Set of custom images of 1x, 2x and 4x sizes for the reward. Can be null if no images have been uploaded. + Image *Image `json:"image"` + + // Set of default images of 1x, 2x and 4x sizes for the reward. + DefaultImage Image `json:"default_image"` + + // Whether a cooldown is enabled and what the cooldown is in seconds. + GlobalCooldown GlobalCooldown `json:"global_cooldown"` + + // Timestamp of the cooldown expiration. null if the reward isn’t on cooldown. + CooldownExpiresAt *time.Time `json:"cooldown_expires_at"` + + // The number of redemptions redeemed during the current live stream. Counts against the max_per_stream limit. + // null if the broadcasters stream isn’t live or max_per_stream isn’t enabled. + RedemptionsRedeemedCurrentStream *int `json:"redemptions_redeemed_current_stream"` +} + +type ChannelPointsCustomRewardRemoveEvent struct { + // The reward identifier. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Is the reward currently enabled. If false, the reward won’t show up to viewers. + IsEnabled bool `json:"is_enabled"` + + // Is the reward currently paused. If true, viewers can’t redeem. + IsPaused bool `json:"is_paused"` + + // Is the reward currently in stock. If false, viewers can’t redeem. + IsInStock bool `json:"is_in_stock"` + + // The reward title. + Title string `json:"title"` + + // The reward cost. + Cost int `json:"cost"` + + // The reward description. + Prompt string `json:"prompt"` + + // Does the viewer need to enter information when redeeming the reward. + IsUserInputRequired bool `json:"is_user_input_required"` + + // Should redemptions be set to fulfilled status immediately when redeemed and skip the request queue instead of the normal unfulfilled status. + ShouldRedemptionsSkipRequestQueue bool `json:"should_redemptions_skip_request_queue"` + + // Whether a maximum per stream is enabled and what the maximum is. + MaxPerStream MaxPerStream `json:"max_per_stream"` + + // Whether a maximum per user per stream is enabled and what the maximum is. + MaxPerUserPerStream MaxPerStream `json:"max_per_user_per_stream"` + + // Custom background color for the reward. Format: Hex with # prefix. Example: #FA1ED2. + BackgroundColor string `json:"background_color"` + + // Set of custom images of 1x, 2x and 4x sizes for the reward. Can be null if no images have been uploaded. + Image *Image `json:"image"` + + // Set of default images of 1x, 2x and 4x sizes for the reward. + DefaultImage Image `json:"default_image"` + + // Whether a cooldown is enabled and what the cooldown is in seconds. + GlobalCooldown GlobalCooldown `json:"global_cooldown"` + + // Timestamp of the cooldown expiration. null if the reward isn’t on cooldown. + CooldownExpiresAt *time.Time `json:"cooldown_expires_at"` + + // The number of redemptions redeemed during the current live stream. Counts against the max_per_stream limit. + // null if the broadcasters stream isn’t live or max_per_stream isn’t enabled. + RedemptionsRedeemedCurrentStream *int `json:"redemptions_redeemed_current_stream"` +} + +type Reward struct { + // The reward identifier. + Id string `json:"id"` + + // The reward title. + Title string `json:"title"` + + // The reward cost. + Cost int `json:"cost"` + + // The reward prompt. + Prompt string `json:"prompt"` +} + +type ChannelPointsCustomRewardRedemptionAddEvent struct { + + // The redemption identifier. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // User ID of the user that redeemed the reward. + UserId string `json:"user_id"` + + // Login of the user that redeemed the reward. + UserLogin string `json:"user_login"` + + // Display name of the user that redeemed the reward. + UserName string `json:"user_name"` + + // The user input provided. Empty string if not provided. + UserInput string `json:"user_input"` + + // Defaults to unfulfilled. Possible values are unknown, unfulfilled, fulfilled, and canceled. + Status string `json:"status"` + + // Basic information about the reward that was redeemed, at the time it was redeemed. + Reward Reward `json:"reward"` + + // RFC3339 timestamp of when the reward was redeemed. + RedeemedAt time.Time `json:"redeemed_at"` +} + +type ChannelPointsCustomRewardRedemptionUpdateEvent struct { + + // The redemption identifier. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // User ID of the user that redeemed the reward. + UserId string `json:"user_id"` + + // Login of the user that redeemed the reward. + UserLogin string `json:"user_login"` + + // Display name of the user that redeemed the reward. + UserName string `json:"user_name"` + + // The user input provided. Empty string if not provided. + UserInput string `json:"user_input"` + + // Defaults to unfulfilled. Possible values are unknown, unfulfilled, fulfilled, and canceled. + Status string `json:"status"` + + // Basic information about the reward that was redeemed, at the time it was redeemed. + Reward Reward `json:"reward"` + + // RFC3339 timestamp of when the reward was redeemed. + RedeemedAt time.Time `json:"redeemed_at"` +} diff --git a/eventsub/events/charity.go b/eventsub/events/charity.go new file mode 100644 index 0000000..0bb0c78 --- /dev/null +++ b/eventsub/events/charity.go @@ -0,0 +1,237 @@ +package events + +import "time" + +type CharityDonationEvent struct { + // An ID that identifies the donation. The ID is unique across campaigns. + Id string `json:"id"` + + // An ID that identifies the charity campaign. + CampaignId string `json:"campaign_id"` + + // An ID that identifies the broadcaster that’s running the campaign. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster’s login name. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // An ID that identifies the user that donated to the campaign. + UserId string `json:"user_id"` + + // The user’s login name. + UserLogin string `json:"user_login"` + + // The user’s display name. + UserName string `json:"user_name"` + + // The charity’s name. + CharityName string `json:"charity_name"` + + // A description of the charity. + CharityDescription string `json:"charity_description"` + + // A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px. + CharityLogo string `json:"charity_logo"` + + // A URL to the charity’s website. + CharityWebsite string `json:"charity_website"` + + // An object that contains the amount of money that the user donated. + Amount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"amount"` +} + +type CharityCampaignStartEvent struct { + // An ID that identifies the charity campaign. + Id string `json:"id"` + + // An ID that identifies the broadcaster that’s running the campaign. + BroadcasterId string `json:"broadcaster_id"` + + // The broadcaster’s login name. + BroadcasterLogin string `json:"broadcaster_login"` + + // The broadcaster’s display name. + BroadcasterName string `json:"broadcaster_name"` + + // The charity’s name. + CharityName string `json:"charity_name"` + + // A description of the charity. + CharityDescription string `json:"charity_description"` + + // A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px. + CharityLogo string `json:"charity_logo"` + + // A URL to the charity’s website. + CharityWebsite string `json:"charity_website"` + + // An object that contains the current amount of donations that the campaign has received. + CurrentAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"current_amount"` + + // An object that contains the campaign’s target fundraising goal. + TargetAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"target_amount"` + + // The UTC timestamp (in RFC3339 format) of when the broadcaster started the campaign. + StartedAt time.Time `json:"started_at"` +} + +type CharityCampaignProgressEvent struct { + // An ID that identifies the charity campaign. + Id string `json:"id"` + + // An ID that identifies the broadcaster that’s running the campaign. + BroadcasterId string `json:"broadcaster_id"` + + // The broadcaster’s login name. + BroadcasterLogin string `json:"broadcaster_login"` + + // The broadcaster’s display name. + BroadcasterName string `json:"broadcaster_name"` + + // The charity's name. + CharityName string `json:"charity_name"` + + // A description of the charity. + CharityDescription string `json:"charity_description"` + + // A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px. + CharityLogo string `json:"charity_logo"` + + // A URL to the charity’s website. + CharityWebsite string `json:"charity_website"` + + // An object that contains the current amount of donations that the campaign has received. + CurrentAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"current_amount"` + + // An object that contains the campaign’s target fundraising goal. + TargetAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"target_amount"` +} + +type CharityCampaignStopEvent struct { + // An ID that identifies the charity campaign. + Id string `json:"id"` + + // An ID that identifies the broadcaster that ran the campaign. + BroadcasterId string `json:"broadcaster_id"` + + // The broadcaster’s login name. + BroadcasterLogin string `json:"broadcaster_login"` + + // The broadcaster’s display name. + BroadcasterName string `json:"broadcaster_name"` + + // The charity's name + CharityName string `json:"charity_name"` + + // A description of the charity. + CharityDescription string `json:"charity_description"` + + // A URL to an image of the charity’s logo. The image’s type is PNG and its size is 100px X 100px. + CharityLogo string `json:"charity_logo"` + + // A URL to the charity’s website. + CharityWebsite string `json:"charity_website"` + + // An object that contains the final amount of donations that the campaign received. + CurrentAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"current_amount"` + + // An object that contains the campaign’s target fundraising goal. + TargetAmount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. For example, USD uses two decimal places. + // Use this number to translate value from minor units to major units by using the formula: + // + // value / 10^decimal_places + DecimalPlaces int `json:"decimal_places"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"target_amount"` + + // The UTC timestamp (in RFC3339 format) of when the broadcaster stopped the campaign. + StoppedAt string `json:"stopped_at"` +} diff --git a/eventsub/events/chat.go b/eventsub/events/chat.go new file mode 100644 index 0000000..ebbd302 --- /dev/null +++ b/eventsub/events/chat.go @@ -0,0 +1,552 @@ +package events + +import "time" + +type ChannelBanEvent struct { + // The user ID for the user who was banned on the specified channel. + UserID string `json:"user_id"` + + // The user login for the user who was banned on the specified channel. + UserLogin string `json:"user_login"` + + // The user display name for the user who was banned on the specified channel. + UserName string `json:"user_name"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the issuer of the ban. + ModeratorUserID string `json:"moderator_user_id"` + + // The user login of the issuer of the ban. + ModeratorUserLogin string `json:"moderator_user_login"` + + // The user name of the issuer of the ban. + ModeratorUserName string `json:"moderator_user_name"` + + // The reason behind the ban. + Reason string `json:"reason"` + + // The UTC date and time (in RFC3339 format) of when the user was banned or put in a timeout. + BannedAt time.Time `json:"banned_at"` + + // The UTC date and time (in RFC3339 format) of when the timeout ends. + // Is null if the user was banned instead of put in a timeout. + EndsAt *time.Time `json:"ends_at"` + + // Indicates whether the ban is permanent (true) or a timeout (false). If true, ends_at will be null. + IsPermanent bool `json:"is_permanent"` +} + +type ChannelChatClearEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` +} + +type ChannelChatClearUserMessagesEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The ID of the user that was banned or put in a timeout. All of their messages are deleted. + TargetUserID string `json:"target_user_id"` + + // The user name of the user that was banned or put in a timeout. + TargetUserName string `json:"target_user_name"` + + // The user login of the user that was banned or put in a timeout. + TargetUserLogin string `json:"target_user_login"` +} + +type Badge struct { + // An ID that identifies this set of chat badges. For example, Bits or Subscriber. + SetID string `json:"set_id"` + + // An ID that identifies this version of the badge. + // The ID can be any value. For example, for Bits, the ID is the Bits tier level, but for World of Warcraft, it could be Alliance or Horde. + ID string `json:"id"` + + // Contains metadata related to the chat badges in the badges tag. + // Currently, this tag contains metadata only for subscriber badges, to indicate the number of months the user has been a subscriber. + Info string `json:"info"` +} + +type MessageData struct { + // The chat message in plain text. + Text string `json:"text"` + + // Ordered list of chat message fragments. + Framgments []struct { + // The type of chat message fragment. + // Possible values: + // + // text, cheermote, emote, mention + Type string `json:"type"` + + // Message text in fragment. + Text string `json:"text"` + + // Metadata pertaining to the cheermote. + Cheermote *struct { + // The name portion of the Cheermote string that you use in chat to cheer Bits. + // The full Cheermote string is the concatenation of {prefix} + {number of Bits}. + // For example, if the prefix is “Cheer” and you want to cheer 100 Bits, the full Cheermote string is Cheer100. + // When the Cheermote string is entered in chat, Twitch converts it to the image associated with the Bits tier that was cheered. + Prefix string `json:"prefix"` + + // The amount of bits cheered. + Bits int `json:"bits"` + + // The tier level of the cheermote. + Tier int `json:"tier"` + } `json:"cheermote"` + + // Metadata pertaining to the emote. + Emote *struct { + // An ID that uniquely identifies this emote. + ID string `json:"id"` + + // An ID that identifies the emote set that the emote belongs to. + EmoteSetID string `json:"emote_set_id"` + + // The ID of the broadcaster who owns the emote. + OwnerID string `json:"owner_id"` + + // The formats that the emote is available in. + // For example, if the emote is available only as a static PNG, the array contains only static. + // But if the emote is available as a static PNG and an animated GIF, the array contains static and animated. + // The possible formats are: + // + // animated - An animated GIF is available for this emote. + // + // static - A static PNG file is available for this emote. + Format []string `json:"format"` + } `json:"emote"` + + // Metadata pertaining to the mention. + Mention *struct { + // The user ID of the mentioned user. + UserID string `json:"user_id"` + + // The user name of the mentioned user. + UserName string `json:"user_name"` + + // The user login of the mentioned user. + UserLogin string `json:"user_login"` + } `json:"mention"` + } +} + +type ChannelChatMessageEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the user that sent the message. + ChatterUserID string `json:"chatter_user_id"` + + // The user name of the user that sent the message. + ChatterUserName string `json:"chatter_user_name"` + + // The user login of the user that sent the message. + ChatterUserLogin string `json:"chatter_user_login"` + + // A UUID that identifies the message. + MessageID string `json:"message_id"` + + // The structured chat message. + Message MessageData `json:"message"` + + // The type of message. Possible values: + // + // text, channel_points_highlighted, channel_points_sub_only, user_intro + MessageType string `json:"message_type"` + + // List of chat badges. + Badges []Badge `json:"badges"` + + // Metadata if this message is a cheer. + Cheer *struct { + // The amount of Bits the user cheered. + Bits int `json:"bits"` + } + + // The color of the user’s name in the chat room. + // This is a hexadecimal RGB color code in the form, #. + // This tag may be empty if it is never set. + Color string `json:"color"` + + // Metadata if this message is a reply. + Reply *struct { + // An ID that uniquely identifies the parent message that this message is replying to. + ParentMessageID string `json:"parent_message_id"` + + // The message body of the parent message. + ParentMessageBody string `json:"parent_message_body"` + + // User ID of the sender of the parent message. + ParentUserID string `json:"parent_user_id"` + + // User name of the sender of the parent message. + ParentUserName string `json:"parent_user_name"` + + // User login of the sender of the parent message. + ParentUserLogin string `json:"parent_user_login"` + + // An ID that identifies the parent message of the reply thread. + ThreadMessageID string `json:"thread_message_id"` + + // User ID of the sender of the thread’s parent message. + ThreadUserID string `json:"thread_user_id"` + + // User name of the sender of the thread’s parent message. + ThreadUserName string `json:"thread_user_name"` + + // User login of the sender of the thread’s parent message. + ThreadUserLogin string `json:"thread_user_login"` + } `json:"reply"` + + // The ID of a channel points custom reward that was redeemed. + ChannelPointsCustomRewardID *string `json:"channel_points_custom_reward_id"` +} + +type ChannelChatMessageDeleteEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The ID of the user whose message was deleted. + TargetUserID string `json:"target_user_id"` + + // The user name of the user whose message was deleted. + TargetUserName string `json:"target_user_name"` + + // The user login of the user whose message was deleted. + TargetUserLogin string `json:"target_user_login"` + + // A UUID that identifies the message that was removed. + MessageID string `json:"message_id"` +} + +type ChannelChatNotificationEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster user name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The user ID of the user that sent the message. + ChatterUserID string `json:"chatter_user_id"` + + // The user login of the user that sent the message. + ChatterUserLogin string `json:"chatter_user_login"` + + // The user name of the user that sent the message. + ChatterUserName string `json:"chatter_user_name"` + + // Whether or not the chatter is anonymous. + ChatterIsAnonymous bool `json:"chatter_is_anonymous"` + + // The color of the user's name in the chat room. + Color string `json:"color"` + + // List of chat badges + Badges []Badge `json:"badges"` + + // The message Twitch shows in the chat room for this notice. + SystemMessage string `json:"system_message"` + + // A UUID that identifies the message. + MessageID string `json:"message_id"` + + // The structured chat message. + Message MessageData `json:"message"` + + // The type of notice. + // Possible values are: + // + // sub, resub, sub_gift, community_sub_gift, gift_paid_upgrade, raid, unraid, pay_it_forward, announcement, bits_badge_tier, charity_donation + NoticeType string `json:"notice_type"` + + // Information about the sub event. Null if notice_type is not sub. + Sub *struct { + // The type of subscription plan being used. Possible values are: + // + // 1000 - First level of paid or Prime subscription. + // + // 2000 - Second level of paid subscription. + // + // 3000 - Third level of paid subscription. + SubTier string `json:"sub_tier"` + + // Indicates if the subscription was obtained through Amazon Prime. + IsPrime bool `json:"is_prime"` + + // The number of months the subscription is for. + DurationMonths int `json:"duration_months"` + } `json:"sub"` + + // Information about the resub event. Null if notice_type is not resub. + Resub *struct { + // The total number of months the user has subscribed. + CumulativeMonths int `json:"cumulative_months"` + + // The number of months the subscription is for. + DurationMonths int `json:"duration_months"` + + // The total number of months the user has subscribed consecutively. + StreakMonths int `json:"streak_months"` + + // The type of subscription plan being used. Possible values are: + // + // 1000 - First level of paid or Prime subscription. + // + // 2000 - Second level of paid subscription. + // + // 3000 - Third level of paid subscription. + SubTier string `json:"sub_tier"` + + // Whether or not the resub was prime resub. + IsPrime bool `json:"is_prime"` + + // Whether or not the resub was a result of a gift + IsGift bool `json:"is_gift"` + + // Whether or not the gift was anonymous. + GifterIsAnonymous bool `json:"gifter_is_anonymous"` + + // The user ID of the user who sent the gift. + GifterUserID string `json:"gifter_user_id"` + + // The user login of the user who sent the gift. + GifterUserLogin string `json:"gifter_user_login"` + + // The user name of the user who sent the gift. + GifterUserName string `json:"gifter_user_name"` + } `json:"resub"` + + // Information about the gift sub event. Null if notice_type is not sub_gift. + SubGift *struct { + // The number of months the subscription is for. + DurationMonths int `json:"duration_months"` + + // The amount of gifts the gifter has given in this channel. Null if anonymous. + CumulativeTotal *int `json:"cumulative_total"` + + // The user ID of the subscription gift recipient. + RecipientUserID string `json:"recipient_user_id"` + + // The user login of the subscription gift recipient. + RecipientUserLogin string `json:"recipient_user_login"` + + // The user name of the subscription gift recipient. + RecipientUserName string `json:"recipient_user_name"` + + // The type of subscription plan being used. Possible values are: + // + // 1000 - First level of paid or Prime subscription. + // + // 2000 - Second level of paid subscription. + // + // 3000 - Third level of paid subscription. + SubTier string `json:"sub_tier"` + + // The ID of the associated community gift. Null if not associated with a community gift. + CommunityGiftID *string `json:"community_gift_id"` + } `json:"sub_gift"` + + // Information about the community gift sub event. Null if notice_type is not community_sub_gift. + CommunitySubGift *struct { + // The ID of the associated community gift. + ID string `json:"id"` + + // The number of subscriptions being gifted. + Total int `json:"total"` + + // The type of subscription plan being used. Possible values are: + // + // 1000 - First level of paid or Prime subscription. + // + // 2000 - Second level of paid subscription. + // + // 3000 - Third level of paid subscription. + SubTier string `json:"sub_tier"` + + // The amount of gifts the gifter has given in this channel. Null if anonymous. + CumulativeTotal *int `json:"cumulative_total"` + } `json:"community_sub_gift"` + + // Information about the community gift paid upgrade event. Null if notice_type is not gift_paid_upgrade. + GiftPaidUpgrade *struct { + // Whether the gift was given anonymously. + GifterIsAnonymous bool `json:"gifter_is_anonymous"` + + // The user ID of the user who gifted the subscription. Null if anonymous. + GifterUserID *string `json:"gifter_user_id"` + + // The user login of the user who gifted the subscription. Null if anonymous. + GifterUserLogin *string `json:"gifter_user_login"` + + // The user name of the user who gifted the subscription. Null if anonymous. + GifterUserName *string `json:"gifter_user_name"` + } `json:"gift_paid_upgrade"` + + // Information about the Prime gift paid upgrade event. Null if notice_type is not prime_paid_upgrade. + PrimePaidUpgrade *struct { + // The type of subscription plan being used. Possible values are: + // + // 1000 - First level of paid or Prime subscription. + // + // 2000 - Second level of paid subscription. + // + // 3000 - Third level of paid subscription. + SubTier string `json:"sub_tier"` + } `json:"prime_paid_upgrade"` + + // Information about the raid event. Null if notice_type is not raid. + Raid *struct { + // The user ID of the broadcaster raiding this channel. + UserID string `json:"user_id"` + + // The user name of the broadcaster raiding this channel. + UserName string `json:"user_name"` + + // The user login of the broadcaster raiding this channel. + UserLogin string `json:"user_login"` + + // The number of viewers raiding this channel from the broadcaster’s channel. + ViewerCount int `json:"viewer_count"` + + // Profile image URL of the broadcaster raiding this channel. + ProfileImageURL string `json:"profile_image_url"` + } `json:"raid"` + + // Returns an empty payload if notice_type is unraid, otherwise returns null. + Unraid *struct{} `json:"unraid"` + + // Information about the pay it forward event. Null if notice_type is not pay_it_forward. + PayItForward *struct { + // Whether the gift was given anonymously. + GifterIsAnonymous bool `json:"gifter_is_anonymous"` + + // The user ID of the user who gifted the subscription. Null if anonymous. + GifterUserID *string `json:"gifter_user_id"` + + // The user login of the user who gifted the subscription. Null if anonymous. + GifterUserLogin *string `json:"gifter_user_login"` + + // The user name of the user who gifted the subscription. Null if anonymous. + GifterUserName *string `json:"gifter_user_name"` + } `json:"pay_it_forward"` + + // Information about the announcement event. Null if notice_type is not announcement. + Announcement *struct { + // Color of the announcement. + Color string `json:"color"` + } `json:"announcement"` + + // Information about the charity donation event. Null if notice_type is not charity_donation. + CharityDonation *struct { + // Name of the charity. + CharityName string `json:"charity_name"` + + // An object that contains the amount of money that the user paid. + Amount struct { + // The monetary amount. The amount is specified in the currency’s minor unit. + // For example, the minor units for USD is cents, so if the amount is $5.50 USD, value is set to 550. + Value int `json:"value"` + + // The number of decimal places used by the currency. + // For example, USD uses two decimal places. + DecimalPlace int `json:"decimal_place"` + + // The ISO-4217 three-letter currency code that identifies the type of currency in value. + Currency string `json:"currency"` + } `json:"amount"` + } `json:"charity_donation"` + + // Information about the bits badge tier event. Null if notice_type is not bits_badge_tier. + BitsBadgeTier *struct { + // The tier of the Bits badge the user just earned. For example, 100, 1000, or 10000. + Tier int `json:"tier"` + } `json:"bits_badge_tier"` +} + +type ChannelChatSettingsUpdateEvent struct { + // The ID of the broadcaster specified in the request. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The login of the broadcaster specified in the request. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The user name of the broadcaster specified in the request. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // A Boolean value that determines whether chat messages must contain only emotes. + // True if only messages that are 100% emotes are allowed; otherwise false. + EmoteMode bool `json:"emote_mode"` + + // A Boolean value that determines whether the broadcaster restricts the chat room to followers only, based on how long they’ve followed. + // + // True if the broadcaster restricts the chat room to followers only; otherwise false. + // + // See follower_mode_duration_minutes for how long the followers must have followed the broadcaster to participate in the chat room. + FollowerMode bool `json:"follower_mode"` + + // The length of time, in minutes, that the followers must have followed the broadcaster to participate in the chat room. See follower_mode. + // + // Null if follower_mode is false. + FollowerModeDurationMinutes *int `json:"follower_mode_duration_minutes"` + + // A Boolean value that determines whether the broadcaster limits how often users in the chat room are allowed to send messages. + // + // Is true, if the broadcaster applies a delay; otherwise, false. + // + // See slow_mode_wait_time_seconds for the delay. + SlowMode bool `json:"slow_mode"` + + // The amount of time, in seconds, that users need to wait between sending messages. See slow_mode. + // + // Null if slow_mode is false. + SlowModeWaitTimeSeconds *int `json:"slow_mode_wait_time_seconds"` + + // A Boolean value that determines whether only users that subscribe to the broadcaster’s channel can talk in the chat room. + // + // True if the broadcaster restricts the chat room to subscribers only; otherwise false. + SubscriberMode bool `json:"subscriber_mode"` + + // A Boolean value that determines whether the broadcaster requires users to post only unique messages in the chat room. + // + // True if the broadcaster requires unique messages only; otherwise false. + UniqueChatMode bool `json:"unique_chat_mode"` +} diff --git a/eventsub/events/conduit.go b/eventsub/events/conduit.go new file mode 100644 index 0000000..d1b2cb6 --- /dev/null +++ b/eventsub/events/conduit.go @@ -0,0 +1,30 @@ +package events + +import "time" + +type ConduitShardDisabledEvent struct { + + // The ID of the conduit. + ConduitId string `json:"conduit_id"` + + // The ID of the disabled shard. + ShardId string `json:"shard_id"` + + // The new status of the transport. + Status string `json:"status"` + + // The disabled transport. + Transport map[string]interface{} `json:"transport"` + + // Webhook callback URL. Null if method is set to websocket. + Callback *string `json:"callback"` + + // WebSocket session ID. Null if method is set to webhook. + SessionId *string `json:"session_id"` + + // Time that the WebSocket session connected. Null if method is set to webhook. + ConnectedAt *time.Time `json:"connected_at"` + + // Time that the WebSocket session disconnected. Null if method is set to webhook. + DisconnectedAt *time.Time `json:"disconnected_at"` +} diff --git a/eventsub/events/drops.go b/eventsub/events/drops.go new file mode 100644 index 0000000..19bc95a --- /dev/null +++ b/eventsub/events/drops.go @@ -0,0 +1,41 @@ +package events + +import "time" + +type DropEntitlementGrantEvent struct { + // Individual event ID, as assigned by EventSub. Use this for de-duplicating messages. + Id string `json:"id"` + + // The entitlement object. + Data []struct { + // The ID of the organization that owns the game that has Drops enabled. + OrganizationId string `json:"organization_id"` + + // Twitch category ID of the game that was being played when this benefit was entitled. + CategoryId string `json:"category_id"` + + // The category name. + CategoryName string `json:"category_name"` + + // The campaign this entitlement is associated with. + CampaignId string `json:"campaign_id"` + + // Twitch user ID of the user who was granted the entitlement. + UserId string `json:"user_id"` + + // The user display name of the user who was granted the entitlement. + UserName string `json:"user_name"` + + // The user login of the user who was granted the entitlement. + UserLogin string `json:"user_login"` + + // Unique identifier of the entitlement. Use this to de-duplicate entitlements. + EntitlementId string `json:"entitlement_id"` + + // Identifier of the Benefit. + BenefitId string `json:"benefit_id"` + + // UTC timestamp in ISO format when this entitlement was granted on Twitch. + CreatedAt time.Time `json:"created_at"` + } `json:"data"` +} diff --git a/eventsub/events/events.go b/eventsub/events/events.go new file mode 100644 index 0000000..b3adf69 --- /dev/null +++ b/eventsub/events/events.go @@ -0,0 +1 @@ +package events diff --git a/eventsub/events/extensions.go b/eventsub/events/extensions.go new file mode 100644 index 0000000..81682b6 --- /dev/null +++ b/eventsub/events/extensions.go @@ -0,0 +1,44 @@ +package events + +type Product struct { + // Product name. + Name string `json:"name"` + + // Bits involved in the transaction. + Bits int `json:"bits"` + + // Unique identifier for the product acquired. + Sku string `json:"sku"` + + // Flag indicating if the product is in development. If in_development is true, bits will be 0. + InDevelopment bool `json:"in_development"` +} + +type ExtensionBitsTransactionCreateEvent struct { + // Client ID of the extension. + ExtensionClientId string `json:"extension_client_id"` + + // Transaction ID. + Id string `json:"id"` + + // The transaction’s broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The transaction’s broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The transaction’s broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The transaction’s user ID. + UserId string `json:"user_id"` + + // The transaction’s user login. + UserLogin string `json:"user_login"` + + // The transaction’s user display name. + UserName string `json:"user_name"` + + // Additional extension product information. + Product Product `json:"product"` +} diff --git a/eventsub/events/goals.go b/eventsub/events/goals.go new file mode 100644 index 0000000..4933e17 --- /dev/null +++ b/eventsub/events/goals.go @@ -0,0 +1,68 @@ +package events + +import "time" + +type GoalsEvent struct { + // An ID that identifies this event. + Id string `json:"id"` + + // An ID that uniquely identifies the broadcaster. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster's display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The broadcaster's user handle. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The type of goal. Possible values are: + // + // follow — The goal is to increase followers. + // + // subscription — The goal is to increase subscriptions. This type shows the net increase or decrease in tier points associated with the subscriptions. + // + // subscription_count — The goal is to increase subscriptions. This type shows the net increase or decrease in the number of subscriptions. + // + // new_subscription — The goal is to increase subscriptions. This type shows only the net increase in tier points associated with the subscriptions + // (it does not account for users that unsubscribed since the goal started). + // + // new_subscription_count — The goal is to increase subscriptions. This type shows only the net increase in the number of subscriptions + // (it does not account for users that unsubscribed since the goal started). + Type string `json:"type"` + + // A description of the goal, if specified. The description may contain a maximum of 40 characters. + Description string `json:"description"` + + // A Boolean value that indicates whether the broadcaster achieved their goal. Is true if the goal was achieved; otherwise, false. + IsAchieved bool `json:"is_achieved"` + + // The goal's current value. + // + // The goal's type determines how this value is increased or decreased. + // + // - If type is follow, this field is set to the broadcaster's current number of followers. + // This number increases with new followers and decreases when users unfollow the broadcaster. + // + // - If type is subscription, this field is increased and decreased by the points value associated with the subscription tier. + // For example, if a tier-two subscription is worth 2 points, this field is increased or decreased by 2, not 1. + // + // - If type is subscription_count, this field is increased by 1 for each new subscription and decreased by 1 for each user that unsubscribes. + // + // - If type is new_subscription, this field is increased by the points value associated with the subscription tier. + // For example, if a tier-two subscription is worth 2 points, this field is increased by 2, not 1. + // + // - If type is new_subscription_count, this field is increased by 1 for each new subscription. + CurrentAmount int `json:"current_amount"` + + // The goal's target value. For example, if the broadcaster has 200 followers before creating the goal, + // and their goal is to double that number, this field is set to 400. + TargetAmount int `json:"target_amount"` + + // The UTC timestamp in RFC 3339 format, which indicates when the broadcaster created the goal. + StartedAt time.Time `json:"started_at"` + + // The UTC timestamp in RFC 3339 format, which indicates when the broadcaster ended the goal. + // + // Only the channel.goal.end event includes this field. + EndedAt time.Time `json:"ended_at"` +} diff --git a/eventsub/events/gueststar.go b/eventsub/events/gueststar.go new file mode 100644 index 0000000..4568850 --- /dev/null +++ b/eventsub/events/gueststar.go @@ -0,0 +1,159 @@ +package events + +import "time" + +type ChannelGuestStarSessionBeginEvent struct { + // The broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // ID representing the unique session that was started. + SessionID string `json:"session_id"` + + // RFC3339 timestamp indicating the time the session began. + StartedAt time.Time `json:"started_at"` +} + +type ChannelGuestStarSessionEndEvent struct { + // The non-host broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The non-host broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The non-host broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // ID representing the unique session that was started. + SessionID string `json:"session_id"` + + // RFC3339 timestamp indicating the time the session began. + StartedAt time.Time `json:"started_at"` + + // RFC3339 timestamp indicating the time the session ended. + EndedAt time.Time `json:"ended_at"` + + // User ID of the host channel. + HostUserID string `json:"host_user_id"` + + // The host display name. + HostUserName string `json:"host_user_name"` + + // The host login. + HostUserLogin string `json:"host_user_login"` +} + +type ChannelGuestStarGuestUpdateEvent struct { + // The non-host broadcaster user ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The non-host broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The non-host broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // ID representing the unique session that was started. + SessionID string `json:"session_id"` + + // The user ID of the moderator who updated the guest's state (could be the host). null if the update was performed by the guest. + ModeratorUserID *string `json:"moderator_user_id"` + + // The moderator display name. null if the update was performed by the guest. + ModeratorUserName *string `json:"moderator_user_name"` + + // The moderator login. null if the update was performed by the guest. + ModeratorUserLogin *string `json:"moderator_user_login"` + + // The user ID of the guest who transitioned states in the session. null if the slot is now empty. + GuestUserID *string `json:"guest_user_id"` + + // The guest display name. null if the slot is now empty. + GuestUserName *string `json:"guest_user_name"` + + // The guest login. null if the slot is now empty. + GuestUserLogin *string `json:"guest_user_login"` + + // The ID of the slot assignment the guest is assigned to. null if the guest is in the INVITED, REMOVED, READY, or ACCEPTED state. + SlotID *string `json:"slot_id"` + + // The current state of the user after the update has taken place. null if the slot is now empty. Can otherwise be one of the following: + // + // invited — The guest has transitioned to the invite queue. This can take place when the guest was previously assigned a slot, + // but have been removed from the call and are sent back to the invite queue. + // + // accepted — The guest has accepted the invite and is currently in the process of setting up to join the session. + // + // ready — The guest has signaled they are ready and can be assigned a slot. + // + // backstage — The guest has been assigned a slot in the session, but is not currently seen live in the broadcasting software. + // + // live — The guest is now live in the host's broadcasting software. + // + // removed — The guest was removed from the call or queue. + // + // accepted — The guest has accepted the invite to the call. + State *string `json:"state"` + + // The user ID of the host channel. + HostUserID string `json:"host_user_id"` + + // The host display name. + HostUserName string `json:"host_user_name"` + + // The host login. + HostUserLogin string `json:"host_user_login"` + + // Flag that signals whether the host is allowing the slot's video to be seen by participants within the session. + // null if the guest is not slotted. + HostVideoEnabled *bool `json:"host_video_enabled"` + + // Flag that signals whether the host is allowing the slot's audio to be heard by participants within the session. + // null if the guest is not slotted. + HostAudioEnabled *bool `json:"host_audio_enabled"` + + // Value between 0-100 that represents the slot's audio level as heard by participants within the session. + // null if the guest is not slotted. + HostVolume *int `json:"host_volume"` +} + +type ChannelGuestStarSettingsUpdateEvent struct { + // The user ID of the host channel. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // Flag determining if Guest Star moderators have access to control whether a guest is live once assigned to a slot. + IsModeratorSendLiveEnabled bool `json:"is_moderator_send_live_enabled"` + + // Number of slots the Guest Star call interface will allow the host to add to a call. + SlotCount int `json:"slot_count"` + + // Flag determining if browser sources subscribed to sessions on this channel should output audio + IsBrowserSourceAudioEnabled bool `json:"is_browser_source_audio_enabled"` + + // This setting determines how the guests within a session should be laid out within a group browser source. Can be one of the following values: + // + // tiled — All live guests are tiled within the browser source with the same size. + // + // screenshare — All live guests are tiled within the browser source with the same size. + // If there is an active screen share, it is sized larger than the other guests. + // + // horizontal_top — Indicates the group layout will contain all participants in a top-aligned horizontal stack. + // + // horizontal_bottom — Indicates the group layout will contain all participants in a bottom-aligned horizontal stack. + // + // vertical_left — Indicates the group layout will contain all participants in a left-aligned vertical stack. + // + // vertical_right — Indicates the group layout will contain all participants in a right-aligned vertical stack. + GroupLayout string `json:"group_layout"` +} diff --git a/eventsub/events/handler.go b/eventsub/events/handler.go new file mode 100644 index 0000000..47100a1 --- /dev/null +++ b/eventsub/events/handler.go @@ -0,0 +1,25 @@ +package events + +type Handler interface { + // Handle receives events from the eventsub transports. + // + // NOTE: When using webooks as the eventsub transport you should make sure + // that you handle the message in a background goroutine and return as soon + // as possible. If you don't, you may receive duplicate messages as twitch + // will consider the processing failed after one or two seconds. If you fail + // to respond quickly enough too many times, the subscription may be revoked + // and you will have to resubscribe to the eventsub events. + Handle(event any) error +} + +type RevocationHandler interface { + // Handle is called when an eventsub subscription is revoked. + // + // NOTE: When using webooks as the eventsub transport you should make sure + // that you handle the message in a background goroutine and return as soon + // as possible. If you don't, you may receive duplicate messages as twitch + // will consider the processing failed after one or two seconds. If you fail + // to respond quickly enough too many times, the subscription may be revoked + // and you will have to resubscribe to the eventsub events. + Handle() error +} diff --git a/eventsub/events/hypetrains.go b/eventsub/events/hypetrains.go new file mode 100644 index 0000000..3aeb9d0 --- /dev/null +++ b/eventsub/events/hypetrains.go @@ -0,0 +1,164 @@ +package events + +import "time" + +// The top contributor for a contribution type. +// For example, the top contributor using BITS (by aggregate) or the top contributor using subscriptions (by count). +type TopContribution struct { + // The user ID of the contributor. + UserId string `json:"user_id"` + + // The user login of the contributor. + UserLogin string `json:"user_login"` + + // The user display name of the contributor. + UserName string `json:"user_name"` + + // The contribution method used. Possible values are: + // + // bits — Cheering with Bits. + // + // subscription — Subscription activity like subscribing or gifting subscriptions. + // + // other — Covers other contribution methods not listed. + Type string `json:"type"` + + // The amount of the contribution. + // If type is bits, total represents the amount of Bits used. + // If type is subscription, total is 500, 1000, or 2500 to represent tier 1, 2, or 3 subscriptions, respectively. + Total int `json:"total"` +} + +// The most recent contribution towards the Hype Train’s goal. +type LastContribution struct { + // The user ID of the user that made the contribution. + UserId string `json:"user_id"` + + // The user login of the user that made the contribution. + UserLogin string `json:"user_login"` + + // The user display name of the user that made the contribution. + UserName string `json:"user_name"` + + // The contribution method used. Possible values are: + // + // bits — Cheering with Bits. + // + // subscription — Subscription activity like subscribing or gifting subscriptions. + // + // other — Covers other contribution methods not listed. + Type string `json:"type"` + + // The amount of the contribution. + // If type is bits, total represents the amount of Bits used. + // If type is subscription, total is 500, 1000, or 2500 to represent tier 1, 2, or 3 subscriptions, respectively. + Total int `json:"total"` +} + +type HypeTrainBeginEvent struct { + // The Hype Train ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Total points contributed to the Hype Train. + Total int `json:"total"` + + // The number of points contributed to the Hype Train at the current level. + Progress int `json:"progress"` + + // The number of points required to reach the next level. + Goal int `json:"goal"` + + // The contributors with the most points contributed. + TopContributions []TopContribution `json:"top_contributions"` + + // The most recent contribution. + LastContribution LastContribution `json:"last_contribution"` + + // The starting level of the Hype Train. + Level int `json:"level"` + + // The time when the Hype Train started. + StartedAt time.Time `json:"started_at"` + + // The time when the Hype Train expires. The expiration is extended when the Hype Train reaches a new level. + ExpiresAt time.Time `json:"expires_at"` +} + +type HypeTrainProgressEvent struct { + // The Hype Train ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The current level of the Hype Train. + Level int `json:"level"` + + // Total points contributed to the Hype Train. + Total int `json:"total"` + + // The number of points contributed to the Hype Train at the current level. + Progress int `json:"progress"` + + // The number of points required to reach the next level. + Goal int `json:"goal"` + + // The contributors with the most points contributed. + TopContributions []TopContribution `json:"top_contributions"` + + // The most recent contribution. + LastContribution LastContribution `json:"last_contribution"` + + // The time when the Hype Train started. + StartedAt time.Time `json:"started_at"` + + // The time when the Hype Train expires. The expiration is extended when the Hype Train reaches a new level. + ExpiresAt time.Time `json:"expires_at"` +} + +type HypeTrainEndEvent struct { + // The Hype Train ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The final level of the Hype Train. + Level int `json:"level"` + + // Total points contributed to the Hype Train. + Total int `json:"total"` + + // The contributors with the most points contributed. + TopContributions []TopContribution `json:"top_contributions"` + + // The time when the Hype Train started. + StartedAt time.Time `json:"started_at"` + + // The time when the Hype Train ended. + EndedAt time.Time `json:"ended_at"` + + // The time when the Hype Train cooldown ends so that the next Hype Train can start. + CooldownEndsAt time.Time `json:"cooldown_ends_at"` +} diff --git a/eventsub/events/polls.go b/eventsub/events/polls.go new file mode 100644 index 0000000..73d4693 --- /dev/null +++ b/eventsub/events/polls.go @@ -0,0 +1,112 @@ +package events + +import "time" + +type Choice struct { + // ID for the choice. + ID string `json:"id"` + + // Text displayed for the choice. + Title string `json:"title"` + + // Number of votes received via Channel Points. + ChannelPointsVotes int `json:"channel_points_votes"` + + // Total number of votes received for the choice across all methods of voting. + Votes int `json:"votes"` +} + +type ChannelPointsVoting struct { + // Indicates if Channel Points can be used for voting. + IsEnabled bool `json:"is_enabled"` + + // Number of Channel Points required to vote once with Channel Points. + AmountPerVote int `json:"amount_per_vote"` +} + +type ChannelPollBeginEvent struct { + // ID of the poll. + ID string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Question displayed for the poll. + Title string `json:"title"` + + // An array of choices for the poll. + Choices []Choice `json:"choices"` + + // The channel points voting settings for the poll. + ChannelPointsVoting ChannelPointsVoting `json:"channel_points_voting"` + + // The time the poll started. + StartedAt time.Time `json:"started_at"` + + // The time the poll will end. + EndsAt time.Time `json:"ends_at"` +} + +type ChannelPollProgressEvent struct { + // ID of the poll. + ID string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Question displayed for the poll. + Title string `json:"title"` + + // An array of choices for the poll. + Choices []Choice `json:"choices"` + + // The channel points voting settings for the poll. + ChannelPointsVoting ChannelPointsVoting `json:"channel_points_voting"` + + // The time the poll started. + StartedAt time.Time `json:"started_at"` + + // The time the poll will end. + EndsAt time.Time `json:"ends_at"` +} + +type ChannelPollEndEvent struct { + // ID of the poll. + ID string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Question displayed for the poll. + Title string `json:"title"` + + // An array of choices for the poll. + Choices []Choice `json:"choices"` + + // The channel points voting settings for the poll. + ChannelPointsVoting ChannelPointsVoting `json:"channel_points_voting"` + + // The time the poll started. + StartedAt time.Time `json:"started_at"` + + // The time the poll ended. + EndedAt time.Time `json:"ended_at"` +} diff --git a/eventsub/events/predictions.go b/eventsub/events/predictions.go new file mode 100644 index 0000000..9af9aaf --- /dev/null +++ b/eventsub/events/predictions.go @@ -0,0 +1,159 @@ +package events + +import "time" + +// An array of up to 10 objects that describe users who participated in a Channel Points Prediction. +type TopPredictors struct { + // The ID of the user. + UserId string `json:"user_id"` + + // The login of the user. + UserLogin string `json:"user_login"` + + // The display name of the user. + UserName string `json:"user_name"` + + // The number of Channel Points won. + // This value is always null in the event payload for Prediction progress and Prediction lock. + // This value is 0 if the outcome did not win or if the Prediction was canceled and Channel Points were refunded. + ChannelPointsWon int `json:"channel_points_won"` + + // The number of Channel Points used. + ChannelPointsUsed int `json:"channel_points_used"` +} + +// An array of the outcomes for a particular Channel Points Prediction. Each Prediction’s event payload includes an outcomes array. +// The outcomes array contains an object that describes each outcome and, if applicable, the number of users who selected that outcome, +// the number of Channel Points for that outcome, and an array of top_predictors. +type Outcome struct { + // The outcome ID. + Id string `json:"id"` + + // The outcome title. + Title string `json:"title"` + + // The color for the outcome. Valid values are pink and blue. + Color string `json:"color"` + + // The number of users who used Channel Points on this outcome. + Users int `json:"users"` + + // The total number of Channel Points used on this outcome. + ChannelPoints int `json:"channel_points"` + + // An array of users who used the most Channel Points on this outcome. + TopPredictors []TopPredictors `json:"top_predictors"` +} + +type ChannelPredictionBeginEvent struct { + + // The Channel Points Prediction ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Title for the Channel Points Prediction. + Title string `json:"title"` + + // An array of outcomes for the Channel Points Prediction. + Outcomes []Outcome `json:"outcomes"` + + // The time the Channel Points Prediction started. + StartedAt time.Time `json:"started_at"` + + // The time the Channel Points Prediction will automatically lock. + LocksAt time.Time `json:"locks_at"` +} + +type ChannelPredictionProgressEvent struct { + + // The Channel Points Prediction ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Title for the Channel Points Prediction. + Title string `json:"title"` + + // An array of outcomes for the Channel Points Prediction. + Outcomes []Outcome `json:"outcomes"` + + // The time the Channel Points Prediction started. + StartedAt time.Time `json:"started_at"` + + // The time the Channel Points Prediction will automatically lock. + LocksAt time.Time `json:"locks_at"` +} + +type ChannelPredictionLockEvent struct { + + // The Channel Points Prediction ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Title for the Channel Points Prediction. + Title string `json:"title"` + + // An array of outcomes for the Channel Points Prediction. + Outcomes []Outcome `json:"outcomes"` + + // The time the Channel Points Prediction started. + StartedAt time.Time `json:"started_at"` + + // The time the Channel Points Prediction was locked. + LockedAt time.Time `json:"locked_at"` +} + +type ChannelPredictionEndEvent struct { + // The Channel Points Prediction ID. + Id string `json:"id"` + + // The requested broadcaster ID. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The requested broadcaster login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The requested broadcaster display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // Title for the Channel Points Prediction. + Title string `json:"title"` + + // The ID of the winning outcome. + WinningOutcomeId string `json:"winning_outcome_id"` + + // An array of outcomes for the Channel Points Prediction. + Outcomes []Outcome `json:"outcomes"` + + // The status of the Channel Points Prediction. Valid values are resolved and canceled. + Status string `json:"status"` + + // The time the Channel Points Prediction started. + StartedAt time.Time `json:"started_at"` + + // The time the Channel Points Prediction ended. + EndedAt time.Time `json:"ended_at"` +} diff --git a/eventsub/events/shoutouts.go b/eventsub/events/shoutouts.go new file mode 100644 index 0000000..77fac6e --- /dev/null +++ b/eventsub/events/shoutouts.go @@ -0,0 +1,72 @@ +package events + +import "time" + +// Defines the Shoutout data that you receive when the channel.shoutout.create event occurs. +type ShoutoutCreateEvent struct { + // The ID that identifies the broadcaster that sent the Shoutout. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster’s login name. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The ID that identifies the broadcaster that received the Shoutout. + ToBroadcasterUserID string `json:"to_broadcaster_user_id"` + + // The broadcaster’s login name. + ToBroadcasterUserLogin string `json:"to_broadcaster_user_login"` + + // The broadcaster’s display name. + ToBroadcasterUserName string `json:"to_broadcaster_user_name"` + + // An ID that identifies the moderator that sent the Shoutout. If the broadcaster sent the Shoutout, this ID is the same as the ID in broadcaster_user_id. + ModeratorUserID string `json:"moderator_user_id"` + + // The moderator’s login name. + ModeratorUserLogin string `json:"moderator_user_login"` + + // The moderator’s display name. + ModeratorUserName string `json:"moderator_user_name"` + + // The number of users that were watching the broadcaster’s stream at the time of the Shoutout. + ViewerCount int `json:"viewer_count"` + + // The UTC timestamp (in RFC3339 format) of when the moderator sent the Shoutout. + StartedAt time.Time `json:"started_at"` + + // The UTC timestamp (in RFC3339 format) of when the broadcaster may send a Shoutout to a different broadcaster. + CooldownEndsAt time.Time `json:"cooldown_ends_at"` + + // The UTC timestamp (in RFC3339 format) of when the broadcaster may send another Shoutout to the broadcaster in to_broadcaster_user_id. + TargetCooldownEndsAt time.Time `json:"target_cooldown_ends_at"` +} + +// Defines the Shoutout data that you receive when the channel.shoutout.receive event occurs. +type ShoutoutReceivedEvent struct { + // An ID that identifies the broadcaster that received the Shoutout. + BroadcasterUserID string `json:"broadcaster_user_id"` + + // The broadcaster’s login name. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // An ID that identifies the broadcaster that sent the Shoutout. + FromBroadcasterUserID string `json:"from_broadcaster_user_id"` + + // The broadcaster’s login name. + FromBroadcasterUserLogin string `json:"from_broadcaster_user_login"` + + // The broadcaster’s display name. + FromBroadcasterUserName string `json:"from_broadcaster_user_name"` + + // The number of users that were watching the broadcaster’s stream at the time of the Shoutout. + ViewerCount int `json:"viewer_count"` + + // The UTC timestamp (in RFC3339 format) of when the moderator sent the Shoutout. + StartedAt time.Time `json:"started_at"` +} diff --git a/eventsub/events/streams.go b/eventsub/events/streams.go new file mode 100644 index 0000000..118d4fe --- /dev/null +++ b/eventsub/events/streams.go @@ -0,0 +1,34 @@ +package events + +import "time" + +type StreamOnlineEvent struct { + // The id of the stream. + Id string `json:"id"` + + // The broadcaster’s user id. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster’s user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` + + // The stream type. Valid values are: live, playlist, watch_party, premiere, rerun. + Type string `json:"type"` + + // The timestamp at which the stream went online at. + StartedAt time.Time `json:"started_at"` +} + +type StreamOfflineEvent struct { + // The broadcaster’s user id. + BroadcasterUserId string `json:"broadcaster_user_id"` + + // The broadcaster’s user login. + BroadcasterUserLogin string `json:"broadcaster_user_login"` + + // The broadcaster’s user display name. + BroadcasterUserName string `json:"broadcaster_user_name"` +} diff --git a/eventsub/events/users.go b/eventsub/events/users.go new file mode 100644 index 0000000..33cbf98 --- /dev/null +++ b/eventsub/events/users.go @@ -0,0 +1,53 @@ +package events + +type UserAuthorizationGrantEvent struct { + // The client_id of the application that was granted user access. + ClientId string `json:"client_id"` + + // The user id for the user who has granted authorization for your client id. + UserId string `json:"user_id"` + + // The user login for the user who has granted authorization for your client id. + UserLogin string `json:"user_login"` + + // The user display name for the user who has granted authorization for your client id. + UserName string `json:"user_name"` +} + +type UserAuthorizationRevokeEvent struct { + // The client_id of the application with revoked user access. + ClientId string `json:"client_id"` + + // The user id for the user who has revoked authorization for your client id. + UserId string `json:"user_id"` + + // The user login for the user who has revoked authorization for your client id. This is null if the user no longer exists. + UserLogin *string `json:"user_login"` + + // The user display name for the user who has revoked authorization for your client id. This is null if the user no longer exists. + UserName *string `json:"user_name"` +} + +type UserUpdateEvent struct { + // The user’s user id. + UserId string `json:"user_id"` + + // The user’s user login. + UserLogin string `json:"user_login"` + + // The user’s user display name. + UserName string `json:"user_name"` + + // The user’s email address. + // The event includes the user’s email address only if the app used to request this event type includes the user:read:email scope for the user; + // otherwise, the field is set to an empty string. See Create EventSub Subscription: https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription + Email string `json:"email"` + + // A Boolean value that determines whether Twitch has verified the user’s email address. + // Is true if Twitch has verified the email address; otherwise, false. + // NOTE: Ignore this field if the email field contains an empty string. + EmailVerified bool `json:"email_verified"` + + // The user’s description. + Description string `json:"description"` +} diff --git a/eventsub/eventsub.go b/eventsub/eventsub.go index b16bcd0..40fc773 100644 --- a/eventsub/eventsub.go +++ b/eventsub/eventsub.go @@ -9,12 +9,12 @@ import ( type EventSub struct { api *api.API - transport *eventsub.Transport + transport Transport subscriptions map[string]*eventsub.Subscription } -func New(api *api.API, trans *eventsub.Transport) *EventSub { +func New(api *api.API, trans Transport) *EventSub { return &EventSub{ api: api, transport: trans, @@ -25,7 +25,7 @@ func (e *EventSub) Subscribe(ctx context.Context, subType eventsub.SubscriptionT res, err := e.api.EventSub.CreateEventSubSubscription(ctx, &eventsub.CreateEventSubSubscriptionRequest{ SubscriptionType: subType, Condition: cond, - Transport: e.transport, + Transport: e.transport.ApiTransport(), }) if err != nil { @@ -39,6 +39,26 @@ func (e *EventSub) Subscribe(ctx context.Context, subType eventsub.SubscriptionT return nil } +func (e *EventSub) Unsubscribe(ctx context.Context, subType eventsub.SubscriptionType) error { + for _, sub := range e.subscriptions { + if sub.SubType() == subType { + if err := e.api.EventSub.DeleteEventSubSubscription(ctx, sub.ID); err != nil { + return err + } + } + } + + return nil +} + +func (e *EventSub) Run() error { + return e.transport.Run() +} + +func (e *EventSub) Start() { + e.transport.Start() +} + func (e *EventSub) Close() error { for _, sub := range e.subscriptions { if err := e.api.EventSub.DeleteEventSubSubscription(context.Background(), sub.ID); err != nil { @@ -46,5 +66,9 @@ func (e *EventSub) Close() error { } } + if err := e.transport.Close(); err != nil { + return err + } + return nil } diff --git a/eventsub/transport.go b/eventsub/transport.go new file mode 100644 index 0000000..cc18846 --- /dev/null +++ b/eventsub/transport.go @@ -0,0 +1,12 @@ +package eventsub + +import ( + "go.fifitido.net/twitch/api/eventsub" +) + +type Transport interface { + ApiTransport() *eventsub.Transport + Run() error + Close() error + Start() +} diff --git a/eventsub/webhook/handler.go b/eventsub/webhook/handler.go new file mode 100644 index 0000000..80573e4 --- /dev/null +++ b/eventsub/webhook/handler.go @@ -0,0 +1,78 @@ +package webhook + +import ( + "encoding/json" + "net/http" + "time" + + "go.fifitido.net/twitch/api/eventsub" + "go.fifitido.net/twitch/eventsub/webhook/messages" +) + +var _ http.Handler = (*Transport)(nil) + +// ServeHTTP implements http.Handler. +func (t *Transport) ServeHTTP(w http.ResponseWriter, r *http.Request) { + timestamp, err := time.Parse(time.RFC3339, r.Header.Get("Twitch-Eventsub-Message-Timestamp")) + if err != nil { + http.Error(w, "failed to parse timestamp", http.StatusBadRequest) + return + } + + msg := &messages.Message{ + Id: r.Header.Get("Twitch-Eventsub-Message-Id"), + Retry: r.Header.Get("Twitch-Eventsub-Message-Retry"), + Type: messages.Type(r.Header.Get("Twitch-Eventsub-Message-Type")), + Signature: r.Header.Get("Twitch-Eventsub-Message-Signature"), + Timestamp: timestamp, + SubscriptionType: &eventsub.SubscriptionType{ + Name: r.Header.Get("Twitch-Eventsub-Subscription-Type"), + Version: r.Header.Get("Twitch-Eventsub-Subscription-Version"), + }, + } + + switch msg.Type { + case messages.TypeNotification: + var payload messages.Notification + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "failed to parse message", http.StatusBadRequest) + return + } + msg.Data = &payload + + if t.opts.EventsHandler != nil { + if err := t.opts.EventsHandler.Handle(msg); err != nil { + http.Error(w, "failed to handle message", http.StatusInternalServerError) + return + } + } + case messages.TypeWebhookCallbackVerification: + var payload messages.WebhookCallbackVerification + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "failed to parse message", http.StatusBadRequest) + return + } + msg.Data = &payload + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(payload.Challenge)) + w.WriteHeader(http.StatusOK) + return + case messages.TypeRevocation: + var payload messages.Revocation + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "failed to parse message", http.StatusBadRequest) + return + } + msg.Data = &payload + + if t.opts.RevocationHandler != nil { + if err := t.opts.RevocationHandler.Handle(); err != nil { + http.Error(w, "failed to handle message", http.StatusInternalServerError) + return + } + } + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/eventsub/webhook/messages/handler.go b/eventsub/webhook/messages/handler.go new file mode 100644 index 0000000..c7f3281 --- /dev/null +++ b/eventsub/webhook/messages/handler.go @@ -0,0 +1,13 @@ +package messages + +// Handler receives messages from the eventsub transports. +// +// NOTE: When using webooks as the eventsub transport you should make sure +// that you handle the message in a background goroutine and return as soon +// as possible. If you don't, you may receive duplicate messages as twitch +// will consider the processing failed after one or two seconds. If you fail +// to respond quickly enough too many times, the subscription may be revoked +// and you will have to resubscribe to the eventsub events. +type Handler interface { + Handle(msg *Message) error +} diff --git a/eventsub/webhook/messages/marshal.go b/eventsub/webhook/messages/marshal.go new file mode 100644 index 0000000..f86c2c0 --- /dev/null +++ b/eventsub/webhook/messages/marshal.go @@ -0,0 +1,272 @@ +package messages + +import ( + "encoding/json" + + "github.com/mitchellh/mapstructure" + "go.fifitido.net/twitch/eventsub/events" +) + +func decode[T any](input map[string]any) (T, error) { + var result T + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Metadata: nil, + Result: &result, + TagName: "json", + }) + if err != nil { + return result, err + } + + if err := decoder.Decode(input); err != nil { + return result, err + } + + return result, nil +} + +var _ json.Unmarshaler = (*Notification)(nil) + +func (m *Notification) UnmarshalJSON(data []byte) error { + var payload Notification + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + m.Subscription = payload.Subscription + event := payload.Event.(map[string]any) + + switch payload.Subscription.Type { + case "channel.update": + var err error + m.Event, err = decode[events.ChannelUpdateEvent](event) + return err + case "channel.follow": + var err error + m.Event, err = decode[events.ChannelFollowEvent](event) + return err + case "channel.ad_break.begin": + var err error + m.Event, err = decode[events.ChannelAdBreakBeginEvent](event) + return err + case "channel.chat.clear": + var err error + m.Event, err = decode[events.ChannelChatClearEvent](event) + return err + case "channel.chat.clear_user_messages": + var err error + m.Event, err = decode[events.ChannelChatClearUserMessagesEvent](event) + return err + case "channel.chat.message": + var err error + m.Event, err = decode[events.ChannelChatMessageEvent](event) + return err + case "channel.chat.message_delete": + var err error + m.Event, err = decode[events.ChannelChatMessageDeleteEvent](event) + return err + case "channel.chat.notification": + var err error + m.Event, err = decode[events.ChannelChatNotificationEvent](event) + return err + case "channel.chat_settings.update": + var err error + m.Event, err = decode[events.ChannelChatSettingsUpdateEvent](event) + return err + case "channel.subscribe": + var err error + m.Event, err = decode[events.ChannelSubscribeEvent](event) + return err + case "channel.subscription.end": + var err error + m.Event, err = decode[events.ChannelSubscriptionEndEvent](event) + return err + case "channel.subscription.gift": + var err error + m.Event, err = decode[events.ChannelSubscriptionGiftEvent](event) + return err + case "channel.subscription.message": + var err error + m.Event, err = decode[events.ChannelSubscriptionMessageEvent](event) + return err + case "channel.cheer": + var err error + m.Event, err = decode[events.ChannelCheerEvent](event) + return err + case "channel.raid": + var err error + m.Event, err = decode[events.ChannelRaidEvent](event) + return err + case "channel.ban": + var err error + m.Event, err = decode[events.ChannelBanEvent](event) + return err + case "channel.unban": + var err error + m.Event, err = decode[events.ChannelUnbanEvent](event) + return err + case "channel.moderator.add": + var err error + m.Event, err = decode[events.ChannelModeratorAddEvent](event) + return err + case "channel.moderator.remove": + var err error + m.Event, err = decode[events.ChannelModeratorRemoveEvent](event) + return err + case "channel.guest_star_session.begin": + var err error + m.Event, err = decode[events.ChannelGuestStarSessionBeginEvent](event) + return err + case "channel.guest_star_session.end": + var err error + m.Event, err = decode[events.ChannelGuestStarSessionEndEvent](event) + return err + case "channel.guest_star_guest.update": + var err error + m.Event, err = decode[events.ChannelGuestStarGuestUpdateEvent](event) + return err + case "channel.guest_star_settings.update": + var err error + m.Event, err = decode[events.ChannelGuestStarSettingsUpdateEvent](event) + return err + case "channel.channel_points_custom_reward.add": + var err error + m.Event, err = decode[events.ChannelPointsCustomRewardAddEvent](event) + return err + case "channel.channel_points_custom_reward.update": + var err error + m.Event, err = decode[events.ChannelPointsCustomRewardUpdateEvent](event) + return err + case "channel.channel_points_custom_reward.remove": + var err error + m.Event, err = decode[events.ChannelPointsCustomRewardRemoveEvent](event) + return err + case "channel.channel_points_custom_reward_redemption.add": + var err error + m.Event, err = decode[events.ChannelPointsCustomRewardRedemptionAddEvent](event) + return err + case "channel.channel_points_custom_reward_redemption.update": + var err error + m.Event, err = decode[events.ChannelPointsCustomRewardRedemptionUpdateEvent](event) + return err + case "channel.poll.begin": + var err error + m.Event, err = decode[events.ChannelPollBeginEvent](event) + return err + case "channel.poll.progress": + var err error + m.Event, err = decode[events.ChannelPollProgressEvent](event) + return err + case "channel.poll.end": + var err error + m.Event, err = decode[events.ChannelPollEndEvent](event) + return err + case "channel.prediction.begin": + var err error + m.Event, err = decode[events.ChannelPredictionBeginEvent](event) + return err + case "channel.prediction.progress": + var err error + m.Event, err = decode[events.ChannelPredictionProgressEvent](event) + return err + case "channel.prediction.lock": + var err error + m.Event, err = decode[events.ChannelPredictionLockEvent](event) + return err + case "channel.prediction.end": + var err error + m.Event, err = decode[events.ChannelPredictionEndEvent](event) + return err + case "channel.hype_train.begin": + var err error + m.Event, err = decode[events.HypeTrainBeginEvent](event) + return err + case "channel.hype_train.progress": + var err error + m.Event, err = decode[events.HypeTrainProgressEvent](event) + return err + case "channel.hype_train.end": + var err error + m.Event, err = decode[events.HypeTrainEndEvent](event) + return err + case "channel.charity_campaign.donate": + var err error + m.Event, err = decode[events.CharityDonationEvent](event) + return err + case "channel.charity_campaign.start": + var err error + m.Event, err = decode[events.CharityCampaignStartEvent](event) + return err + case "channel.charity_campaign.progress": + var err error + m.Event, err = decode[events.CharityCampaignProgressEvent](event) + return err + case "channel.charity_campaign.stop": + var err error + m.Event, err = decode[events.CharityCampaignStopEvent](event) + return err + case "channel.shield_mode.begin": + var err error + m.Event, err = decode[events.ShieldModeEvent](event) + return err + case "channel.shield_mode.end": + var err error + m.Event, err = decode[events.ShieldModeEvent](event) + return err + case "channel.shoutout.create": + var err error + m.Event, err = decode[events.ShoutoutCreateEvent](event) + return err + case "channel.shoutout.receive": + var err error + m.Event, err = decode[events.ShoutoutReceivedEvent](event) + return err + case "conduit.shard.disabled": + var err error + m.Event, err = decode[events.ConduitShardDisabledEvent](event) + return err + case "drop.entitlement.grant": + var err error + m.Event, err = decode[events.DropEntitlementGrantEvent](event) + return err + case "extension.bits_transaction.create": + var err error + m.Event, err = decode[events.ExtensionBitsTransactionCreateEvent](event) + return err + case "channel.goal.begin": + var err error + m.Event, err = decode[events.GoalsEvent](event) + return err + case "channel.goal.progress": + var err error + m.Event, err = decode[events.GoalsEvent](event) + return err + case "channel.goal.end": + var err error + m.Event, err = decode[events.GoalsEvent](event) + return err + case "stream.online": + var err error + m.Event, err = decode[events.StreamOnlineEvent](event) + return err + case "stream.offline": + var err error + m.Event, err = decode[events.StreamOfflineEvent](event) + return err + case "user.authorization.grant": + var err error + m.Event, err = decode[events.UserAuthorizationGrantEvent](event) + return err + case "user.authorization.revoke": + var err error + m.Event, err = decode[events.UserAuthorizationRevokeEvent](event) + return err + case "user.update": + var err error + m.Event, err = decode[events.UserUpdateEvent](event) + return err + } + + return nil +} diff --git a/eventsub/webhook/messages/messages.go b/eventsub/webhook/messages/messages.go new file mode 100644 index 0000000..1997323 --- /dev/null +++ b/eventsub/webhook/messages/messages.go @@ -0,0 +1,100 @@ +package messages + +import ( + "time" + + "go.fifitido.net/twitch/api/eventsub" +) + +type Message struct { + // An ID that uniquely identifies this message. + // This is an opaque ID, and is not required to be in any particular format. + Id string + + // Twitch sends you a notification at least once. + // If Twitch is unsure of whether you received a notification, it’ll resend the event, which means you may receive a notification twice. + // If this is an issue for your implementation, see Handling duplicates for options: https://dev.twitch.tv/docs/eventsub#handling-duplicate-events + Retry string + + // The type of notification. + Type Type + + // The HMAC signature that you use to verify that Twitch sent the message. + // See Verifying the event message: https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#verifying-the-event-message + Signature string + + // The UTC date and time (in RFC3339 format) that Twitch sent the notification. + Timestamp time.Time + + // The subscription type you subscribed to. For example, channel.follow. + SubscriptionType *eventsub.SubscriptionType + + // The message data. + // + // For TypeNotification, this will be a Notification. + // + // For TypeWebhookCallbackVerification, this will be a WebhookCallbackVerification. + // + // For TypeRevocation, this will be a Revocation. + Data any +} + +type Type string + +const ( + // notification — Contains the event's data. + // See Processing an event: https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#processing-an-event + TypeNotification Type = "notification" + + // webhook_callback_verification — Contains the challenge used to verify that you own the event handler. + // See Responding to a challenge request: https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#responding-to-a-challenge-request + TypeWebhookCallbackVerification Type = "webhook_callback_verification" + + // revocation — Contains the reason why Twitch revoked your subscription. + // See Revoking your subscription: https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#revoking-your-subscription + TypeRevocation Type = "revocation" +) + +type Subscription struct { + // Your client ID. + Id string `json:"id"` + + // The notification’s subscription type. + Type string `json:"type"` + + // The version of the subscription. + Version string `json:"version"` + + // The status of the subscription. + Status string `json:"status"` + + // How much the subscription counts against your limit. See Subscription Limits for more information. + Cost int `json:"cost"` + + // Subscription-specific parameters. + Condition map[string]any `json:"condition"` + + // The time the notification was created. + CreatedAt string `json:"created_at"` +} + +type Notification struct { + // Metadata about the subscription. + Subscription Subscription `json:"subscription"` + + // The notification’s event data.. + Event any `json:"event"` +} + +type Revocation struct { + // Metadata about the subscription. + Subscription Subscription `json:"subscription"` +} + +type WebhookCallbackVerification struct { + // The challenge. + Challenge string `json:"challenge"` + + // Metadata about the subscription. + Subscription Subscription `json:"subscription"` +} diff --git a/eventsub/webhook/options.go b/eventsub/webhook/options.go new file mode 100644 index 0000000..12a00cb --- /dev/null +++ b/eventsub/webhook/options.go @@ -0,0 +1,16 @@ +package webhook + +import "go.fifitido.net/twitch/eventsub/events" + +type TransportOptions struct { + // The address to serve the webhook on. + // + // If not specified it uses the default value :8080 + Address *string + + // The full callback url where the webhook will be accessible. + CallbackURL string + + EventsHandler events.Handler + RevocationHandler events.RevocationHandler +} diff --git a/eventsub/webhook/webhook.go b/eventsub/webhook/webhook.go new file mode 100644 index 0000000..4c9abdb --- /dev/null +++ b/eventsub/webhook/webhook.go @@ -0,0 +1,91 @@ +package webhook + +import ( + "context" + "net/http" + "time" + + eventsubapi "go.fifitido.net/twitch/api/eventsub" + "go.fifitido.net/twitch/eventsub" +) + +const ( + DefaultAddress = ":8080" +) + +type Transport struct { + ctx context.Context + cancel context.CancelFunc + + opts TransportOptions + secret string + + srv *http.Server +} + +func New(parentCtx context.Context, opts TransportOptions) *Transport { + ctx, cancel := context.WithCancel(parentCtx) + + return &Transport{ + ctx: ctx, + cancel: cancel, + opts: opts, + srv: &http.Server{}, + } +} + +var _ eventsub.Transport = (*Transport)(nil) + +// ApiTransport implements eventsub.Transport. +func (t *Transport) ApiTransport() *eventsubapi.Transport { + return &eventsubapi.Transport{ + Method: "webhook", + Callback: &t.opts.CallbackURL, + Secret: &t.secret, + } +} + +// Close implements eventsub.Transport. +func (t *Transport) Close() error { + t.cancel() + + return nil +} + +// Run implements eventsub.Transport. +func (t *Transport) Run() error { + addr := DefaultAddress + if t.opts.Address != nil { + addr = *t.opts.Address + } + + go func() { + if err := http.ListenAndServe(addr, t); err != nil { + if err == http.ErrServerClosed { + return + } + + panic(err) + } + }() + + <-t.ctx.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := t.srv.Shutdown(ctx); err != nil { + return err + } + + return nil +} + +// Start implements eventsub.Transport. +func (t *Transport) Start() { + go func() { + if err := t.Run(); err != nil { + panic(err) + } + }() +} diff --git a/eventsub/websocket/handler.go b/eventsub/websocket/handler.go new file mode 100644 index 0000000..74ca685 --- /dev/null +++ b/eventsub/websocket/handler.go @@ -0,0 +1,42 @@ +package websocket + +import ( + "fmt" + + "go.fifitido.net/twitch/eventsub/websocket/messages" +) + +func (t *Transport) handleMsg(msg *messages.Message) error { + rawPayload := msg.Payload.(map[string]any) + payload, err := messages.DecodePayload(&msg.Metadata, rawPayload) + if err != nil { + return err + } + + switch msg.Metadata.MessageType { + case messages.TypeSessionWelcome: + payload := payload.(messages.SessionWelcomePayload) + t.sessionId = payload.Session.ID + case messages.TypeSessionKeepAlive: + // TODO: add keep alive handler + case messages.TypeRevocation: + // TODO: add revocation handler + case messages.TypeSessionReconnect: + // TODO: add session reconnect handler + case messages.TypeNotification: + payload := payload.(messages.NotificationPayload) + if t.opts.EventsHandler != nil { + if err := t.opts.EventsHandler.Handle(payload.Event); err != nil { + return err + } + } + default: + return fmt.Errorf("unknown message type: %s", msg.Metadata.MessageType) + } + + if err := t.opts.EventsHandler.Handle(msg); err != nil { + return err + } + + return nil +} diff --git a/eventsub/websocket/messages/decoder.go b/eventsub/websocket/messages/decoder.go new file mode 100644 index 0000000..0037b89 --- /dev/null +++ b/eventsub/websocket/messages/decoder.go @@ -0,0 +1,267 @@ +package messages + +import ( + "github.com/mitchellh/mapstructure" + "go.fifitido.net/twitch/eventsub/events" +) + +func decode[T any](input map[string]any) (T, error) { + var result T + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Metadata: nil, + Result: &result, + TagName: "json", + }) + if err != nil { + return result, err + } + + if err := decoder.Decode(input); err != nil { + return result, err + } + + return result, nil +} + +func decodeNotificationPayload(data map[string]any) (NotificationPayload, error) { + payload, err := decode[NotificationPayload](data) + if err != nil { + return NotificationPayload{}, err + } + + event := payload.Event.(map[string]any) + + switch payload.Subscription.Type { + case "channel.update": + var err error + payload.Event, err = decode[events.ChannelUpdateEvent](event) + return NotificationPayload{}, err + case "channel.follow": + var err error + payload.Event, err = decode[events.ChannelFollowEvent](event) + return NotificationPayload{}, err + case "channel.ad_break.begin": + var err error + payload.Event, err = decode[events.ChannelAdBreakBeginEvent](event) + return NotificationPayload{}, err + case "channel.chat.clear": + var err error + payload.Event, err = decode[events.ChannelChatClearEvent](event) + return NotificationPayload{}, err + case "channel.chat.clear_user_messages": + var err error + payload.Event, err = decode[events.ChannelChatClearUserMessagesEvent](event) + return NotificationPayload{}, err + case "channel.chat.message": + var err error + payload.Event, err = decode[events.ChannelChatMessageEvent](event) + return NotificationPayload{}, err + case "channel.chat.message_delete": + var err error + payload.Event, err = decode[events.ChannelChatMessageDeleteEvent](event) + return NotificationPayload{}, err + case "channel.chat.notification": + var err error + payload.Event, err = decode[events.ChannelChatNotificationEvent](event) + return NotificationPayload{}, err + case "channel.chat_settings.update": + var err error + payload.Event, err = decode[events.ChannelChatSettingsUpdateEvent](event) + return NotificationPayload{}, err + case "channel.subscribe": + var err error + payload.Event, err = decode[events.ChannelSubscribeEvent](event) + return NotificationPayload{}, err + case "channel.subscription.end": + var err error + payload.Event, err = decode[events.ChannelSubscriptionEndEvent](event) + return NotificationPayload{}, err + case "channel.subscription.gift": + var err error + payload.Event, err = decode[events.ChannelSubscriptionGiftEvent](event) + return NotificationPayload{}, err + case "channel.subscription.message": + var err error + payload.Event, err = decode[events.ChannelSubscriptionMessageEvent](event) + return NotificationPayload{}, err + case "channel.cheer": + var err error + payload.Event, err = decode[events.ChannelCheerEvent](event) + return NotificationPayload{}, err + case "channel.raid": + var err error + payload.Event, err = decode[events.ChannelRaidEvent](event) + return NotificationPayload{}, err + case "channel.ban": + var err error + payload.Event, err = decode[events.ChannelBanEvent](event) + return NotificationPayload{}, err + case "channel.unban": + var err error + payload.Event, err = decode[events.ChannelUnbanEvent](event) + return NotificationPayload{}, err + case "channel.moderator.add": + var err error + payload.Event, err = decode[events.ChannelModeratorAddEvent](event) + return NotificationPayload{}, err + case "channel.moderator.remove": + var err error + payload.Event, err = decode[events.ChannelModeratorRemoveEvent](event) + return NotificationPayload{}, err + case "channel.guest_star_session.begin": + var err error + payload.Event, err = decode[events.ChannelGuestStarSessionBeginEvent](event) + return NotificationPayload{}, err + case "channel.guest_star_session.end": + var err error + payload.Event, err = decode[events.ChannelGuestStarSessionEndEvent](event) + return NotificationPayload{}, err + case "channel.guest_star_guest.update": + var err error + payload.Event, err = decode[events.ChannelGuestStarGuestUpdateEvent](event) + return NotificationPayload{}, err + case "channel.guest_star_settings.update": + var err error + payload.Event, err = decode[events.ChannelGuestStarSettingsUpdateEvent](event) + return NotificationPayload{}, err + case "channel.channel_points_custom_reward.add": + var err error + payload.Event, err = decode[events.ChannelPointsCustomRewardAddEvent](event) + return NotificationPayload{}, err + case "channel.channel_points_custom_reward.update": + var err error + payload.Event, err = decode[events.ChannelPointsCustomRewardUpdateEvent](event) + return NotificationPayload{}, err + case "channel.channel_points_custom_reward.remove": + var err error + payload.Event, err = decode[events.ChannelPointsCustomRewardRemoveEvent](event) + return NotificationPayload{}, err + case "channel.channel_points_custom_reward_redemption.add": + var err error + payload.Event, err = decode[events.ChannelPointsCustomRewardRedemptionAddEvent](event) + return NotificationPayload{}, err + case "channel.channel_points_custom_reward_redemption.update": + var err error + payload.Event, err = decode[events.ChannelPointsCustomRewardRedemptionUpdateEvent](event) + return NotificationPayload{}, err + case "channel.poll.begin": + var err error + payload.Event, err = decode[events.ChannelPollBeginEvent](event) + return NotificationPayload{}, err + case "channel.poll.progress": + var err error + payload.Event, err = decode[events.ChannelPollProgressEvent](event) + return NotificationPayload{}, err + case "channel.poll.end": + var err error + payload.Event, err = decode[events.ChannelPollEndEvent](event) + return NotificationPayload{}, err + case "channel.prediction.begin": + var err error + payload.Event, err = decode[events.ChannelPredictionBeginEvent](event) + return NotificationPayload{}, err + case "channel.prediction.progress": + var err error + payload.Event, err = decode[events.ChannelPredictionProgressEvent](event) + return NotificationPayload{}, err + case "channel.prediction.lock": + var err error + payload.Event, err = decode[events.ChannelPredictionLockEvent](event) + return NotificationPayload{}, err + case "channel.prediction.end": + var err error + payload.Event, err = decode[events.ChannelPredictionEndEvent](event) + return NotificationPayload{}, err + case "channel.hype_train.begin": + var err error + payload.Event, err = decode[events.HypeTrainBeginEvent](event) + return NotificationPayload{}, err + case "channel.hype_train.progress": + var err error + payload.Event, err = decode[events.HypeTrainProgressEvent](event) + return NotificationPayload{}, err + case "channel.hype_train.end": + var err error + payload.Event, err = decode[events.HypeTrainEndEvent](event) + return NotificationPayload{}, err + case "channel.charity_campaign.donate": + var err error + payload.Event, err = decode[events.CharityDonationEvent](event) + return NotificationPayload{}, err + case "channel.charity_campaign.start": + var err error + payload.Event, err = decode[events.CharityCampaignStartEvent](event) + return NotificationPayload{}, err + case "channel.charity_campaign.progress": + var err error + payload.Event, err = decode[events.CharityCampaignProgressEvent](event) + return NotificationPayload{}, err + case "channel.charity_campaign.stop": + var err error + payload.Event, err = decode[events.CharityCampaignStopEvent](event) + return NotificationPayload{}, err + case "channel.shield_mode.begin": + var err error + payload.Event, err = decode[events.ShieldModeEvent](event) + return NotificationPayload{}, err + case "channel.shield_mode.end": + var err error + payload.Event, err = decode[events.ShieldModeEvent](event) + return NotificationPayload{}, err + case "channel.shoutout.create": + var err error + payload.Event, err = decode[events.ShoutoutCreateEvent](event) + return NotificationPayload{}, err + case "channel.shoutout.receive": + var err error + payload.Event, err = decode[events.ShoutoutReceivedEvent](event) + return NotificationPayload{}, err + case "conduit.shard.disabled": + var err error + payload.Event, err = decode[events.ConduitShardDisabledEvent](event) + return NotificationPayload{}, err + case "drop.entitlement.grant": + var err error + payload.Event, err = decode[events.DropEntitlementGrantEvent](event) + return NotificationPayload{}, err + case "extension.bits_transaction.create": + var err error + payload.Event, err = decode[events.ExtensionBitsTransactionCreateEvent](event) + return NotificationPayload{}, err + case "channel.goal.begin": + var err error + payload.Event, err = decode[events.GoalsEvent](event) + return NotificationPayload{}, err + case "channel.goal.progress": + var err error + payload.Event, err = decode[events.GoalsEvent](event) + return NotificationPayload{}, err + case "channel.goal.end": + var err error + payload.Event, err = decode[events.GoalsEvent](event) + return NotificationPayload{}, err + case "stream.online": + var err error + payload.Event, err = decode[events.StreamOnlineEvent](event) + return NotificationPayload{}, err + case "stream.offline": + var err error + payload.Event, err = decode[events.StreamOfflineEvent](event) + return NotificationPayload{}, err + case "user.authorization.grant": + var err error + payload.Event, err = decode[events.UserAuthorizationGrantEvent](event) + return NotificationPayload{}, err + case "user.authorization.revoke": + var err error + payload.Event, err = decode[events.UserAuthorizationRevokeEvent](event) + return NotificationPayload{}, err + case "user.update": + var err error + payload.Event, err = decode[events.UserUpdateEvent](event) + return NotificationPayload{}, err + } + + return payload, nil +} diff --git a/eventsub/websocket/messages/handler.go b/eventsub/websocket/messages/handler.go new file mode 100644 index 0000000..4a8b9fb --- /dev/null +++ b/eventsub/websocket/messages/handler.go @@ -0,0 +1,9 @@ +package messages + +// Handler receives messages from the websocket. +// +// You will only receive TypeNotification and TypeRevocation messages, +// all other messages will be handled automatically. +type Handler interface { + Handle(msg *Message) error +} diff --git a/eventsub/websocket/messages/mesages.go b/eventsub/websocket/messages/mesages.go new file mode 100644 index 0000000..9c09294 --- /dev/null +++ b/eventsub/websocket/messages/mesages.go @@ -0,0 +1,45 @@ +package messages + +import ( + "time" +) + +type Metadata struct { + MessageID string `json:"message_id"` + MessageType Type `json:"message_type"` + MessageTimestamp time.Time `json:"message_timestamp"` +} + +type Type string + +const ( + TypeSessionWelcome Type = "session_welcome" + TypeSessionKeepAlive Type = "session_keepalive" + TypeNotification Type = "notification" + TypeSessionReconnect Type = "session_reconnect" + TypeRevocation Type = "revocation" +) + +type Message struct { + Metadata Metadata `json:"metadata"` + Payload any `json:"payload"` +} + +type Session struct { + // An ID that uniquely identifies this WebSocket connection. Use this ID to set the session_id field in all subscription requests. + ID string `json:"id"` + + // The connection’s status, which is set to connected. + Status string `json:"status"` + + // The maximum number of seconds that you should expect silence before receiving a keepalive message. + // For a welcome message, this is the number of seconds that you have to subscribe to an event after receiving the welcome message. + // If you don’t subscribe to an event within this window, the socket is disconnected. + KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"` + + // The URL to reconnect to if you get a Reconnect message. Is set to null. + ReconnectURL *string `json:"reconnect_url"` + + // The UTC date and time that the connection was created. + ConnectedAt time.Time `json:"connected_at"` +} diff --git a/eventsub/websocket/messages/payloads.go b/eventsub/websocket/messages/payloads.go new file mode 100644 index 0000000..ba5ce65 --- /dev/null +++ b/eventsub/websocket/messages/payloads.go @@ -0,0 +1,43 @@ +package messages + +import ( + "fmt" + + "go.fifitido.net/twitch/api/eventsub" +) + +type SessionWelcomePayload struct { + Session Session `json:"session"` +} + +type SessionKeepAlivePayload struct{} + +type NotificationPayload struct { + Subscription eventsub.Subscription `json:"subscription"` + Event any `json:"event"` +} + +type SessionReconnectPayload struct { + Session Session `json:"session"` +} + +type RevocationPayload struct { + Subscription eventsub.Subscription `json:"subscription"` +} + +func DecodePayload(meta *Metadata, rawPayload map[string]any) (any, error) { + switch meta.MessageType { + case TypeSessionWelcome: + return decode[SessionWelcomePayload](rawPayload) + case TypeSessionKeepAlive: + return SessionKeepAlivePayload{}, nil + case TypeNotification: + return decodeNotificationPayload(rawPayload) + case TypeSessionReconnect: + return decode[SessionReconnectPayload](rawPayload) + case TypeRevocation: + return decode[RevocationPayload](rawPayload) + default: + return nil, fmt.Errorf("unknown message type: %s", meta.MessageType) + } +} diff --git a/eventsub/websocket/options.go b/eventsub/websocket/options.go new file mode 100644 index 0000000..1912e8e --- /dev/null +++ b/eventsub/websocket/options.go @@ -0,0 +1,19 @@ +package websocket + +import "go.fifitido.net/twitch/eventsub/events" + +type TransportOptions struct { + // The address of the websocket server. + // + // If not specified it uses the default eventsub endpoint. + Address *string + + // KeepaliveTimeoutSeconds sets the keep-alive timeout for the websocket + // when connecting to the default eventsub endpoint + // + // Accepts values between 10 and 600 + KeepaliveTimeoutSeconds *int + + EventsHandler events.Handler + RevocationHandler events.RevocationHandler +} diff --git a/eventsub/websocket/websocket.go b/eventsub/websocket/websocket.go new file mode 100644 index 0000000..6eb2ffb --- /dev/null +++ b/eventsub/websocket/websocket.go @@ -0,0 +1,119 @@ +package websocket + +import ( + "context" + "encoding/json" + "errors" + + "go.fifitido.net/twitch/api/eventsub" + "go.fifitido.net/twitch/eventsub/websocket/messages" + "nhooyr.io/websocket" +) + +const ( + DefaultEventSubEndpoint = "wss://eventsub.wss.twitch.tv/ws" +) + +var ( + ErrNotConnected = errors.New("not connected") +) + +type Transport struct { + ctx context.Context + cancel context.CancelFunc + opts *TransportOptions + + sessionId string + conn *websocket.Conn +} + +func New(parentCtx context.Context, opts *TransportOptions) *Transport { + ctx, cancel := context.WithCancel(parentCtx) + + if opts == nil { + opts = &TransportOptions{} + } + + return &Transport{ + ctx: ctx, + cancel: cancel, + opts: opts, + } +} + +// ApiTransport implements eventsub.Transport. +func (t *Transport) ApiTransport() *eventsub.Transport { + return &eventsub.Transport{ + Method: "websocket", + SessionID: &t.sessionId, + } +} + +func (t *Transport) Close() error { + t.sessionId = "" + + if t.conn != nil { + if err := t.conn.Close(websocket.StatusNormalClosure, ""); err != nil { + return err + } + } + + t.cancel() + + return nil +} + +func (t *Transport) readLoop() chan *messages.Message { + c := make(chan *messages.Message) + go func(c chan *messages.Message) { + for { + _, raw, err := t.conn.Read(t.ctx) + if err != nil { + return + } + + var msg *messages.Message + if err := json.Unmarshal(raw, &msg); err != nil { + return + } + + c <- msg + } + }(c) + return c +} + +func (t *Transport) Run() error { + addr := DefaultEventSubEndpoint + if t.opts.Address != nil { + addr = *t.opts.Address + } + + var err error + t.conn, _, err = websocket.Dial(t.ctx, addr, nil) + if err != nil { + return err + } + defer t.conn.CloseNow() + + msgC := t.readLoop() + + for { + select { + case msg := <-msgC: + if err := t.handleMsg(msg); err != nil { + return err + } + case <-t.ctx.Done(): + return t.Close() + } + } +} + +func (t *Transport) Start() { + go func() { + if err := t.Run(); err != nil { + panic(err) + } + }() +} diff --git a/go.mod b/go.mod index 45747d4..d4a625d 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.21.7 require ( github.com/google/go-querystring v1.1.0 + github.com/mitchellh/mapstructure v1.5.0 golang.org/x/oauth2 v0.17.0 + nhooyr.io/websocket v1.8.10 ) require ( diff --git a/go.sum b/go.sum index 2bd9adc..5a886a1 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= @@ -25,3 +27,5 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=