backend for twitch chat widgets

This commit is contained in:
ari melody 2026-09-02 22:32:45 +01:00
parent 09c11f11a1
commit 28b9492cb3
Signed by: ari
GPG key ID: 60B5F0386E3DDB7E
11 changed files with 725 additions and 94 deletions

11
colour/colour.go Normal file
View file

@ -0,0 +1,11 @@
package colour
var Reset = "\033[0m"
var Red = "\033[31m"
var Green = "\033[32m"
var Yellow = "\033[33m"
var Blue = "\033[34m"
var Purple = "\033[35m"
var Cyan = "\033[36m"
var Gray = "\033[37m"
var White = "\033[97m"

View file

@ -13,6 +13,7 @@ import (
"path"
"strconv"
"codeberg.org/arimelody/ari-stream-tools/colour"
"codeberg.org/arimelody/ari-stream-tools/twitch/api"
)
@ -76,14 +77,20 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
switch payload.Subscription.Type {
case "channel.follow":
case api.CHANNEL_FOLLOW:
var event api.FollowEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.FollowEvent: %v", err) }
log.Printf("New follow: %s", event.UserName)
log.Printf("%sFollowed: %s%s", colour.Green, event.UserName, colour.Reset)
srv.labels.LatestFollower.Text = event.UserName
srv.labels.LatestFollower.C <- event.UserName
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("New Follow: %s", event.UserName),
Type: SYSTEM_CHAT_FOLLOW,
}
if err := os.WriteFile(
path.Join(DATA_PATH, "state", LABEL_LATEST_FOLLOWER),
[]byte(event.UserName), 0640,
@ -91,14 +98,46 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_FOLLOWER, err)
}
case "channel.subscribe":
case api.CHANNEL_SUBSCRIBE:
var event api.SubscribeEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.SubscribeEvent: %v", err) }
log.Printf("New subscription: %s", event.UserName)
log.Printf("%sSubscribed: %s%s", colour.Purple, event.UserName, colour.Reset)
srv.labels.LatestSubscriber.Text = event.UserName
srv.labels.LatestSubscriber.C <- event.UserName
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("New Sub: %s", event.UserName),
Type: SYSTEM_CHAT_SUBSCRIBE,
}
if err := os.WriteFile(
path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER),
[]byte(event.UserName), 0640,
); err != nil {
return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_SUBSCRIBER, err)
}
case api.CHANNEL_SUBSCRIPTION_MESSAGE:
var event api.SubscribeMessageEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.SubscribeMessageEvent: %v", err) }
log.Printf("%sResub: %s x%d%s", colour.Purple, event.UserName, event.CumulativeMonths, colour.Reset)
if event.StreakMonths > 1 {
log.Printf("%s is on a %d-month streak!", event.UserName, event.StreakMonths)
}
if len(event.Message.Text) > 0 {
log.Printf("%s: %s", event.UserLogin, event.Message.Text)
}
srv.labels.LatestSubscriber.Text = event.UserName
srv.labels.LatestSubscriber.C <- event.UserName
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("Resub: %s x%d", event.UserName, event.CumulativeMonths),
Type: SYSTEM_CHAT_SUBSCRIBE,
}
if err := os.WriteFile(
path.Join(DATA_PATH, "state", LABEL_LATEST_SUBSCRIBER),
[]byte(event.UserName), 0640,
@ -106,14 +145,19 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_SUBSCRIBER, err)
}
case "channel.cheer":
case api.CHANNEL_CHEER:
var event api.CheerEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.CheerEvent: %v", err) }
log.Printf("%s cheered x%d bits: %s", event.UserName, event.Bits, event.Message)
log.Printf("%s%s cheered x%d bits:%s %s", colour.Cyan, event.UserName, event.Bits, colour.Reset, event.Message)
srv.labels.LatestCheer.Text = event.UserName
srv.labels.LatestCheer.C <- event.UserName
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("Cheer x%d %s: %s", event.Bits, event.UserName, event.Message),
Type: SYSTEM_CHAT_CHEER,
}
if err := os.WriteFile(
path.Join(DATA_PATH, "state", LABEL_LATEST_CHEER),
[]byte(event.UserName), 0640,
@ -121,54 +165,117 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
return fmt.Errorf("Failed to write %s state: %v", LABEL_LATEST_CHEER, err)
}
case "channel.raid":
case api.CHANNEL_RAID:
var event api.RaidEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.RaidEvent: %v", err) }
log.Printf("%s is now raiding with %d viewers!", event.FromUserName, event.Viewers)
log.Printf("%sRAID: %s with %d viewers!%s", colour.Red, event.FromUserName, event.Viewers, colour.Reset)
case "channel.channel_points_custom_reward_redemption.add":
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("%s raiding with %d viewers!", event.FromUserName, event.Viewers),
Type: SYSTEM_CHAT_RAID,
}
case api.CHANNEL_POINTS_CUSTOM_REWARD_REDEMPTION_ADD:
var event api.ChannelPointCustomRewardRedeemEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ChannelPointCustomRewardRedeemEvent: %v", err) }
log.Printf(
"%s just redeemed %s for %d channel points.",
"%s redeemed %s for %d channel points.",
event.UserName,
event.Reward.Title,
event.Reward.Cost,
)
case "channel.shoutout.create":
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("%s redeemed %s", event.UserName, event.Reward.Title),
Type: SYSTEM_CHAT_POINT_REDEEM,
}
case api.SHOUTOUT_CREATE:
var event api.ShoutoutCreate
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ShoutoutCreate: %v", err) }
log.Printf(
"%s gave a shoutout to %s.",
event.FromUserName,
event.ToUserName,
)
log.Printf("%s%s shouted out %s!%s", colour.Yellow, event.FromUserName, event.ToUserName, colour.Reset)
case "channel.chat.message":
srv.SystemChatC <- &systemChatMessage{
Text: fmt.Sprintf("Shoutout %s!", event.ToUserName),
Type: SYSTEM_CHAT_SHOUTOUT,
}
case api.CHANNEL_CHAT_MESSAGE:
var event api.ChatEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ChatEvent: %v", err) }
if event.Cheer != nil { return nil }
log.Printf(
"[%s] %s: %s",
event.MessageID,
event.ChatterLogin,
event.Message.Text,
)
case "channel.chat.message_delete":
if event.Cheer != nil { return nil }
var modifier ChatMessageModifier = CHAT_MODIFIER_NONE
if event.MessageType == api.MESSAGE_CHANNEL_POINTS_HIGHLIGHTED {
modifier = CHAT_MODIFIER_HIGHLIGHT
log.Printf("Highlighted from %s: %s", event.ChatterLogin, event.Message.Text)
} else {
log.Printf("%s: %s", event.ChatterLogin, event.Message.Text)
}
srv.ChatC <- &chatMessage{
ID: event.MessageID,
Username: event.ChatterLogin,
Colour: event.Color,
Text: event.Message.Text,
Fragments: event.Message.Fragments,
Modifier: modifier,
}
case api.CHANNEL_CHAT_MESSAGE_DELETE:
var event api.ChatDeleteEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ChatDeleteEvent: %v", err) }
log.Printf(
"Message %s by %s deleted.",
event.MessageID,
event.TargetLogin,
)
log.Printf("Message %s deleted.", event.TargetLogin)
srv.DeleteChatC <- &deleteChatMessage{
Username: "",
MessageID: event.MessageID,
}
case api.CHANNEL_CHAT_NOTIFICATION:
var event api.ChatNotificationEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ChatNotificationEvent: %v", err) }
switch event.NoticeType {
case api.NOTICE_ANNOUNCEMENT:
log.Printf("%sANNOUNCEMENT from %s:%s %s", colour.Yellow, event.ChatterName, colour.Reset, event.Message.Text)
srv.ChatC <- &chatMessage{
ID: event.MessageID,
Username: event.ChatterName,
Colour: event.Color,
Text: event.Message.Text,
Fragments: event.Message.Fragments,
Modifier: CHAT_MODIFIER_ANNOUNCEMENT,
AnnouncementColour: event.Announcement.Color,
}
}
case api.CHANNEL_CHAT_CLEAR:
log.Printf("Chat cleared.")
srv.DeleteChatC <- &deleteChatMessage{
Username: "*",
MessageID: "*",
}
case api.CHANNEL_CHAT_CLEAR_USER_MESSAGES:
var event api.ChatClearUserMessagesEvent
err := json.Unmarshal(jsonData, &event)
if err != nil { return fmt.Errorf("Failed to cast to api.ChatClearUserMessagesEvent: %v", err) }
log.Printf("Messages from %s cleared.", event.TargetName)
srv.DeleteChatC <- &deleteChatMessage{
Username: event.TargetName,
MessageID: "*",
}
// TODO: polls (creation and results)
default:
log.Printf("Unhandled message type %s.", payload.Subscription.Type)
}
@ -177,7 +284,7 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
func (srv *Service) subscribeToEvent(
ctx context.Context,
subscriptionType string,
subscriptionType api.SubscriptionType,
version string,
condition map[string]string,
sessionID string,
@ -189,7 +296,7 @@ func (srv *Service) subscribeToEvent(
}
Request struct {
Type string `json:"type"`
Type api.SubscriptionType `json:"type"`
Version string `json:"version"`
Condition map[string]string `json:"condition"`
Transport Transport `json:"transport"`
@ -247,90 +354,154 @@ func (srv *Service) subscribeToEvent(
return nil
}
// Calls subscribeToEvent with helpful logging
func (srv *Service) subscribeToEventHelper(
ctx context.Context,
subscriptionType api.SubscriptionType,
version string,
condition map[string]string,
sessionID string,
) {
if err := srv.subscribeToEvent(ctx, subscriptionType, version, condition, sessionID); err != nil {
log.Printf("Failed to subscribe to %s: %v", subscriptionType, err)
}
}
func (srv *Service) subscribeToDefaultEvents(ctx context.Context) {
// channel.follow
if err := srv.subscribeToEvent(
ctx, "channel.follow", "2",
// Channel Updates
srv.subscribeToEventHelper(
ctx, api.CHANNEL_UPDATE, "2",
map[string]string{
"broadcaster_user_id": srv.channelID,
},
srv.eventSubSession.ID,
)
// Follows
srv.subscribeToEventHelper(
ctx, api.CHANNEL_FOLLOW, "2",
map[string]string{
"broadcaster_user_id": srv.channelID,
"moderator_user_id": srv.channelID,
},
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.follow: %v", err)
}
)
// channel.subscribe
if err := srv.subscribeToEvent(
ctx, "channel.subscribe", "1",
// Subscriptions
srv.subscribeToEventHelper(
ctx, api.CHANNEL_SUBSCRIBE, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.subscribe: %v", err)
}
)
// channel.cheer
if err := srv.subscribeToEvent(
ctx, "channel.cheer", "1",
// Subscription Message
srv.subscribeToEventHelper(
ctx, api.CHANNEL_SUBSCRIPTION_MESSAGE, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.cheer: %v", err)
}
)
// channel.raid
if err := srv.subscribeToEvent(
ctx, "channel.raid", "1",
// Gift Subscriptions
srv.subscribeToEventHelper(
ctx, api.CHANNEL_SUBSCRIPTION_GIFT, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
)
// Cheer
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHEER, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
)
// Raid
srv.subscribeToEventHelper(
ctx, api.CHANNEL_RAID, "1",
map[string]string{ "to_broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.raid: %v", err)
}
)
// channel.channel_points_custom_reward_redemption.add
if err := srv.subscribeToEvent(
ctx, "channel.channel_points_custom_reward_redemption.add", "1",
// Channel Point Redeems
srv.subscribeToEventHelper(
ctx, api.CHANNEL_POINTS_CUSTOM_REWARD_REDEMPTION_ADD, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.channel_points_custom_reward_redemption.add: %v", err)
}
)
// channel.shoutout.create
if err := srv.subscribeToEvent(
ctx, "channel.shoutout.create", "1",
// Shoutouts
srv.subscribeToEventHelper(
ctx, api.SHOUTOUT_CREATE, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"moderator_user_id": srv.channelID,
},
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.shoutout.create: %v", err)
}
)
// channel.chat.message
if err := srv.subscribeToEvent(
ctx, "channel.chat.message", "1",
// Chat Messages
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHAT_MESSAGE, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"user_id": srv.channelID,
},
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.chat.message: %v", err)
}
)
// channel.chat.message_delete
if err := srv.subscribeToEvent(
ctx, "channel.chat.message_delete", "1",
// Chat Message Deletions
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHAT_MESSAGE_DELETE, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"user_id": srv.channelID,
},
srv.eventSubSession.ID,
); err != nil {
log.Printf("Failed to subscribe to channel.chat.message_delete: %v", err)
}
)
// Chat Notifications
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHAT_NOTIFICATION, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"user_id": srv.channelID,
},
srv.eventSubSession.ID,
)
// Chat Clear
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHAT_CLEAR, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"user_id": srv.channelID,
},
srv.eventSubSession.ID,
)
// Clear User Messages
srv.subscribeToEventHelper(
ctx, api.CHANNEL_CHAT_CLEAR_USER_MESSAGES, "1",
map[string]string{
"broadcaster_user_id": srv.channelID,
"user_id": srv.channelID,
},
srv.eventSubSession.ID,
)
// Polls (Starting)
srv.subscribeToEventHelper(
ctx, api.CHANNEL_POLL_BEGIN, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
)
// Polls (Ending)
srv.subscribeToEventHelper(
ctx, api.CHANNEL_POLL_END, "1",
map[string]string{ "broadcaster_user_id": srv.channelID },
srv.eventSubSession.ID,
)
}
type (
@ -383,6 +554,10 @@ func (srv *Service) getFollowers(
return resData, nil
}
// Unfortunately, this function is very unreliable for pulling chronological
// subscription records. Twitch API does not currently provide a mechanism for
// fetching the latest subscriber; this will need to be tracked manually.
/*
type (
Subscriber struct {
BroadcasterID string `json:"broadcaster_id"`
@ -409,10 +584,6 @@ type (
Points int `json:"points"`
}
)
// Unfortunately, this function is very unreliable for pulling chronological
// subscription records. Twitch API does not currently provide a mechanism for
// fetching the latest subscriber; this will need to be tracked manually.
/*
func (srv *Service) getSubscribers(
ctx context.Context,
broadcasterID string,

View file

@ -22,7 +22,7 @@ type (
EventSubSubscription struct {
ID string `json:"id"`
Status string `json:"status"`
Type string `json:"type"`
Type SubscriptionType `json:"type"`
Version string `json:"version"`
Cost int `json:"cost"`
Condition any `json:"condition"`

View file

@ -0,0 +1,10 @@
package api
type ChatMessageType string
const MESSAGE_TEXT ChatMessageType = "text"
const MESSAGE_CHANNEL_POINTS_HIGHLIGHTED ChatMessageType = "channel_points_highlighted"
const MESSAGE_CHANNEL_POINTS_SUB_ONLY ChatMessageType = "channel_points_sub_only"
const MESSAGE_USER_INTRO ChatMessageType = "user_intro"
const MESSAGE_POWER_UPS_MESSAGE_EFFECT ChatMessageType = "power_ups_message_effect"
const MESSAGE_POWER_UPS_GIGANTIFIED_EMOTE ChatMessageType = "power_ups_gigantified_emote"

View file

@ -22,6 +22,24 @@ type (
IsGift bool `json:"is_gift"`
}
SubscribeMessageEmote struct {
Begin int `json:"begin"`
End int `json:"end"`
ID string `json:"id"`
}
SubscribeMessageContent struct {
Text string `json:"text"`
Emotes []SubscribeMessageEmote `json:"emotes"`
}
SubscribeMessageEvent struct {
ChannelEvent
Tier string `json:"tier"`
Message SubscribeMessageContent `json:"message"`
CumulativeMonths int `json:"cumulative_months"`
StreakMonths int `json:"streak_months"`
DurationMonths int `json:"duration_months"`
}
CheerEvent struct {
ChannelEvent
Message string `json:"message"`
@ -97,11 +115,11 @@ type (
}
ChatMessageFragment struct {
Type string `json:"type"`
Text string `json:"text"`
Cheermote *ChatMessageCheermote `json:"cheermote"`
Emote *ChatMessageEmote `json:"emote"`
Mention *ChatMessageMention `json:"mention"`
Gif *ChatMessageGif `json:"gif"`
Text string `json:"text,omitempty"`
Cheermote *ChatMessageCheermote `json:"cheermote,omitempty"`
Emote *ChatMessageEmote `json:"emote,omitempty"`
Mention *ChatMessageMention `json:"mention,omitempty"`
Gif *ChatMessageGif `json:"gif,omitempty"`
}
ChatMessage struct {
Text string `json:"text"`
@ -143,7 +161,7 @@ type (
MessageID string `json:"message_id"`
Message ChatMessage `json:"message"`
MessageType string `json:"message_type"`
MessageType ChatMessageType `json:"message_type"`
Badges []ChatBadge `json:"badges"`
Cheer *ChatCheer `json:"cheer"`
@ -163,7 +181,7 @@ type (
IsSourceOnly bool `json:"is_source_only"`
}
ChatDeleteEvent struct {
ChatClearUserMessagesEvent struct {
BroadcasterID string `json:"broadcaster_user_id"`
BroadcasterLogin string `json:"broadcaster_user_login"`
BroadcasterName string `json:"broadcaster_user_name"`
@ -171,7 +189,54 @@ type (
TargetID string `json:"target_user_id"`
TargetLogin string `json:"target_user_login"`
TargetName string `json:"target_user_name"`
}
ChatDeleteEvent struct {
ChatClearUserMessagesEvent
MessageID string `json:"message_id"`
}
ChatAnnouncement struct {
Color string `json:"color"`
}
CharityDonationAmount struct {
Value int `json:"value"`
DecimalPlace int `json:"decimal_place"`
Currency string `json:"currency"`
}
CharityDonation struct {
CharityName string `json:"charity_name"`
Amount CharityDonationAmount `json:"amount"`
}
WatchStreak struct {
StreakCount int `json:"streak_count"`
ChannelPointsAwarded int `json:"channel_points_awarded"`
}
Modiversary struct {
Months int `json:"months"`
}
ChatNotificationEvent struct {
BroadcasterID string `json:"broadcaster_user_id"`
BroadcasterLogin string `json:"broadcaster_user_login"`
BroadcasterName string `json:"broadcaster_user_name"`
ChatterID string `json:"chatter_user_id"`
ChatterName string `json:"chatter_user_name"`
ChatterIsAnonymous bool `json:"chatter_is_anonymous"`
Color string `json:"color"`
Badges []ChatBadge `json:"badges"`
// The message Twitch shows in the chat room for this notice.
SystemMessage string `json:"system_message"`
MessageID string `json:"message_id"`
Message ChatMessage `json:"message"`
NoticeType ChatNoticeType `json:"notice_type"`
Announcement ChatAnnouncement `json:"announcement"`
CharityDonation CharityDonation `json:"charity_donation"`
WatchStreak WatchStreak `json:"watch_streak"`
Modiversary Modiversary `json:"modiversary"`
}
)

29
twitch/api/notice.go Normal file
View file

@ -0,0 +1,29 @@
package api
type ChatNoticeType string
const NOTICE_SUB ChatNoticeType = "sub"
const NOTICE_RESUB ChatNoticeType = "resub"
const NOTICE_SUB_GIFT ChatNoticeType = "sub_gift"
const NOTICE_COMMUNITY_SUB_GIFT ChatNoticeType = "community_sub_gift"
const NOTICE_GIFT_PAID_UPGRADE ChatNoticeType = "gift_paid_upgrade"
const NOTICE_PRIME_PAID_UPGRADE ChatNoticeType = "prime_paid_upgrade"
const NOTICE_RAID ChatNoticeType = "raid"
const NOTICE_UNRAID ChatNoticeType = "unraid"
const NOTICE_PAY_IT_FORWARD ChatNoticeType = "pay_it_forward"
const NOTICE_ANNOUNCEMENT ChatNoticeType = "announcement"
const NOTICE_BITS_BADGE_TIER ChatNoticeType = "bits_badge_tier"
const NOTICE_CHARITY_DONATION ChatNoticeType = "charity_donation"
const NOTICE_WATCH_STREAK ChatNoticeType = "watch_streak"
const NOTICE_MODIVERSARY ChatNoticeType = "modiversary"
const NOTICE_SHARED_CHAT_SUB ChatNoticeType = "shared_chat_sub"
const NOTICE_SHARED_CHAT_RESUB ChatNoticeType = "shared_chat_resub"
const NOTICE_SHARED_CHAT_SUB_GIFT ChatNoticeType = "shared_chat_sub_gift"
const NOTICE_SHARED_CHAT_COMMUNITY_SUB_GIFT ChatNoticeType = "shared_chat_community_sub_gift"
const NOTICE_SHARED_CHAT_GIFT_PAID_UPGRADE ChatNoticeType = "shared_chat_gift_paid_upgrade"
const NOTICE_SHARED_CHAT_PRIME_PAID_UPGRADE ChatNoticeType = "shared_chat_prime_paid_upgrade"
const NOTICE_SHARED_CHAT_RAID ChatNoticeType = "shared_chat_raid"
const NOTICE_SHARED_CHAT_PAY_IT_FORWARD ChatNoticeType = "shared_chat_pay_it_forward"
const NOTICE_SHARED_CHAT_ANNOUNCEMENT ChatNoticeType = "shared_chat_announcement"
const NOTICE_SHARED_CHAT_MODIVERSARY ChatNoticeType = "shared_chat_modiversary"
const NOTICE_UNKNOWN ChatNoticeType = "unknown"

206
twitch/api/subscriptions.go Normal file
View file

@ -0,0 +1,206 @@
package api
type SubscriptionType string
// Automod
// Version 1 - A user is notified if a message is caught by automod for review.
const AUTOMOD_MESSAGE_HOLD SubscriptionType = "automod.message.hold"
// Version 2 - A user is notified if a message is caught by automod for review. Only public blocked terms trigger notifications, not private ones.
const AUTOMOD_MESSAGE_HOLD_V2 SubscriptionType = "automod.message.hold"
// Version 1 - A message in the automod queue had its status changed.
const AUTOMOD_MESSAGE_UPDATE SubscriptionType = "automod.message.update"
// Version 2 - A message in the automod queue had its status changed. Only public blocked terms trigger notifications, not private ones.
const AUTOMOD_MESSAGE_UPDATE_V2 SubscriptionType = "automod.message.update"
// Version 1 - A notification is sent when a broadcasters automod settings are updated.
const AUTOMOD_SETTINGS_UPDATE SubscriptionType = "automod.settings.update"
// Version 1 - A notification is sent when a broadcasters automod terms are updated. Changes to private terms are not sent.
const AUTOMOD_TERMS_UPDATE SubscriptionType = "automod.terms.update"
// Channel
// Version 1 - A notification is sent whenever Bits are used on a channel.
const CHANNEL_BITS_USE SubscriptionType = "channel.bits.use"
// Version 2 - A broadcaster updates their channel properties e.g., category, title, content classification labels, broadcast, or language.
const CHANNEL_UPDATE SubscriptionType = "channel.update"
// Version 2 - A specified channel receives a follow.
const CHANNEL_FOLLOW SubscriptionType = "channel.follow"
// Version 1 - A midroll commercial break has started running.
const CHANNEL_AD_BREAK_BEGIN SubscriptionType = "channel.ad_break.begin"
// Chat
// Version 1 - A moderator or bot has cleared all messages from the chat room.
const CHANNEL_CHAT_CLEAR SubscriptionType = "channel.chat.clear"
// Version 1 - A moderator or bot has cleared all messages from a specific user.
const CHANNEL_CHAT_CLEAR_USER_MESSAGES SubscriptionType = "channel.chat.clear_user_messages"
// Version 1 - Any user sends a message to a specific chat room.
const CHANNEL_CHAT_MESSAGE SubscriptionType = "channel.chat.message"
// Version 1 - A moderator has removed a specific message.
const CHANNEL_CHAT_MESSAGE_DELETE SubscriptionType = "channel.chat.message_delete"
// Version 1 - A notification for when an event that appears in chat has occurred.
const CHANNEL_CHAT_NOTIFICATION SubscriptionType = "channel.chat.notification"
// Version 1 - A notification for when a broadcasters chat settings are updated.
const CHANNEL_CHAT_SETTINGS_UPDATE SubscriptionType = "channel.chat_settings.update"
// Version 1 - A user is notified if their message is caught by automod.
const CHANNEL_CHAT_USER_MESSAGE_HOLD SubscriptionType = "channel.chat.user_message_hold"
// Version 1 - A user is notified if their messages automod status is updated.
const CHANNEL_CHAT_USER_MESSAGE_UPDATE SubscriptionType = "channel.chat.user_message_update"
// Shared Chat
// Version 1 - A notification when a channel becomes active in an active shared chat session.
const CHANNEL_SHARED_CHAT_SESSION_BEGIN SubscriptionType = "channel.shared_chat.begin"
// Version 1 - A notification when the active shared chat session the channel is in changes.
const CHANNEL_SHARED_CHAT_SESSION_UPDATE SubscriptionType = "channel.shared_chat.update"
// Version 1 - A notification when a channel leaves a shared chat session or the session ends.
const CHANNEL_SHARED_CHAT_SESSION_END SubscriptionType = "channel.shared_chat.end"
// Subscriptions
// Version 1 - A notification is sent when a specified channel receives a subscriber. This does not include resubscribes.
const CHANNEL_SUBSCRIBE SubscriptionType = "channel.subscribe"
// Version 1 - A notification when a subscription to the specified channel ends.
const CHANNEL_SUBSCRIPTION_END SubscriptionType = "channel.subscription.end"
// Version 1 - A notification when a viewer gives a gift subscription to one or more users in the specified channel.
const CHANNEL_SUBSCRIPTION_GIFT SubscriptionType = "channel.subscription.gift"
// Version 1 - A notification when a user sends a resubscription chat message in a specific channel.
const CHANNEL_SUBSCRIPTION_MESSAGE SubscriptionType = "channel.subscription.message"
// Version 1 - A user cheers on the specified channel.
const CHANNEL_CHEER SubscriptionType = "channel.cheer"
// Version 1 - A broadcaster raids another broadcasters channel.
const CHANNEL_RAID SubscriptionType = "channel.raid"
// Version 1 - A viewer is banned from the specified channel.
const CHANNEL_BAN SubscriptionType = "channel.ban"
// Version 1 - A viewer is unbanned from the specified channel.
const CHANNEL_UNBAN SubscriptionType = "channel.unban"
// Version 1 - A user creates an unban request.
const CHANNEL_UNBAN_REQUEST_CREATE SubscriptionType = "channel.unban_request.create"
// Version 1 - An unban request has been resolved.
const CHANNEL_UNBAN_REQUEST_RESOLVE SubscriptionType = "channel.unban_request.resolve"
// Version 1 - A moderator performs a moderation action in a channel.
const CHANNEL_MODERATE SubscriptionType = "channel.moderate"
// Version 2 - A moderator performs a moderation action in a channel. Includes warnings.
const CHANNEL_MODERATE_V2 SubscriptionType = "channel.moderate"
// Version 1 - Moderator privileges were added to a user on a specified channel.
const CHANNEL_MODERATOR_ADD SubscriptionType = "channel.moderator.add"
// Version 1 - Moderator privileges were removed from a user on a specified channel.
const CHANNEL_MODERATOR_REMOVE SubscriptionType = "channel.moderator.remove"
// Version beta - The host began a new Guest Star session.
const CHANNEL_GUEST_STAR_SESSION_BEGIN SubscriptionType = "channel.guest_star_session.begin"
// Version beta - A running Guest Star session has ended.
const CHANNEL_GUEST_STAR_SESSION_END SubscriptionType = "channel.guest_star_session.end"
// Version beta - A guest or a slot is updated in an active Guest Star session.
const CHANNEL_GUEST_STAR_GUEST_UPDATE SubscriptionType = "channel.guest_star_guest.update"
// Version beta - The host preferences for Guest Star have been updated.
const CHANNEL_GUEST_STAR_SETTINGS_UPDATE SubscriptionType = "channel.guest_star_settings.update"
// Version 1 - A viewer has redeemed an automatic channel points reward on the specified channel.
const CHANNEL_POINTS_AUTOMATIC_REWARD_REDEMPTION_ADD SubscriptionType = "channel.channel_points_automatic_reward_redemption.add"
// Version 2 - A viewer has redeemed an automatic channel points reward on the specified channel.
const CHANNEL_POINTS_AUTOMATIC_REWARD_REDEMPTION_ADD_V2 SubscriptionType = "channel.channel_points_automatic_reward_redemption.add"
// Version 1 - A custom channel points reward has been created for the specified channel.
const CHANNEL_POINTS_CUSTOM_REWARD_ADD SubscriptionType = "channel.channel_points_custom_reward.add"
// Version 1 - A custom channel points reward has been updated for the specified channel.
const CHANNEL_POINTS_CUSTOM_REWARD_UPDATE SubscriptionType = "channel.channel_points_custom_reward.update"
// Version 1 - A custom channel points reward has been removed from the specified channel.
const CHANNEL_POINTS_CUSTOM_REWARD_REMOVE SubscriptionType = "channel.channel_points_custom_reward.remove"
// Version 1 - A viewer has redeemed a custom channel points reward on the specified channel.
const CHANNEL_POINTS_CUSTOM_REWARD_REDEMPTION_ADD SubscriptionType = "channel.channel_points_custom_reward_redemption.add"
// Version 1 - A redemption of a channel points custom reward has been updated for the specified channel.
const CHANNEL_POINTS_CUSTOM_REWARD_REDEMPTION_UPDATE SubscriptionType = "channel.channel_points_custom_reward_redemption.update"
// Version 1 - A viewer has redeemed a custom Power-up on the specified channel.
const CHANNEL_CUSTOM_POWER_UPS_REDEMPTION_ADD SubscriptionType = "channel.custom_power_up_redemption.add"
// Version 1 - A poll started on a specified channel.
const CHANNEL_POLL_BEGIN SubscriptionType = "channel.poll.begin"
// Version 1 - Users respond to a poll on a specified channel.
const CHANNEL_POLL_PROGRESS SubscriptionType = "channel.poll.progress"
// Version 1 - A poll ended on a specified channel.
const CHANNEL_POLL_END SubscriptionType = "channel.poll.end"
// Version 1 - A Prediction started on a specified channel.
const CHANNEL_PREDICTION_BEGIN SubscriptionType = "channel.prediction.begin"
// Version 1 - Users participated in a Prediction on a specified channel.
const CHANNEL_PREDICTION_PROGRESS SubscriptionType = "channel.prediction.progress"
// Version 1 - A Prediction was locked on a specified channel.
const CHANNEL_PREDICTION_LOCK SubscriptionType = "channel.prediction.lock"
// Version 1 - A Prediction ended on a specified channel.
const CHANNEL_PREDICTION_END SubscriptionType = "channel.prediction.end"
// Version 1 - A chat message has been sent by a suspicious user.
const CHANNEL_SUSPICIOUS_USER_MESSAGE SubscriptionType = "channel.suspicious_user.message"
// Version 1 - A suspicious user has been updated.
const CHANNEL_SUSPICIOUS_USER_UPDATE SubscriptionType = "channel.suspicious_user.update"
// Version 1 - A VIP is added to the channel.
const CHANNEL_VIP_ADD SubscriptionType = "channel.vip.add"
// Version 1 - A VIP is removed from the channel.
const CHANNEL_VIP_REMOVE SubscriptionType = "channel.vip.remove"
// Version 1 - A user acknowledges a warning. Broadcasters and moderators can see the warnings details.
const CHANNEL_WARNING_ACKNOWLEDGEMENT SubscriptionType = "channel.warning.acknowledge"
// Version 1 - A user is sent a warning. Broadcasters and moderators can see the warnings details.
const CHANNEL_WARNING_SEND SubscriptionType = "channel.warning.send"
// Version 1 - Sends an event notification when a user donates to the broadcasters charity campaign.
const CHARITY_DONATION SubscriptionType = "channel.charity_campaign.donate"
// Version 1 - Sends an event notification when the broadcaster starts a charity campaign.
const CHARITY_CAMPAIGN_START SubscriptionType = "channel.charity_campaign.start"
// Version 1 - Sends an event notification when progress is made towards the campaigns goal or when the broadcaster changes the fundraising goal.
const CHARITY_CAMPAIGN_PROGRESS SubscriptionType = "channel.charity_campaign.progress"
// Version 1 - Sends an event notification when the broadcaster stops a charity campaign.
const CHARITY_CAMPAIGN_STOP SubscriptionType = "channel.charity_campaign.stop"
// Version 1 - Sends a notification when EventSub disables a shard due to the status of the underlying transport changing.
const CONDUIT_SHARD_DISABLED SubscriptionType = "conduit.shard.disabled"
// Version 1 - An entitlement for a Drop is granted to a user.
const DROP_ENTITLEMENT_GRANT SubscriptionType = "drop.entitlement.grant"
// Version 1 - A Bits transaction occurred for a specified Twitch Extension.
const EXTENSION_BITS_TRANSACTION_CREATE SubscriptionType = "extension.bits_transaction.create"
// Version 1 - Get notified when a broadcaster begins a goal.
const GOAL_BEGIN SubscriptionType = "channel.goal.begin"
// Version 1 - Get notified when progress (either positive or negative) is made towards a broadcasters goal.
const GOAL_PROGRESS SubscriptionType = "channel.goal.progress"
// Version 1 - Get notified when a broadcaster ends a goal.
const GOAL_END SubscriptionType = "channel.goal.end"
// Version 2 - A Hype Train begins on the specified channel.
const HYPE_TRAIN_BEGIN SubscriptionType = "channel.hype_train.begin"
// Version 2 - A Hype Train makes progress on the specified channel.
const HYPE_TRAIN_PROGRESS SubscriptionType = "channel.hype_train.progress"
// Version 2 - A Hype Train ends on the specified channel.
const HYPE_TRAIN_END SubscriptionType = "channel.hype_train.end"
// Version 1 - Sends a notification when the broadcaster activates Shield Mode.
const SHIELD_MODE_BEGIN SubscriptionType = "channel.shield_mode.begin"
// Version 1 - Sends a notification when the broadcaster deactivates Shield Mode.
const SHIELD_MODE_END SubscriptionType = "channel.shield_mode.end"
// Version 1 - Sends a notification when the specified broadcaster sends a Shoutout.
const SHOUTOUT_CREATE SubscriptionType = "channel.shoutout.create"
// Version 1 - Sends a notification when the specified broadcaster receives a Shoutout.
const SHOUTOUT_RECEIVED SubscriptionType = "channel.shoutout.receive"
// Version 1 - The specified broadcaster starts a stream.
const STREAM_ONLINE SubscriptionType = "stream.online"
// Version 1 - The specified broadcaster stops a stream.
const STREAM_OFFLINE SubscriptionType = "stream.offline"
// Version 1 - A users authorization has been granted to your client id.
const USER_AUTHORIZATION_GRANT SubscriptionType = "user.authorization.grant"
// Version 1 - A users authorization has been revoked for your client id.
const USER_AUTHORIZATION_REVOKE SubscriptionType = "user.authorization.revoke"
// Version 1 - A user has updated their account.
const USER_UPDATE SubscriptionType = "user.update"
// Version 1 - A user receives a whisper.
const WHISPER_RECEIVED SubscriptionType = "user.whisper.message"

14
twitch/pages/chat.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Twitch Chat</title>
<link rel="stylesheet" href="/public/fonts/inter/inter.css">
<link rel="stylesheet" href="/twitch/public/chat.css">
</head>
<body>
<div id="chat"></div>
<script type="module" src="/twitch/public/chat.js" defer></script>
</body>
</html>

0
twitch/public/chat.css Normal file
View file

38
twitch/public/chat.js Normal file
View file

@ -0,0 +1,38 @@
const chatContainer = document.getElementById("chat");
async function sync() {
let eventSource;
try {
eventSource = new EventSource("/twitch/sse/chat");
} catch (err) {
console.error("Failed to connect to event source", err);
return;
}
eventSource.addEventListener("error", () => {
eventSource.close();
console.error("Connection lost, or an error has occurred.");
console.log("Attempting to reconnect...");
setTimeout(() => {
sync();
}, 1000);
});
eventSource.addEventListener("chat", event => {
console.log(`[${chat}] ${event.data}`);
const messageEl = document.createElement("p");
messageEl.innerText = event.data;
chatContainer.appendChild(messageEl);
});
eventSource.addEventListener("open", () => {
console.log("Connected.");
});
}
function twitchEmoteURL(emoteID) {
return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/1.0#e=0`
}
sync();

View file

@ -38,6 +38,26 @@ type (
LatestSubscriber *twitchLabel
LatestCheer *twitchLabel
}
ChatMessageModifier string
chatMessage struct {
ID string `json:"id"`
Username string `json:"username,omitempty"`
Colour string `json:"colour,omitempty"`
Text string `json:"text,omitempty"`
Fragments []api.ChatMessageFragment `json:"fragments,omitempty"`
Modifier ChatMessageModifier `json:"modifier,omitempty"`
AnnouncementColour string `json:"announcement_colour,omitempty"`
}
SystemChatMessageType string
systemChatMessage struct {
Text string `json:"text,omitempty"`
Type SystemChatMessageType `json:"type,omitempty"`
}
deleteChatMessage struct {
Username string `json:"username,omitempty"`
MessageID string `json:"message_id,omitempty"`
}
ServiceOptions struct {
Host string
@ -63,6 +83,13 @@ type (
eventSubSession *api.EventSubSession
labels *twitchLabels
ChatC chan *chatMessage
ChatBroadcast broadcast.BroadcastChannel[*chatMessage]
SystemChatC chan *systemChatMessage
SystemChatBroadcast broadcast.BroadcastChannel[*systemChatMessage]
DeleteChatC chan *deleteChatMessage
DeleteChatBroadcast broadcast.BroadcastChannel[*deleteChatMessage]
}
)
@ -70,6 +97,20 @@ const (
LABEL_LATEST_FOLLOWER string = "latest-follower"
LABEL_LATEST_SUBSCRIBER string = "latest-subscriber"
LABEL_LATEST_CHEER string = "latest-cheer"
CHAT_MODIFIER_NONE ChatMessageModifier = ""
CHAT_MODIFIER_SUBSCRIBE ChatMessageModifier = "subscribe"
CHAT_MODIFIER_CHEER ChatMessageModifier = "cheer"
CHAT_MODIFIER_HIGHLIGHT ChatMessageModifier = "highlight"
CHAT_MODIFIER_ANNOUNCEMENT ChatMessageModifier = "announcement"
SYSTEM_CHAT_FOLLOW SystemChatMessageType = "follow"
SYSTEM_CHAT_SUBSCRIBE SystemChatMessageType = "subscribe"
SYSTEM_CHAT_CHEER SystemChatMessageType = "cheer"
SYSTEM_CHAT_RAID SystemChatMessageType = "raid"
SYSTEM_CHAT_POLL SystemChatMessageType = "poll"
SYSTEM_CHAT_SHOUTOUT SystemChatMessageType = "shoutout"
SYSTEM_CHAT_POINT_REDEEM SystemChatMessageType = "point-redeem"
)
var DATA_PATH string = path.Join(config.CONFIG_DIR, "twitch")
@ -98,6 +139,16 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) {
latestCheerC := make(chan string)
latestCheerBroadcast := broadcast.NewBroadcastChannel(
ctx, latestCheerC)
chatC := make(chan *chatMessage)
chatBroadcast := broadcast.NewBroadcastChannel(
ctx, chatC)
systemChatC := make(chan *systemChatMessage)
systemChatBroadcast := broadcast.NewBroadcastChannel(
ctx, systemChatC)
deleteChatC := make(chan *deleteChatMessage)
deleteChatBroadcast := broadcast.NewBroadcastChannel(
ctx, deleteChatC)
srv := &Service{
host: opts.Host,
@ -138,6 +189,13 @@ func New(ctx context.Context, opts ServiceOptions) (*Service, error) {
},
RedirectURL: fmt.Sprintf("http://%s:%d/twitch/auth", opts.Host, opts.Port),
},
ChatC: chatC,
ChatBroadcast: chatBroadcast,
SystemChatC: systemChatC,
SystemChatBroadcast: systemChatBroadcast,
DeleteChatC: deleteChatC,
DeleteChatBroadcast: deleteChatBroadcast,
}
if authFile, err := os.OpenFile(AUTH_FILEPATH, os.O_RDONLY, 0600); err != nil {
@ -203,7 +261,7 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
go srv.start(ctx)
})
group.GET("/sse/:label", func(ctx *gin.Context) {
group.GET("/sse/label/:label", func(ctx *gin.Context) {
ctx.Header("connection", "keep-alive")
labelParam := ctx.Param("label")
@ -250,6 +308,35 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/labels.html")
})
group.GET("/sse/chat", func(ctx *gin.Context) {
ctx.Header("connection", "keep-alive")
chatUpdate := srv.ChatBroadcast.Subscribe()
defer srv.ChatBroadcast.Cancel(chatUpdate)
systemChatUpdate := srv.SystemChatBroadcast.Subscribe()
defer srv.SystemChatBroadcast.Cancel(systemChatUpdate)
deleteChatUpdate := srv.DeleteChatBroadcast.Subscribe()
defer srv.DeleteChatBroadcast.Cancel(deleteChatUpdate)
ticker := time.NewTicker(10 * time.Millisecond)
ctx.Stream(func(w io.Writer) bool {
select {
case update := <-chatUpdate:
ctx.SSEvent("chat", update)
case update := <-systemChatUpdate:
ctx.SSEvent("system", update)
case update := <-deleteChatUpdate:
ctx.SSEvent("delete", update)
case <-ticker.C:
}
return true
})
})
group.GET("/chat", func(ctx *gin.Context) {
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/chat.html")
})
}
func (srv *Service) Run(ctx context.Context) {