From 2cf336faadd08e63d4044150c7529da984ff7972 Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Wed, 14 Feb 2024 18:55:25 -0600 Subject: [PATCH] message: add service-agnostic type for messages we send --- config.go | 13 ------------- message/irc.go | 8 ++++---- message/message.go | 26 ++++++++++++++++++++++++++ privmsg.go | 39 ++++++++++++++++++++++++--------------- robot.go | 15 +++++++++++++++ 5 files changed, 69 insertions(+), 32 deletions(-) diff --git a/config.go b/config.go index 93d810e..ef61802 100644 --- a/config.go +++ b/config.go @@ -170,19 +170,6 @@ func fseconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } -// client is the settings for OAuth2 and related elements. -type client struct { - // me is the bot's username. The interpretation of this is domain-specific. - me string - // owner is the user ID of the owner. The interpretation of this is - // domain-specific. - owner string - // rate is the global rate limiter for this client. - rate *rate.Limiter - // token is the OAuth2 token. - token *auth.Token -} - // loadClient loads client configuration from unmarshaled TOML. func loadClient(ctx context.Context, t ClientCfg, key [auth.KeySize]byte, scopes ...string) (*client, error) { secret, err := os.ReadFile(t.SecretFile) diff --git a/message/irc.go b/message/irc.go index 35ab507..b68999c 100644 --- a/message/irc.go +++ b/message/irc.go @@ -53,10 +53,10 @@ func elevated(m *tmi.Message) bool { // ToTMI creates a message to send to TMI. If reply is not empty, then the // result is a reply to the message with that ID. -func ToTMI(reply, to, text string) *tmi.Message { - r := tmi.Privmsg(to, text) - if reply != "" { - r.Tags = "reply-parent-msg-id=" + reply +func ToTMI(msg Sent) *tmi.Message { + r := tmi.Privmsg(msg.To, msg.Text) + if msg.Reply != "" { + r.Tags = "reply-parent-msg-id=" + msg.Reply } return r } diff --git a/message/message.go b/message/message.go index 6c421a3..817122b 100644 --- a/message/message.go +++ b/message/message.go @@ -1,6 +1,8 @@ package message import ( + "fmt" + "strings" "time" ) @@ -32,3 +34,27 @@ type Received struct { func (m *Received) Time() time.Time { return time.UnixMilli(m.Timestamp) } + +// Sent is a message to be sent to a service. +type Sent struct { + // Reply is a message to reply to. If empty, the message is not interpreted + // as a reply. + Reply string + // To is the channel to whom the message is sent. + To string + // Text is the message text. + Text string +} + +// formatString is a type to prevent misuse of format strings passed to [Format]. +type formatString string + +// Format constructs a message to send from a format string literal and +// formatting arguments. +func Format(reply, to string, f formatString, args ...any) Sent { + return Sent{ + Reply: reply, + To: to, + Text: strings.TrimSpace(fmt.Sprintf(string(f), args...)), + } +} diff --git a/privmsg.go b/privmsg.go index 2d97555..0eac5bd 100644 --- a/privmsg.go +++ b/privmsg.go @@ -33,6 +33,9 @@ func (robo *Robot) tmiMessage(ctx context.Context, send chan<- *tmi.Message, msg if ch.Ignore[from] { return } + if ch.Block.MatchString(m.Text) { + return + } if cmd, ok := parseCommand(robo.tmi.me, m.Text); ok { if from == robo.tmi.owner { // TODO(zeph): check owner and moderator commands @@ -45,15 +48,21 @@ func (robo *Robot) tmiMessage(ctx context.Context, send chan<- *tmi.Message, msg return } robo.learn(ctx, ch, userhash.New(robo.secrets.userhash), m) + // TODO(zeph): this should be asking for a reservation if !ch.Rate.Allow() { return } - if err := ch.Memery.Check(m.Time(), from, m.Text); err == nil { - // NOTE(zeph): inverted error check - robo.sendTMI(ctx, send, ch, m.Text) + switch err := ch.Memery.Check(m.Time(), from, m.Text); err { + case channel.ErrNotCopypasta: // do nothing + case nil: + // Meme detected. Copypasta. + text := m.Text + // TODO(zeph): effects; once we apply them, we also need to check block + msg := message.Format("", ch.Name, "%s", text) + robo.sendTMI(ctx, send, msg) return - } else if err != channel.ErrNotCopypasta { - log.Println("copypasta error:", err) + default: + log.Println("copypasta check error:", err) } if rand.Float64() < ch.Responses { s, err := brain.Speak(ctx, robo.brain, ch.Send, "") @@ -64,7 +73,13 @@ func (robo *Robot) tmiMessage(ctx context.Context, send chan<- *tmi.Message, msg e := ch.Emotes.Pick(rand.Uint32()) s = strings.TrimSpace(s + " " + e) // TODO(zeph): effect - robo.sendTMI(ctx, send, ch, s) + if ch.Block.MatchString(s) { + // Don't send messages we wouldn't learn from. + // TODO(zeph): log? + return + } + msg := message.Format("", ch.Name, "%s", s) + robo.sendTMI(ctx, send, msg) } } robo.enqueue(ctx, work) @@ -140,18 +155,12 @@ func (robo *Robot) learn(ctx context.Context, ch *channel.Channel, hasher userha } // sendTMI sends a message to TMI after waiting for the global rate limit. -func (robo *Robot) sendTMI(ctx context.Context, send chan<- *tmi.Message, ch *channel.Channel, s string) { - if ch.Block.MatchString(s) { - return - } +// The caller should verify that it is safe to send the message. +func (robo *Robot) sendTMI(ctx context.Context, send chan<- *tmi.Message, msg message.Sent) { if err := robo.tmi.rate.Wait(ctx); err != nil { return } - resp := &tmi.Message{ - Command: "PRIVMSG", - Params: []string{ch.Name}, - Trailing: s, - } + resp := message.ToTMI(msg) select { case <-ctx.Done(): return diff --git a/robot.go b/robot.go index 3d0d55b..c9089e2 100644 --- a/robot.go +++ b/robot.go @@ -10,7 +10,9 @@ import ( "gitlab.com/zephyrtronium/tmi" "golang.org/x/sync/errgroup" + "golang.org/x/time/rate" + "github.com/zephyrtronium/robot/auth" "github.com/zephyrtronium/robot/brain/sqlbrain" "github.com/zephyrtronium/robot/channel" "github.com/zephyrtronium/robot/privacy" @@ -37,6 +39,19 @@ type Robot struct { tmi *client } +// client is the settings for OAuth2 and related elements. +type client struct { + // me is the bot's username. The interpretation of this is domain-specific. + me string + // owner is the user ID of the owner. The interpretation of this is + // domain-specific. + owner string + // rate is the global rate limiter for this client. + rate *rate.Limiter + // token is the OAuth2 token. + token *auth.Token +} + // New creates a new robot instance. Use SetOwner, SetSecrets, &c. as needed // to initialize the robot. func New(poolSize int) *Robot {