From 8ed7c0a52a2f5717674d19697ed1b38976a1321c Mon Sep 17 00:00:00 2001 From: Branden J Brown Date: Wed, 27 Nov 2024 10:34:21 -0500 Subject: [PATCH] robot, command: add quiet time --- README.md | 1 + channel/channel.go | 8 ++++++ command/marriage.go | 12 ++++++++ command/moderate.go | 69 +++++++++++++++++++++++++++++++++++++++++++++ command/talk.go | 8 ++++++ privmsg.go | 12 ++++++++ 6 files changed, 110 insertions(+) diff --git a/README.md b/README.md index e69e9e7..cc6f3e5 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ These are *not* recognized as commands: - `echo bocchi` causes Robot to say `bocchi`, or whatever other message you give. - `talk about ranked competitive marriage` gives a short description of Robot's marriage system. - `forget bocchi` causes Robot to forget everything she's learned from messages containing `bocchi` in the last fifteen minutes. As a special case, `forget everything` tells her to forget all messages in the last fifteen minutes. +- `be quiet for 8 hours` has Robot stop learning and speaking for eight hours; other durations like `an hour`, `1h30m`, `until tomorrow` work as well. Some commands relating to moderation and privacy will still cause her to talk. There is a twelve hour limit on quiet time. ## Effects diff --git a/channel/channel.go b/channel/channel.go index 83425f6..fa89633 100644 --- a/channel/channel.go +++ b/channel/channel.go @@ -5,6 +5,7 @@ import ( "regexp" "sync" "sync/atomic" + "time" "gitlab.com/zephyrtronium/pick" "golang.org/x/time/rate" @@ -44,8 +45,15 @@ type Channel struct { Emotes *pick.Dist[string] // Effects is the distribution of effects. Effects *pick.Dist[string] + // Silent is the earliest time that speaking and learning is allowed in the + // channel as nanoseconds from the Unix epoch. + Silent atomic.Int64 // Extra is extra channel data that may be added by commands. Extra sync.Map // map[any]any; key is a type // Enabled indicates whether a channel is allowed to learn messages. Enabled atomic.Bool } + +func (ch *Channel) SilentTime() time.Time { + return time.Unix(0, ch.Silent.Load()) +} diff --git a/command/marriage.go b/command/marriage.go index 14bbc09..45a1a47 100644 --- a/command/marriage.go +++ b/command/marriage.go @@ -77,6 +77,10 @@ var affections = pick.New([]pick.Case[string]{ // Affection describes the caller's affection MMR. // No arguments. func Affection(ctx context.Context, robo *Robot, call *Invocation) { + if call.Message.Time().Before(call.Channel.SilentTime()) { + robo.Log.InfoContext(ctx, "silent", slog.Time("until", call.Channel.SilentTime())) + return + } x, c, f, l, n := score(robo.Log, &call.Channel.History, call.Message.Sender.ID) // Anything we do will require an emote. e := call.Channel.Emotes.Pick(rand.Uint32()) @@ -111,6 +115,10 @@ type partner struct { // Marry proposes to the robo. // - partnership: Type of partnership requested, e.g. "wife", "waifu", "daddy". Optional. func Marry(ctx context.Context, robo *Robot, call *Invocation) { + if call.Message.Time().Before(call.Channel.SilentTime()) { + robo.Log.InfoContext(ctx, "silent", slog.Time("until", call.Channel.SilentTime())) + return + } x, _, _, _, _ := score(robo.Log, &call.Channel.History, call.Message.Sender.ID) e := call.Channel.Emotes.Pick(rand.Uint32()) broadcaster := strings.EqualFold(call.Message.Sender.Name, strings.TrimPrefix(call.Channel.Name, "#")) && x == 0 @@ -168,6 +176,10 @@ func Marry(ctx context.Context, robo *Robot, call *Invocation) { // DescribeMarriage gives some exposition about the marriage system. // No args. func DescribeMarriage(ctx context.Context, robo *Robot, call *Invocation) { + if t := call.Channel.SilentTime(); call.Message.Time().Before(t) { + call.Channel.Message(ctx, message.Format("", "I'm being quiet for the next %v, so the marriage system is disabled until then.", t.Sub(call.Message.Time())).AsReply(call.Message.ID)) + return + } const s = `I am looking for a long series of short-term relationships and am holding a ranked competitive how-much-I-like-you tournament to decide my suitors! Politely ask me to marry you (or become your partner) and I'll evaluate your score. I like copypasta, memes, and long walks in the chat.` call.Channel.Message(ctx, message.Sent{Text: s}) } diff --git a/command/moderate.go b/command/moderate.go index bf7054b..2fb8cc0 100644 --- a/command/moderate.go +++ b/command/moderate.go @@ -3,7 +3,10 @@ package command import ( "context" "log/slog" + "regexp" + "strconv" "strings" + "time" "github.com/zephyrtronium/robot/message" ) @@ -40,3 +43,69 @@ func Forget(ctx context.Context, robo *Robot, call *Invocation) { call.Channel.Message(ctx, message.Format("", "Forgot %d messages.", n).AsReply(call.Message.ID)) } } + +// Quiet makes the bot temporarily stop learning and speaking in the channel. +// - dur: Duration to stop learning and speaking. Optional. +// - until: Marker to stop "until tomrrow" if not empty. Optional. +// +// NOTE(zeph): Quiet waits for a timer which can be up to twelve hours. +func Quiet(ctx context.Context, robo *Robot, call *Invocation) { + var dur time.Duration + switch { + case call.Args["dur"] == "" && call.Args["until"] == "": + dur = 2 * time.Hour + case call.Args["until"] != "": + // The only "until" option right now is "tomorrow". + dur = 12 * time.Hour + default: + if m := quietA.FindStringSubmatch(call.Args["dur"]); m != nil { + switch m[1][0] { + case 'h', 'H': + dur = time.Hour + default: + dur = time.Minute + } + break + } + if m := quietN.FindStringSubmatch(call.Args["dur"]); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil { + // Should be impossible. + call.Channel.Message(ctx, message.Format("", `sorry? (%v)`, err).AsReply(call.Message.ID)) + return + } + switch m[2][0] { + case 'h', 'H': + dur = time.Hour * time.Duration(n) + default: + dur = time.Minute * time.Duration(n) + } + break + } + var err error + dur, err = time.ParseDuration(call.Args["dur"]) + if err != nil { + call.Channel.Message(ctx, message.Format("", `sorry? (%v)`, err).AsReply(call.Message.ID)) + return + } + } + if dur > 12*time.Hour { + dur = 12 * time.Hour + } + call.Channel.Silent.Store(call.Message.Time().Add(dur).UnixNano()) + robo.Log.InfoContext(ctx, "silent", slog.Duration("duration", dur), slog.Time("until", call.Channel.SilentTime())) + call.Channel.Message(ctx, message.Format("", `I won't talk or learn for %v. Some commands relating to moderation and privacy will still make me talk. I'll mention when quiet time is up.`, dur).AsReply(call.Message.ID)) + t := time.NewTimer(dur) + defer t.Stop() + select { + case <-ctx.Done(): + return + case <-t.C: + call.Channel.Message(ctx, message.Format("", `@%s My quiet time has ended.`, call.Message.Sender.Name)) + } +} + +var ( + quietA = regexp.MustCompile(`(?i)^an?\s+(ho?u?r|mi?n)`) + quietN = regexp.MustCompile(`(?i)^(\d+)\s+(ho?u?r|mi?n)`) +) diff --git a/command/talk.go b/command/talk.go index cde02e0..0d07158 100644 --- a/command/talk.go +++ b/command/talk.go @@ -13,6 +13,10 @@ import ( ) func speakCmd(ctx context.Context, robo *Robot, call *Invocation, effect string) string { + if call.Message.Time().Before(call.Channel.SilentTime()) { + robo.Log.InfoContext(ctx, "silent", slog.Time("until", call.Channel.SilentTime())) + return "" + } // Don't continue prompts that look like they start with TMI commands // (even though those don't do anything anymore). if ngPrompt.MatchString(call.Args["prompt"]) { @@ -108,6 +112,10 @@ func AAAAA(ctx context.Context, robo *Robot, call *Invocation) { // Rawr says rawr. func Rawr(ctx context.Context, robo *Robot, call *Invocation) { + if call.Message.Time().Before(call.Channel.SilentTime()) { + robo.Log.InfoContext(ctx, "silent", slog.Time("until", call.Channel.SilentTime())) + return + } e := call.Channel.Emotes.Pick(rand.Uint32()) if e == "" { e = ":3" diff --git a/privmsg.go b/privmsg.go index 6622fb3..e5689a2 100644 --- a/privmsg.go +++ b/privmsg.go @@ -46,6 +46,13 @@ func (robo *Robot) tmiMessage(ctx context.Context, send chan<- *tmi.Message, msg return } ch.History.Add(m.Time(), m) + // Check for the channel being silent. This prevents learning, copypasta, + // and random speaking (among other things), which happens to be all the + // rest of this function. + if s := ch.SilentTime(); msg.Time().Before(s) { + log.DebugContext(ctx, "channel is silent", slog.Time("until", s)) + return + } // If the message is a reply to e.g. Bocchi, TMI adds @Bocchi to the // start of the message text. // That's helpful for commands, which we've already processed, but @@ -364,6 +371,11 @@ var twitchMod = []twitchCommand{ fn: command.Forget, name: "forget", }, + { + parse: regexp.MustCompile(`(?i)^(?:be\s+quiet|shut\s*up|stfu)(?:\s+for\s+(?P(?:\d+[hms]){1,3}|an\s+h(?:ou)?r|\d+\s+h(?:ou)?rs?|a\s+min(?:ute)?|\d+\s+min(?:ute)?s?)|\s+until\s+(?Ptomorrow))?$`), + fn: command.Quiet, + name: "quiet", + }, } var twitchAny = []twitchCommand{