From dd262f524e0707901b0cc3bb53b79eaf6be80810 Mon Sep 17 00:00:00 2001 From: Chris Sexton <3216719+chrissexton@users.noreply.github.com> Date: Sun, 18 Sep 2022 17:43:44 -0400 Subject: [PATCH] twitch: Add IRC-Discord bridge * Should connect a bridge to streamer's channel any time a stream starts * Should disconnect when stream ends * Add `track` and `untrack` commands to manually modify bridge * Adds support for creating Discord threads --- connectors/discord/discord.go | 94 ++++++++++-------- go.mod | 7 +- go.sum | 12 ++- main.go | 6 +- plugins/twitch/bridge.go | 142 +++++++++++++++++++++++++++ plugins/twitch/irc.go | 177 ++++++++++++++++++++++++++++++++++ plugins/twitch/twitch.go | 80 +++++++++++---- plugins/twitch/twitch_test.go | 2 +- 8 files changed, 453 insertions(+), 67 deletions(-) create mode 100644 plugins/twitch/bridge.go create mode 100644 plugins/twitch/irc.go diff --git a/connectors/discord/discord.go b/connectors/discord/discord.go index d9e7d46..930a4c8 100644 --- a/connectors/discord/discord.go +++ b/connectors/discord/discord.go @@ -30,6 +30,8 @@ type Discord struct { registeredCmds []*discordgo.ApplicationCommand cmdHandlers map[string]CmdHandler + + guildID string } func New(config *config.Config) *Discord { @@ -37,12 +39,17 @@ func New(config *config.Config) *Discord { if err != nil { log.Fatal().Err(err).Msg("Could not connect to Discord") } + guildID := config.Get("discord.guildid", "") + if guildID == "" { + log.Fatal().Msgf("You must set either DISCORD_GUILDID env or discord.guildid db config") + } d := &Discord{ config: config, client: client, uidCache: map[string]string{}, registeredCmds: []*discordgo.ApplicationCommand{}, cmdHandlers: map[string]CmdHandler{}, + guildID: guildID, } d.client.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { if h, ok := d.cmdHandlers[i.ApplicationCommandData().Name]; ok { @@ -155,12 +162,7 @@ func (d *Discord) GetEmojiList(force bool) map[string]string { if d.emojiCache != nil && !force { return d.emojiCache } - guildID := d.config.Get("discord.guildid", "") - if guildID == "" { - log.Error().Msg("no guild ID set") - return map[string]string{} - } - e, err := d.client.GuildEmojis(guildID) + e, err := d.client.GuildEmojis(d.guildID) if err != nil { log.Error().Err(err).Msg("could not retrieve emojis") return map[string]string{} @@ -220,16 +222,11 @@ func (d *Discord) convertUser(u *discordgo.User) *user.User { } nick := u.Username - guildID := d.config.Get("discord.guildid", "") - if guildID == "" { - log.Error().Msg("no guild ID set") - } else { - mem, err := d.client.GuildMember(guildID, u.ID) - if err != nil { - log.Error().Err(err).Msg("could not get guild member") - } else if mem.Nick != "" { - nick = mem.Nick - } + mem, err := d.client.GuildMember(d.guildID, u.ID) + if err != nil { + log.Error().Err(err).Msg("could not get guild member") + } else if mem.Nick != "" { + nick = mem.Nick } return &user.User{ @@ -327,9 +324,8 @@ func (d *Discord) Emojy(name string) string { } func (d *Discord) UploadEmojy(emojy, path string) error { - guildID := d.config.Get("discord.guildid", "") defaultRoles := d.config.GetArray("discord.emojyRoles", []string{}) - _, err := d.client.GuildEmojiCreate(guildID, &discordgo.EmojiParams{ + _, err := d.client.GuildEmojiCreate(d.guildID, &discordgo.EmojiParams{ emojy, path, defaultRoles, }) if err != nil { @@ -339,9 +335,8 @@ func (d *Discord) UploadEmojy(emojy, path string) error { } func (d *Discord) DeleteEmojy(emojy string) error { - guildID := d.config.Get("discord.guildid", "") emojyID := d.GetEmojySnowflake(emojy) - return d.client.GuildEmojiDelete(guildID, emojyID) + return d.client.GuildEmojiDelete(d.guildID, emojyID) } func (d *Discord) URLFormat(title, url string) string { @@ -350,11 +345,7 @@ func (d *Discord) URLFormat(title, url string) string { // GetChannelName returns the channel ID for a human-friendly name (if possible) func (d *Discord) GetChannelID(name string) string { - guildID := d.config.Get("discord.guildid", "") - if guildID == "" { - return name - } - chs, err := d.client.GuildChannels(guildID) + chs, err := d.client.GuildChannels(d.guildID) if err != nil { return name } @@ -378,11 +369,7 @@ func (d *Discord) GetChannelName(id string) string { func (d *Discord) GetRoles() ([]bot.Role, error) { ret := []bot.Role{} - guildID := d.config.Get("discord.guildid", "") - if guildID == "" { - return nil, errors.New("no guildID set") - } - roles, err := d.client.GuildRoles(guildID) + roles, err := d.client.GuildRoles(d.guildID) if err != nil { return nil, err } @@ -398,24 +385,22 @@ func (d *Discord) GetRoles() ([]bot.Role, error) { } func (d *Discord) SetRole(userID, roleID string) error { - guildID := d.config.Get("discord.guildid", "") - member, err := d.client.GuildMember(guildID, userID) + member, err := d.client.GuildMember(d.guildID, userID) if err != nil { return err } for _, r := range member.Roles { if r == roleID { - return d.client.GuildMemberRoleRemove(guildID, userID, roleID) + return d.client.GuildMemberRoleRemove(d.guildID, userID, roleID) } } - return d.client.GuildMemberRoleAdd(guildID, userID, roleID) + return d.client.GuildMemberRoleAdd(d.guildID, userID, roleID) } type CmdHandler func(s *discordgo.Session, i *discordgo.InteractionCreate) func (d *Discord) RegisterSlashCmd(c discordgo.ApplicationCommand, handler CmdHandler) error { - guildID := d.config.Get("discord.guildid", "") - cmd, err := d.client.ApplicationCommandCreate(d.client.State.User.ID, guildID, &c) + cmd, err := d.client.ApplicationCommandCreate(d.client.State.User.ID, d.guildID, &c) d.cmdHandlers[c.Name] = handler if err != nil { return err @@ -426,17 +411,15 @@ func (d *Discord) RegisterSlashCmd(c discordgo.ApplicationCommand, handler CmdHa func (d *Discord) Shutdown() { log.Debug().Msgf("Shutting down and deleting %d slash commands", len(d.registeredCmds)) - guildID := d.config.Get("discord.guildid", "") for _, c := range d.registeredCmds { - if err := d.client.ApplicationCommandDelete(d.client.State.User.ID, guildID, c.ID); err != nil { + if err := d.client.ApplicationCommandDelete(d.client.State.User.ID, d.guildID, c.ID); err != nil { log.Error().Err(err).Msgf("could not delete command %s", c.Name) } } } func (d *Discord) Nick(nick string) error { - guildID := d.config.Get("discord.guildid", "") - return d.client.GuildMemberNickname(guildID, "@me", nick) + return d.client.GuildMemberNickname(d.guildID, "@me", nick) } func (d *Discord) Topic(channelID string) (string, error) { @@ -454,3 +437,34 @@ func (d *Discord) SetTopic(channelID, topic string) error { _, err := d.client.ChannelEditComplex(channelID, ce) return err } + +type ThreadStart struct { + Name string `json:"name"` + AutoArchiveDuration int `json:"auto_archive_duration,omitempty"` + RateLimitPerUser int `json:"rate_limit_per_user,omitempty"` + AppliedTags []string `json:"applied_tags,omitempty"` + Message ForumMessageData `json:"message"` +} + +type ForumMessageData struct { + Content string `json:"content"` +} + +func (d *Discord) CreateRoom(name, message, parent string, duration int) (string, error) { + data := &ThreadStart{ + Name: name, + AutoArchiveDuration: duration, + Message: ForumMessageData{message}, + } + ch := &discordgo.Channel{} + endpoint := discordgo.EndpointChannelThreads(parent) + body, err := d.client.RequestWithBucketID("POST", endpoint, data, endpoint) + if err != nil { + return "", err + } + + if err = discordgo.Unmarshal(body, &ch); err != nil { + return "", err + } + return ch.ID, nil +} diff --git a/go.mod b/go.mod index 7b8fb45..fc2b35a 100644 --- a/go.mod +++ b/go.mod @@ -43,11 +43,13 @@ require ( github.com/antchfx/xpath v1.1.1 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/azr/backoff v0.0.0-20160115115103-53511d3c7330 // indirect + github.com/cenkalti/backoff/v4 v4.1.3 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect + github.com/gempir/go-twitch-irc/v3 v3.2.0 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect @@ -79,11 +81,12 @@ require ( github.com/temoto/robotstxt v1.1.1 // indirect github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 // indirect github.com/ttacon/libphonenumber v1.1.0 // indirect + github.com/vikpe/twitch-chatbot v0.2.0 // indirect golang.org/x/exp v0.0.0-20191014171548-69215a2ee97e // indirect golang.org/x/image v0.0.0-20190802002840-cff245a6509b // indirect - golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect + golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect - golang.org/x/sys v0.0.0-20220906165534-d0df966e6959 // indirect + golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 // indirect golang.org/x/text v0.3.7 // indirect gonum.org/v1/gonum v0.6.0 // indirect google.golang.org/appengine v1.6.5 // indirect diff --git a/go.sum b/go.sum index 0fee339..2b4b326 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89 github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598 h1:j2XRGH5Y5uWtBYXGwmrjKeM/kfu/jh7ZcnrGvyN5Ttk= github.com/cdipaolo/goml v0.0.0-20190412180403-e1f51f713598/go.mod h1:sduMkaHcXDIWurl/Bd/z0rNEUHw5tr6LUA9IO8E9o0o= +github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= +github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chrissexton/leftpad v0.0.0-20181207133115-1e93189d2fff h1:+TEqaP0eO1unI7XHHFeMDhsxhLDIb0x8KYuZbqbAmxA= @@ -54,6 +56,8 @@ github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkF github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE= github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ= +github.com/gempir/go-twitch-irc/v3 v3.2.0 h1:ENhsa7RgBE1GMmDqe0iMkvcSYfgw6ZsXilt+sAg32/U= +github.com/gempir/go-twitch-irc/v3 v3.2.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU= github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/httprate v0.7.0 h1:8W0dF7Xa2Duz2p8ncGaehIphrxQGNlOtoGY0+NRRfjQ= @@ -176,6 +180,8 @@ github.com/ttacon/libphonenumber v1.1.0/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkU github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/velour/velour v0.0.0-20160303155839-8e090e68d158 h1:p3rTUXxzuKsBOsHlkly7+rj9wagFBKeIsCDKkDII9sw= github.com/velour/velour v0.0.0-20160303155839-8e090e68d158/go.mod h1:Ojy3BTOiOTwpHpw7/HNi+TVTFppao191PQs+Qc3sqpE= +github.com/vikpe/twitch-chatbot v0.2.0 h1:iVzuha6xLq+mlcs0MeF3tnjtJNZfX2RV3Vxqv3Zjjhw= +github.com/vikpe/twitch-chatbot v0.2.0/go.mod h1:IUfuGEwGaDglqrpVls1vPoiBsb8yfZIeAdvGVzGxbP4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= @@ -203,6 +209,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -215,8 +223,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220906165534-d0df966e6959 h1:qSa+Hg9oBe6UJXrznE+yYvW51V9UbyIj/nj/KpDigo8= -golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 h1:ohgcoMbSofXygzo6AD2I1kz3BFmW1QArPYTtwEM3UXc= +golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index 240a9db..358e917 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( "flag" "github.com/velour/catbase/plugins/pagecomment" + "github.com/velour/catbase/plugins/talker" "github.com/velour/catbase/plugins/topic" "io" "math/rand" @@ -64,7 +65,6 @@ import ( "github.com/velour/catbase/plugins/rss" "github.com/velour/catbase/plugins/sisyphus" "github.com/velour/catbase/plugins/stock" - "github.com/velour/catbase/plugins/talker" "github.com/velour/catbase/plugins/tell" "github.com/velour/catbase/plugins/tldr" "github.com/velour/catbase/plugins/twitch" @@ -132,6 +132,7 @@ func main() { b.AddPlugin(admin.New(b)) b.AddPlugin(roles.New(b)) + b.AddPlugin(twitch.New(b)) b.AddPlugin(pagecomment.New(b)) b.AddPlugin(gpt3.New(b)) b.AddPlugin(secrets.New(b)) @@ -141,7 +142,6 @@ func main() { b.AddPlugin(last.New(b)) b.AddPlugin(first.New(b)) b.AddPlugin(leftpad.New(b)) - b.AddPlugin(talker.New(b)) b.AddPlugin(dice.New(b)) b.AddPlugin(picker.New(b)) b.AddPlugin(beers.New(b)) @@ -153,7 +153,6 @@ func main() { b.AddPlugin(babbler.New(b)) b.AddPlugin(rss.New(b)) b.AddPlugin(reaction.New(b)) - b.AddPlugin(twitch.New(b)) b.AddPlugin(inventory.New(b)) b.AddPlugin(rpgORdie.New(b)) b.AddPlugin(sisyphus.New(b)) @@ -177,6 +176,7 @@ func main() { b.AddPlugin(emojy.New(b)) b.AddPlugin(cowboy.New(b)) b.AddPlugin(topic.New(b)) + b.AddPlugin(talker.New(b)) // catches anything left, will always return true b.AddPlugin(fact.New(b)) diff --git a/plugins/twitch/bridge.go b/plugins/twitch/bridge.go new file mode 100644 index 0000000..ef49f03 --- /dev/null +++ b/plugins/twitch/bridge.go @@ -0,0 +1,142 @@ +package twitch + +import ( + "fmt" + "github.com/cenkalti/backoff/v4" + "github.com/rs/zerolog/log" + "github.com/velour/catbase/bot" + "github.com/velour/catbase/connectors/discord" + "strings" +) + +func (t *Twitch) mkBridge(r bot.Request) bool { + ircCh := "#" + r.Values["twitchChannel"] + if err := t.startConn(); err != nil { + t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("Could not connect to IRC: %s", err)) + } + t.irc.Join(ircCh) + t.bridgeMap[r.Msg.Channel] = ircCh + t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("This post is tracking %s", ircCh)) + return true +} + +func (t *Twitch) rmBridge(r bot.Request) bool { + ch, ok := t.bridgeMap[r.Msg.Channel] + if ok { + delete(t.bridgeMap, r.Msg.Channel) + t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("No longer tracking %s.", ch)) + return true + } + t.b.Send(r.Conn, bot.Message, r.Msg.Channel, "This is not a connected bridge channel.") + return true +} + +func (t *Twitch) bridgeMsg(r bot.Request) bool { + if ircCh := t.bridgeMap[r.Msg.Channel]; ircCh != "" { + if t.irc == nil { + if err := t.startConn(); err != nil { + t.b.Send(r.Conn, bot.Message, r.Msg.Channel, fmt.Sprintf("Could not connect to IRC: %s", err)) + } + t.irc.Join(ircCh) + } + replaceSet := t.c.Get("twitch.replaceset", "\"'-") + who := r.Msg.User.Name + for _, c := range replaceSet { + who = strings.ReplaceAll(who, string(c), "") + } + who = strings.Split(who, " ")[0][:9] + msg := fmt.Sprintf("%s: %s", who, r.Msg.Body) + t.irc.sendMessage(ircCh, msg) + } + return false +} + +func (t *Twitch) ircMsg(channel, who, body string) { + for thread, ircCh := range t.bridgeMap { + if ircCh == channel { + t.b.Send(t.b.DefaultConnector(), bot.Message, thread, fmt.Sprintf("%s: %s", who, body)) + } + } +} + +func (t *Twitch) startBridgeMsg(threadName, twitchChannel, msg string) error { + if !strings.HasPrefix(twitchChannel, "#") { + twitchChannel = "#" + twitchChannel + } + if err := t.startConn(); err != nil { + return err + } + + chID, err := t.mkForumPost(threadName, msg) + if err != nil { + return err + } + log.Debug().Msgf("Opened thread %s", chID) + + err = t.irc.Join(twitchChannel) + if err != nil { + return err + } + t.bridgeMap[chID] = twitchChannel + return nil +} + +func (t *Twitch) startBridge(threadName, twitchChannel string) error { + if !strings.HasPrefix(twitchChannel, "#") { + twitchChannel = "#" + twitchChannel + } + msg := fmt.Sprintf("This post is tracking %s", twitchChannel) + return t.startBridgeMsg(threadName, twitchChannel, msg) +} + +func (t *Twitch) startConn() error { + if t.irc == nil { + err := backoff.Retry(func() error { + err := t.connect() + if err != nil { + log.Error().Err(err).Msg("could not connect to IRC") + return err + } + return nil + }, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 5)) + if err != nil { + return err + } + } + return nil +} + +func (t *Twitch) connect() error { + t.ircLock.Lock() + defer t.ircLock.Unlock() + twitchServer := t.c.Get("twitch.ircserver", "irc.chat.twitch.tv:6697") + twitchNick := t.c.Get("twitch.nick", "") + twitchPass := t.c.Get("twitch.pass", "") + twitchIRC, err := t.ConnectIRC(twitchServer, twitchNick, twitchPass, t.ircMsg, func() { + t.ircLock.Lock() + defer t.ircLock.Unlock() + t.irc = nil + }) + if err != nil { + return err + } + t.irc = twitchIRC + return nil +} + +func (t *Twitch) mkForumPost(name, msg string) (string, error) { + forum := t.c.Get("twitch.forum", "") + if forum == "" { + return "", fmt.Errorf("no forum available") + } + switch c := t.b.DefaultConnector().(type) { + case *discord.Discord: + chID, err := c.CreateRoom(name, msg, forum, t.c.GetInt("twitch.threadduration", 60)) + if err != nil { + return "", err + } + return chID, nil + default: + return "", fmt.Errorf("non-Discord connectors not supported") + } +} diff --git a/plugins/twitch/irc.go b/plugins/twitch/irc.go new file mode 100644 index 0000000..3d6f093 --- /dev/null +++ b/plugins/twitch/irc.go @@ -0,0 +1,177 @@ +package twitch + +import ( + "github.com/rs/zerolog/log" + "github.com/velour/catbase/config" + "github.com/velour/velour/irc" + "io" + "time" +) + +var throttle <-chan time.Time + +type eventFunc func(channel, who, body string) + +type IRC struct { + t *Twitch + c *config.Config + client *irc.Client + event eventFunc + quit chan bool +} + +func (t *Twitch) ConnectIRC(server, user, pass string, handler eventFunc, disconnect func()) (*IRC, error) { + log.Debug().Msgf("Connecting to %s, %s:%s", server, user, pass) + i := &IRC{ + t: t, + c: t.c, + event: handler} + wait := make(chan bool) + go i.serve(server, user, pass, wait, disconnect) + <-wait + return i, nil +} + +func (i *IRC) Join(channel string) error { + i.client.Out <- irc.Msg{Cmd: irc.JOIN, Args: []string{channel}} + return nil +} + +func (i *IRC) Say(channel, body string) error { + if _, err := i.sendMessage(channel, body); err != nil { + return err + } + return nil +} + +func (i *IRC) serve(server, user, pass string, wait chan bool, disconnect func()) { + if i.event == nil { + log.Error().Msgf("Missing event handler") + wait <- true + return + } + + var err error + i.client, err = irc.DialSSL(server, user, user, pass, true) + if err != nil { + log.Error(). + Err(err). + Strs("args", []string{server, user, pass}). + Msgf("Could not connect") + wait <- true + return + } + + i.client.Out <- irc.Msg{Cmd: "CAP REQ", Args: []string{":twitch.tv/membership"}} + + i.quit = make(chan bool) + go i.handleConnection() + wait <- true + <-i.quit + disconnect() +} + +func (i *IRC) handleMsg(msg irc.Msg) { + switch msg.Cmd { + case irc.ERROR: + log.Info().Msgf("Received error: " + msg.Raw) + + case irc.PING: + i.client.Out <- irc.Msg{Cmd: irc.PONG} + + case irc.PONG: + // OK, ignore + + case irc.KICK: + fallthrough + + case irc.TOPIC: + fallthrough + + case irc.NOTICE: + fallthrough + + case irc.PRIVMSG: + if len(msg.Args) < 2 { + break + } + i.event(msg.Args[0], msg.Origin, msg.Args[1]) + + case irc.QUIT: + log.Debug(). + Interface("msg", msg). + Msgf("QUIT") + i.quit <- true + + default: + log.Debug(). + Interface("msg", msg). + Msgf("IRC EVENT") + } +} + +func (i *IRC) sendMessage(channel, message string, args ...any) (string, error) { + for len(message) > 0 { + m := irc.Msg{ + Cmd: "PRIVMSG", + Args: []string{channel, message}, + } + _, err := m.RawString() + if err != nil { + mtl := err.(irc.MsgTooLong) + m.Args[1] = message[:mtl.NTrunc] + message = message[mtl.NTrunc:] + } else { + message = "" + } + + if throttle == nil { + ratePerSec := i.c.GetInt("RatePerSec", 5) + throttle = time.Tick(time.Second / time.Duration(ratePerSec)) + } + + <-throttle + + i.client.Out <- m + } + return "NO_IRC_IDENTIFIERS", nil +} + +func (i *IRC) handleConnection() { + pingTime := time.Duration(i.c.GetInt("twitch.pingtime", 60)) * time.Second + t := time.NewTimer(pingTime) + + defer func() { + t.Stop() + close(i.client.Out) + for err := range i.client.Errors { + if err != io.EOF { + log.Error().Err(err) + } + } + }() + + for { + select { + case msg, ok := <-i.client.In: + if !ok { // disconnect + i.quit <- true + return + } + t.Stop() + t = time.NewTimer(pingTime) + i.handleMsg(msg) + + case <-t.C: + i.client.Out <- irc.Msg{Cmd: irc.PING, Args: []string{i.client.Server}} + t = time.NewTimer(pingTime) + + case err, ok := <-i.client.Errors: + if ok && err != io.EOF { + log.Error().Err(err) + i.quit <- true + return + } + } + } +} diff --git a/plugins/twitch/twitch.go b/plugins/twitch/twitch.go index ba77bb3..53a2954 100644 --- a/plugins/twitch/twitch.go +++ b/plugins/twitch/twitch.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" "strings" + "sync" "text/template" "time" @@ -26,11 +27,15 @@ const ( stoppedStreamingTplFallback = "{{.Name}} just stopped streaming" ) -type TwitchPlugin struct { - b bot.Bot - c *config.Config - twitchList map[string]*Twitcher - tbl bot.HandlerTable +type Twitch struct { + b bot.Bot + c *config.Config + twitchList map[string]*Twitcher + tbl bot.HandlerTable + ircConnected bool + irc *IRC + ircLock sync.Mutex + bridgeMap map[string]string } type Twitcher struct { @@ -62,11 +67,12 @@ type stream struct { } `json:"pagination"` } -func New(b bot.Bot) *TwitchPlugin { - p := &TwitchPlugin{ +func New(b bot.Bot) *Twitch { + p := &Twitch{ b: b, c: b.Config(), twitchList: map[string]*Twitcher{}, + bridgeMap: map[string]string{}, } for _, ch := range p.c.GetArray("Twitch.Channels", []string{}) { @@ -90,13 +96,13 @@ func New(b bot.Bot) *TwitchPlugin { return p } -func (p *TwitchPlugin) registerWeb() { +func (p *Twitch) registerWeb() { r := chi.NewRouter() r.HandleFunc("/{user}", p.serveStreaming) p.b.RegisterWeb(r, "/isstreaming") } -func (p *TwitchPlugin) serveStreaming(w http.ResponseWriter, r *http.Request) { +func (p *Twitch) serveStreaming(w http.ResponseWriter, r *http.Request) { userName := strings.ToLower(chi.URLParam(r, "user")) if userName == "" { fmt.Fprint(w, "User not found.") @@ -126,7 +132,7 @@ func (p *TwitchPlugin) serveStreaming(w http.ResponseWriter, r *http.Request) { } } -func (p *TwitchPlugin) register() { +func (p *Twitch) register() { p.tbl = bot.HandlerTable{ { Kind: bot.Message, IsCmd: true, @@ -146,12 +152,29 @@ func (p *TwitchPlugin) register() { HelpText: "Reset the twitch templates", Handler: p.resetTwitch, }, + { + Kind: bot.Message, IsCmd: true, + Regex: regexp.MustCompile(`(?i)^track (?P.+)$`), + HelpText: "Bridge to this channel", + Handler: p.mkBridge, + }, + { + Kind: bot.Message, IsCmd: true, + Regex: regexp.MustCompile(`(?i)^untrack$`), + HelpText: "Disconnect a bridge (only in bridged channel)", + Handler: p.rmBridge, + }, + { + Kind: bot.Message, IsCmd: false, + Regex: regexp.MustCompile(`.*`), + Handler: p.bridgeMsg, + }, } p.b.Register(p, bot.Help, p.help) p.b.RegisterTable(p, p.tbl) } -func (p *TwitchPlugin) twitchStatus(r bot.Request) bool { +func (p *Twitch) twitchStatus(r bot.Request) bool { channel := r.Msg.Channel if users := p.c.GetArray("Twitch."+channel+".Users", []string{}); len(users) > 0 { for _, twitcherName := range users { @@ -168,7 +191,7 @@ func (p *TwitchPlugin) twitchStatus(r bot.Request) bool { return true } -func (p *TwitchPlugin) twitchUserStatus(r bot.Request) bool { +func (p *Twitch) twitchUserStatus(r bot.Request) bool { who := strings.ToLower(r.Values["who"]) if t, ok := p.twitchList[who]; ok { err := p.checkTwitch(r.Conn, r.Msg.Channel, t, true) @@ -182,7 +205,7 @@ func (p *TwitchPlugin) twitchUserStatus(r bot.Request) bool { return true } -func (p *TwitchPlugin) resetTwitch(r bot.Request) bool { +func (p *Twitch) resetTwitch(r bot.Request) bool { p.c.Set("twitch.istpl", isStreamingTplFallback) p.c.Set("twitch.nottpl", notStreamingTplFallback) p.c.Set("twitch.stoppedtpl", stoppedStreamingTplFallback) @@ -190,7 +213,7 @@ func (p *TwitchPlugin) resetTwitch(r bot.Request) bool { return true } -func (p *TwitchPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool { +func (p *Twitch) help(c bot.Connector, kind bot.Kind, message msg.Message, args ...any) bool { msg := "You can set the templates for streams with\n" msg += fmt.Sprintf("twitch.istpl (default: %s)\n", isStreamingTplFallback) msg += fmt.Sprintf("twitch.nottpl (default: %s)\n", notStreamingTplFallback) @@ -201,7 +224,7 @@ func (p *TwitchPlugin) help(c bot.Connector, kind bot.Kind, message msg.Message, return true } -func (p *TwitchPlugin) twitchAuthLoop(c bot.Connector) { +func (p *Twitch) twitchAuthLoop(c bot.Connector) { frequency := p.c.GetInt("Twitch.AuthFreq", 60*60) cid := p.c.Get("twitch.clientid", "") secret := p.c.Get("twitch.secret", "") @@ -226,7 +249,7 @@ func (p *TwitchPlugin) twitchAuthLoop(c bot.Connector) { } } -func (p *TwitchPlugin) twitchChannelLoop(c bot.Connector, channel string) { +func (p *Twitch) twitchChannelLoop(c bot.Connector, channel string) { frequency := p.c.GetInt("Twitch.Freq", 60) if p.c.Get("twitch.clientid", "") == "" || p.c.Get("twitch.secret", "") == "" { log.Info().Msgf("Disabling twitch autochecking.") @@ -276,7 +299,7 @@ errCase: return []byte{}, resp.StatusCode, false } -func (p *TwitchPlugin) checkTwitch(c bot.Connector, channel string, twitcher *Twitcher, alwaysPrintStatus bool) error { +func (p *Twitch) checkTwitch(c bot.Connector, channel string, twitcher *Twitcher, alwaysPrintStatus bool) error { baseURL, err := url.Parse("https://api.twitch.tv/helix/streams") if err != nil { err := fmt.Errorf("error parsing twitch stream URL") @@ -333,6 +356,7 @@ func (p *TwitchPlugin) checkTwitch(c bot.Connector, channel string, twitcher *Tw twitcher.URL(), } + // This is a logic mess and needs to be rejiggered. I am not doing that today. (2022-09-18) if alwaysPrintStatus { if gameID == "" { t, err := template.New("notStreaming").Parse(notStreamingTpl) @@ -363,6 +387,13 @@ func (p *TwitchPlugin) checkTwitch(c bot.Connector, channel string, twitcher *Tw } t.Execute(&buf, info) p.b.Send(c, bot.Message, channel, buf.String()) + log.Debug().Msgf("Disconnecting bridge: %s -> %+v", twitcher.name, p.bridgeMap) + for threadID, ircCh := range p.bridgeMap { + if strings.HasSuffix(ircCh, twitcher.name) { + delete(p.bridgeMap, threadID) + p.b.Send(c, bot.Message, threadID, "Stopped tracking #"+twitcher.name) + } + } } twitcher.gameID = "" } else { @@ -375,13 +406,24 @@ func (p *TwitchPlugin) checkTwitch(c bot.Connector, channel string, twitcher *Tw } t.Execute(&buf, info) p.b.Send(c, bot.Message, channel, buf.String()) + // start the bridge here + msg := fmt.Sprintf("This post is tracking #%s\n<%s>", twitcher.name, twitcher.URL()) + err = p.startBridgeMsg( + fmt.Sprintf("%s-%s-%s", info.Name, info.Game, time.Now().Format("2006-01-02-15:04")), + twitcher.name, + msg, + ) + if err != nil { + log.Error().Err(err).Msgf("unable to start bridge") + return err + } } twitcher.gameID = gameID } return nil } -func (p *TwitchPlugin) validateCredentials() error { +func (p *Twitch) validateCredentials() error { cid := p.c.Get("twitch.clientid", "") token := p.c.Get("twitch.token", "") if token == "" { @@ -395,7 +437,7 @@ func (p *TwitchPlugin) validateCredentials() error { return nil } -func (p *TwitchPlugin) reAuthenticate() error { +func (p *Twitch) reAuthenticate() error { cid := p.c.Get("twitch.clientid", "") secret := p.c.Get("twitch.secret", "") if cid == "" || secret == "" { diff --git a/plugins/twitch/twitch_test.go b/plugins/twitch/twitch_test.go index b112acc..646b76b 100644 --- a/plugins/twitch/twitch_test.go +++ b/plugins/twitch/twitch_test.go @@ -37,7 +37,7 @@ func makeMessage(payload string) (bot.Connector, bot.Kind, msg.Message) { } } -func makeTwitchPlugin(t *testing.T) (*TwitchPlugin, *bot.MockBot) { +func makeTwitchPlugin(t *testing.T) (*Twitch, *bot.MockBot) { mb := bot.NewMockBot() c := New(mb) mb.Config().Set("twitch.clientid", "fake")