Common code for implementing Twitch bots.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

285 lines
6.5 KiB

// Package bot implements a DRY-bot framework, implementing a common base
// for writing various types of Twitch bots.
package bot
import (
"context"
"fmt"
"runtime/debug"
"strconv"
"strings"
"sync"
"raccatta.cc/bot/commander"
"raccatta.cc/bot/output"
"raccatta.cc/bot/twitch"
"raccatta.cc/bot/unstable/chanmgr"
"raccatta.cc/bot/unstable/conpool"
"raccatta.cc/bot/unstable/ipc"
"raccatta.cc/bot/unstable/log"
"raccatta.cc/bot/unstable/webservice"
"raccatta.cc/tmi/ircon"
)
type Bot struct {
mu sync.Mutex
*tmi
RawInputLogger RawInputLogger
RawMessageHook RawMessageHook
WS *webservice.Server
ConfigPropagation func(id, login string, conf map[string]string) (string, error)
PartFeedback interface {
Parted(login string) error
}
ChannelManager chanmgr.Executor
Commander *commander.Commander
Output *output.Output
cancel context.CancelFunc
login string
byname map[string]*user
byid map[string]*user
}
type Target struct {
Target string // Twitch username
TargetID string // Twitch user ID
Conf map[string]string
}
type RawInputLogger interface {
LogRawInput(context string, message string)
}
type RawMessageHook interface {
RawMessage(msg *ircon.Message)
}
type user struct {
id string
login string
}
// set up config-wrapper for output (pbapi + disabled guard)
// use config-wrapped output for MessageAPI
// adminset -> commander
// api -> config
// |
// v
// adminset -> targetMgr -> tmi|joinmgr
//
// tmi -> {logHack, inputHack} -> commander
//
// tmi -> joinmgr (target tracking)
// joinmgr -> tmi (targets)
//
// tmi -> partFeedback -> targetMgr|config
//
// pubsub
// |
// v
// tmi -> commander -> output
// ^
// |
// config|targetMgr
func New(login, oauth string, verified bool) *Bot {
msg30 := 19
join15 := 50
if verified {
msg30 = 1950
join15 = 1950
}
b := &Bot{
login: login,
Commander: commander.New(login),
byname: map[string]*user{},
byid: map[string]*user{},
tmi: newTMI(join15, login, oauth),
}
b.Output = output.New(b.tmi, msg30)
b.Commander.OutputFunc = b.Output.Output
b.WS = botService(b)
b.tmi.messenger = b
return b
}
// Run starts the bot threads and returns a context that will be closed when
// the bot should stop running.
func (b *Bot) Run(targets []Target) context.Context {
ctx, cancel := context.WithCancel(context.Background())
b.cancel = cancel
go b.tmi.Run(ctx)
go b.Output.Exec(ctx)
go b.Commander.Exec(ctx)
for _, t := range targets {
b.AddTarget(t.TargetID, t.Target, t.Conf)
}
go func() {
if err := b.WS.Run("[::]:2047"); err != nil {
log.Error().Err(err).Msg("Webservice error")
}
}()
return ctx
}
func (b *Bot) Message(con *conpool.Con, msg *ircon.Message) {
if l := b.RawInputLogger; l != nil {
a0 := msg.Arg(0) // Not quite correct
if strings.HasPrefix(a0, "#") {
a0 = strings.TrimLeft(a0, "#")
} else {
a0 = "unsorted"
}
l.LogRawInput(a0, msg.Raw())
}
defer func() {
if err := recover(); err != nil {
if v, ok := err.(error); ok {
log.Error().Err(v).Msg("Recovered from crash")
} else {
log.Error().Str("err", fmt.Sprint(err)).Msg("Recovered from crash")
}
debug.PrintStack()
}
}()
channel := msg.Arg(0)
login := strings.TrimLeft(channel, "#")
if !ignoreStats[msg.Command] {
twitchMessagesCounter(login, msg.Command).Inc()
}
if h := b.RawMessageHook; h != nil {
defer h.RawMessage(msg)
}
if msg.Command == "RECONNECT" {
// TODO: disconnect on RECONNECT
log.Warn().Str("raw-msg", msg.Raw()).Msg("RECONNECT")
return
}
if msg.Command == "GLOBALUSERSTATE" {
//log.Info().Str("tmi-user-id", uid).Msg("Discovered user-id")
log.Warn().Str("raw-msg", msg.Raw()).Msg("GLOBALUSERSTATE")
return
}
if msg.Command == "USERNOTICE" {
// E.g. new emote sets
//if msg.Tags["msg-id"] == "rewardgift" {
// b.tracker.Set("twitch/rewardgift/domain", msg.Tags["msg-param-domain"])
//}
return
}
if msg.Command == "WHISPER" {
return
}
switch msg.Command {
case "USERSTATE":
//<feedback for when a message was last received in a channel>
//<sync badges>
b.Commander.SyncHighRL(login, twitch.IsFastSender(msg.Tags["badges"]))
case "NOTICE":
if t := msg.Tags["msg-id"]; t != "host_on" && t != "host_target_went_offline" && t != "host_off" {
log.Warn().Str("login", login).Str("raw-msg", msg.Raw()).Msg("NOTICE")
}
//switch msg.Tags["msg-id"] {
//case "msg_channel_suspended":
//<joinmgr feedback>
//}
return
case "PART":
log.Warn().Str("login", login).Str("raw-msg", msg.Raw()).Msg("PART")
if b.PartFeedback != nil {
if err := b.PartFeedback.Parted(login); err != nil {
log.Error().Str("login", login).Err(err).Msg("Error while processing PART feedback")
}
}
case "ROOMSTATE":
if sm := msg.Tags["slow"]; sm != "" {
log.Info().Str("login", login).Str("raw-msg", msg.Raw()).Msg("ROOMSTATE")
// XXX: adjust channel rate
slow, _ := strconv.Atoi(sm)
// XXX: max delay is 1800
b.Commander.SyncSlowMode(login, slow)
} else {
log.Debug().Str("login", login).Str("raw-msg", msg.Raw()).Msg("ROOMSTATE")
}
case "CLEARCHAT":
// <self-timeout detect>
if msg.Trailer(1) == b.login {
log.Info().Str("login", login).Str("raw-msg", msg.Raw()).Msg("CLEARCHAT")
}
case "PRIVMSG":
b.Commander.Message(msg)
}
}
func (b *Bot) ExternalSayID(id string, frags []string, cmd bool) bool {
login := b.LoginFromID(id)
if login == "" {
log.Warn().Str("id", id).
Strs("msg-frags", frags).
Msg("Cannot send message; no id => login mapping exists")
return false
}
return b.ExternalSay(login, frags, cmd)
}
func (b *Bot) ExternalSay(login string, frags []string, cmd bool) bool {
rfrags := make([]ipc.Fragment, len(frags))
for i, f := range frags {
rfrags[i] = ipc.Fragment{X: true, M: f}
}
// TODO: should only send to existing channels; return feedback whether
// channel exists or not.
disabled, ok := b.Commander.Disabled(login)
if disabled {
// This path is used by the API, so disabling here drops those messages
log.Warn().Str("login", login).
Interface("msg-frags", rfrags).
Msg("Refusing to send message to disabled channel")
return ok
}
b.Output.Output(b.Commander.WithBancheck(output.Message{
Login: login,
Frags: rfrags,
Command: cmd,
}), -1, "")
return ok
}
// Say outputs a message to the given channel.
func (b *Bot) Say(login, msg string) {
b.Output.Say(login, msg)
}
func (b *Bot) SendRaw(raw string) {
b.Output.SendRaw(raw)
}