From 5c7bc9a5e4d08a4efb948fc04daee7568bdd8fd5 Mon Sep 17 00:00:00 2001 From: zneix Date: Thu, 12 Mar 2026 00:09:59 +0100 Subject: [PATCH 1/4] feat: split joins to read conn this also refactors the mod/vip detection logic with this change, we will no longer receive mod/vip data from intially JOINing the channel --- cmd/bot/channels.go | 7 ++-- cmd/bot/events.go | 88 ++++++++++++++++++++++------------------- cmd/bot/main.go | 48 ++++++++++++++-------- internal/bot/channel.go | 4 +- internal/bot/types.go | 9 +++-- 5 files changed, 89 insertions(+), 67 deletions(-) diff --git a/cmd/bot/channels.go b/cmd/bot/channels.go index 1bb89b5..2524bec 100644 --- a/cmd/bot/channels.go +++ b/cmd/bot/channels.go @@ -15,7 +15,7 @@ import ( ) // loadChannels fetches configured channels from the database, sets default values and message queue for each of them -func loadChannels(bgctx context.Context, mongoConn *mongo.Connection, twitchIRC *twitch.Client) map[string]*bot.Channel { +func loadChannels(bgctx context.Context, mongoConn *mongo.Connection, twitchWrite *twitch.Client) map[string]*bot.Channel { channels := make(map[string]*bot.Channel) ctx, cancel := context.WithTimeout(bgctx, 10*time.Second) @@ -29,7 +29,6 @@ func loadChannels(bgctx context.Context, mongoConn *mongo.Connection, twitchIRC }) if err != nil { log.Fatalln("[Mongo] Error querying channels:", err) - return channels } for cur.Next(ctx) { @@ -43,7 +42,7 @@ func loadChannels(bgctx context.Context, mongoConn *mongo.Connection, twitchIRC // Initialize default values channel.QueueChannel = make(chan *bot.QueueMessage) - go channel.StartMessageQueue(twitchIRC) + go channel.StartMessageQueue(twitchWrite) channels[channel.ID] = &channel } @@ -107,7 +106,7 @@ func handleChannelsChunk(tcb *bot.Bot, chunk []string) { channel.CurrentTitle = respChannel.Title // JOIN the channel - tcb.TwitchIRC.Join(channel.Login) + tcb.TwitchRead.Join(channel.Login) // Create all EventSub subscriptions parallelly for _, subscription := range channelSubscriptions { diff --git a/cmd/bot/events.go b/cmd/bot/events.go index 188b955..ed2e344 100644 --- a/cmd/bot/events.go +++ b/cmd/bot/events.go @@ -11,17 +11,43 @@ import ( "github.com/zneix/tcb2/internal/bot" ) +// handlerOnNoticeMessage logic for NOTICE twitch IRC messages +// It's taken out of registerEvents since it's used for both read and write conns +func handlerOnNoticeMessage(tcb *bot.Bot, message *twitch.NoticeMessage, connType string) { + channelID, ok := tcb.Logins[message.Channel] + if !ok { + // tcb.Logins map didn't have current channel's ID + // Note: this should realistically never occur though, but early exit to prevent panic + return + } + channel := tcb.Channels[channelID] + + log.Printf("[TwitchIRC:%s] NOTICE %s in %s: %s\n", connType, message.MsgID, channel, message.Message) + + switch message.MsgID { + case "msg_banned", "msg_channel_suspended": + err := channel.ChangeMode(tcb.Mongo, bot.ChannelModeInactive) + if err != nil { + log.Printf("Failed to change mode in %s: %s\n", channel, err) + } + default: + } +} + func registerEvents(tcb *bot.Bot) { // Twitch IRC events // Authenticated with IRC - tcb.TwitchIRC.OnConnect(func() { - log.Println("[TwitchIRC] connected") + tcb.TwitchRead.OnConnect(func() { + log.Println("[TwitchIRC:read] connected, joining channels") joinChannels(tcb) }) + tcb.TwitchWrite.OnConnect(func() { + log.Println("[TwitchIRC:write] connected") + }) // PRIVMSG - tcb.TwitchIRC.OnPrivateMessage(func(message twitch.PrivateMessage) { + tcb.TwitchRead.OnPrivateMessage(func(message twitch.PrivateMessage) { // Early out in case message does not start with command prefix - meaning it's not a command if !strings.HasPrefix(message.Message, tcb.Commands.Prefix) { // Handle non-commands @@ -64,7 +90,9 @@ func registerEvents(tcb *bot.Bot) { }) // USERSTATE - tcb.TwitchIRC.OnUserStateMessage(func(message twitch.UserStateMessage) { + // These will be triggered whenever a message is written to a channel - so react to those on write connection + // They might also be received upon JOINing on authed connection, however we don't do that + tcb.TwitchWrite.OnUserStateMessage(func(message twitch.UserStateMessage) { channelID, ok := tcb.Logins[message.Channel] if !ok { // tcb.Logins map didn't have current channel's ID @@ -74,30 +102,21 @@ func registerEvents(tcb *bot.Bot) { channel := tcb.Channels[channelID] - // Check if Channel.Mode changed by comparing bot's state - newMode := bot.ChannelModeNormal - // Bot will always have elevated permissions in its own chat, saving some time with the early-out if channel.Login == tcb.Self.Login { return } - userType, ok := message.Tags["user-type"] - switch { - case !ok: - log.Println("[TwitchIRC:USERSTATE] user-type tag was not found in the IRC message, either no capabilities or Twitch removed this tag xd") + // Check if Channel.Mode changed by comparing bot's state + newMode := bot.ChannelModeNormal - case userType == "mod": + // Check if we have privileged write limits - by being either a moderator or vip + // 1. Check for being a moderator - do this via 'mod' message tag which should be present on every twitch PRIVMSG + // 2. Check for being a VIP - do this via checking if user has a VIP badge + if modTag, ok := message.Tags["mod"]; ok && modTag == "1" { + newMode = bot.ChannelModeModerator + } else if _, ok := message.User.Badges["vip"]; ok { newMode = bot.ChannelModeModerator - - default: - // Since user-type does not care about VIP status, we need to check badges - for key := range message.User.Badges { - if key == "vip" || key == "moderator" { - newMode = bot.ChannelModeModerator - break - } - } } // Update ChannelMode in the current channel if it differs @@ -110,25 +129,14 @@ func registerEvents(tcb *bot.Bot) { }) // NOTICE - tcb.TwitchIRC.OnNoticeMessage(func(message twitch.NoticeMessage) { - channelID, ok := tcb.Logins[message.Channel] - if !ok { - // tcb.Logins map didn't have current channel's ID - // Note: this should realistically never occur though, but early exit to prevent panic - return - } - channel := tcb.Channels[channelID] - - log.Printf("[TwitchIRC:NOTICE] %s in %s\n", message.MsgID, channel) - - switch message.MsgID { - case "msg_banned", "msg_channel_suspended": - err := channel.ChangeMode(tcb.Mongo, bot.ChannelModeInactive) - if err != nil { - log.Printf("Failed to change mode in %s: %s\n", channel, err) - } - default: - } + // This might be relevant for both read and write connections: + // on Read: a channel might be suspended + // on Write: the bot user might be banned from channel it attempts to send a message in + tcb.TwitchRead.OnNoticeMessage(func(message twitch.NoticeMessage) { + handlerOnNoticeMessage(tcb, &message, "read") + }) + tcb.TwitchWrite.OnNoticeMessage(func(message twitch.NoticeMessage) { + handlerOnNoticeMessage(tcb, &message, "write") }) // Twitch EventSub events diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 16fd422..93e4a08 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log" "time" @@ -29,8 +30,11 @@ func main() { mongoConnection := mongo.NewMongoConnection(ctx, cfg) mongoConnection.Connect(ctx) - twitchIRC := twitch.NewClient(cfg.TwitchLogin, "oauth:"+cfg.TwitchOAuth) - twitchIRC.SetJoinRateLimiter(twitch.CreateVerifiedRateLimiter()) + // twitch read conn + twitchRead := twitch.NewAnonymousClient() + + // twitch write conn + twitchWrite := twitch.NewClient(cfg.TwitchLogin, "oauth:"+cfg.TwitchOAuth) helixClient, err := helixclient.New(cfg) if err != nil { @@ -41,20 +45,19 @@ func main() { esub := eventsub.New(cfg, apiServer) - self := &bot.Self{ - Login: cfg.TwitchLogin, - OAuth: cfg.TwitchOAuth, - } - tcb := &bot.Bot{ - TwitchIRC: twitchIRC, - Mongo: mongoConnection, - Helix: helixClient, - EventSub: esub, - Logins: make(map[string]string), - Channels: loadChannels(ctx, mongoConnection, twitchIRC), - Commands: bot.NewCommandController(cfg.CommandPrefix), - Self: self, + TwitchRead: twitchRead, + TwitchWrite: twitchWrite, + Mongo: mongoConnection, + Helix: helixClient, + EventSub: esub, + Logins: make(map[string]string), + Channels: loadChannels(ctx, mongoConnection, twitchWrite), + Commands: bot.NewCommandController(cfg.CommandPrefix), + Self: &bot.Self{ + Login: cfg.TwitchLogin, + OAuth: cfg.TwitchOAuth, + }, StartTime: time.Now(), } @@ -70,8 +73,19 @@ func main() { supinic := supinicapi.New(cfg.SupinicAPIKey) go supinic.UpdateAliveStatus() - err = tcb.TwitchIRC.Connect() + // Connect twitch connections + // TODO: Use proper waiting for both connections - maybe use channels waiting for closure + // Check twitch connection's .Connect for a good example + // For now as a scuffed fix read connection will be blocking + go func() { + err := tcb.TwitchWrite.Connect() + if err != nil { + log.Fatalln(fmt.Errorf("Twitch write connection errored: %w", err)) + } + }() + + err = tcb.TwitchRead.Connect() if err != nil { - log.Fatalln(err) + log.Fatalln(fmt.Errorf("Twitch read connection errored: %w", err)) } } diff --git a/internal/bot/channel.go b/internal/bot/channel.go index 57297a1..a5bcca1 100644 --- a/internal/bot/channel.go +++ b/internal/bot/channel.go @@ -35,13 +35,13 @@ func (channel *Channel) String() string { return fmt.Sprintf("#%s(%s)", channel.Login, channel.ID) } -func (channel *Channel) StartMessageQueue(twitchIRC *twitch.Client) { +func (channel *Channel) StartMessageQueue(twitchWrite *twitch.Client) { // log.Println("Starting message queue for", channel) defer log.Println("[Channel] Message queue suddenly quit(?) for", channel) for message := range channel.QueueChannel { // Actually send the message to the chat - twitchIRC.Say(channel.Login, message.Message) + twitchWrite.Say(channel.Login, message.Message) // Update last sent message channel.LastMsg = message.Message diff --git a/internal/bot/types.go b/internal/bot/types.go index 7b00922..e42ebc4 100644 --- a/internal/bot/types.go +++ b/internal/bot/types.go @@ -21,10 +21,11 @@ type Self struct { } type Bot struct { - TwitchIRC *twitch.Client - Mongo *mongo.Connection - Helix *helix.Client - EventSub *eventsub.EventSub + TwitchRead *twitch.Client + TwitchWrite *twitch.Client + Mongo *mongo.Connection + Helix *helix.Client + EventSub *eventsub.EventSub loginsMu sync.Mutex Logins map[string]string From 69fc8572deba8749a1ca5cb8b81ca427d932e91a Mon Sep 17 00:00:00 2001 From: zneix Date: Thu, 12 Mar 2026 00:14:50 +0100 Subject: [PATCH 2/4] use verified bot rate limit for read connection anonymous connections can join channels very fast so we don't have to worry about slow JOINs anymore --- cmd/bot/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 93e4a08..3ca05ce 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -32,6 +32,7 @@ func main() { // twitch read conn twitchRead := twitch.NewAnonymousClient() + twitchRead.SetJoinRateLimiter(twitch.CreateVerifiedRateLimiter()) // twitch write conn twitchWrite := twitch.NewClient(cfg.TwitchLogin, "oauth:"+cfg.TwitchOAuth) From e5b891494d0f8eeabebbed58bf9d421b1a39b6b3 Mon Sep 17 00:00:00 2001 From: zneix Date: Tue, 10 Mar 2026 23:52:01 +0100 Subject: [PATCH 3/4] use new IsMod and IsVip for simpler mode check logic --- cmd/bot/events.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cmd/bot/events.go b/cmd/bot/events.go index ed2e344..191b55e 100644 --- a/cmd/bot/events.go +++ b/cmd/bot/events.go @@ -107,15 +107,10 @@ func registerEvents(tcb *bot.Bot) { return } - // Check if Channel.Mode changed by comparing bot's state + // Check if Channel.Mode changed to see if we now have privileged write limits - by being either a moderator or vip newMode := bot.ChannelModeNormal - // Check if we have privileged write limits - by being either a moderator or vip - // 1. Check for being a moderator - do this via 'mod' message tag which should be present on every twitch PRIVMSG - // 2. Check for being a VIP - do this via checking if user has a VIP badge - if modTag, ok := message.Tags["mod"]; ok && modTag == "1" { - newMode = bot.ChannelModeModerator - } else if _, ok := message.User.Badges["vip"]; ok { + if message.User.IsMod || message.User.IsVip { newMode = bot.ChannelModeModerator } From 80a3045e50c8607f0efe322a6f404c200df5eab7 Mon Sep 17 00:00:00 2001 From: zneix Date: Thu, 12 Mar 2026 12:44:33 +0100 Subject: [PATCH 4/4] chore/fix: linter corrections --- cmd/bot/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 3ca05ce..b641b86 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -79,14 +79,14 @@ func main() { // Check twitch connection's .Connect for a good example // For now as a scuffed fix read connection will be blocking go func() { - err := tcb.TwitchWrite.Connect() + err = tcb.TwitchWrite.Connect() if err != nil { - log.Fatalln(fmt.Errorf("Twitch write connection errored: %w", err)) + log.Fatalln(fmt.Errorf("twitch write connection errored: %w", err)) } }() err = tcb.TwitchRead.Connect() if err != nil { - log.Fatalln(fmt.Errorf("Twitch read connection errored: %w", err)) + log.Fatalln(fmt.Errorf("twitch read connection errored: %w", err)) } }