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

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,