From 9c8ff88f6eeab317e86a8b17884ae1a966c4b6e3 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sun, 13 Aug 2023 20:45:53 -0700 Subject: [PATCH] Update chatbot program * New object macros: dm, takeback, report * Bugfixes --- .gitignore | 1 + Makefile | 3 +- client/brain/public_keywords.rive | 2 +- client/client.go | 15 ++++--- client/handlers.go | 51 ++++++++++++++++++++-- client/rivescript_macros.go | 70 ++++++++++++++++++++++++++++++- client/utils.go | 21 ++++++++++ cmd/BareBot/commands/run.go | 2 +- docs/Chatbot.md | 63 ++++++++++++++++++++++++++++ 9 files changed, 213 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 3f38dc2..576cf17 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ settings.toml +chatbot/ diff --git a/Makefile b/Makefile index 8014bc0..a2bfdca 100644 --- a/Makefile +++ b/Makefile @@ -4,4 +4,5 @@ run: .PHONY: build build: - go build -o BareRTC cmd/BareRTC/main.go \ No newline at end of file + go build -o BareRTC cmd/BareRTC/main.go + go build -o BareBot cmd/BareBot/main.go diff --git a/client/brain/public_keywords.rive b/client/brain/public_keywords.rive index a5efc9b..96846c6 100644 --- a/client/brain/public_keywords.rive +++ b/client/brain/public_keywords.rive @@ -9,7 +9,7 @@ > topic PublicChannel // Users saying hello = react with a wave emoji. - + [*] (hello|hi|hey|howdy|hola|hai|yo) [*] + + [*] (hello|hi|hey|howdy|hola|hai|yo|greetings) [*] - react 👋 ^ diff --git a/client/client.go b/client/client.go index 1930c5e..52b4b0b 100644 --- a/client/client.go +++ b/client/client.go @@ -73,10 +73,16 @@ func (c *Client) Run() error { c.jwt = token } + // Get the WebSocket URL. + wss, err := WebSocketURL(c.url) + if err != nil { + return fmt.Errorf("couldn't get WebSocket URL from %s: %s", c.url, err) + } + ctx := context.Background() c.ctx = ctx - conn, _, err := websocket.Dial(ctx, c.url, nil) + conn, _, err := websocket.Dial(ctx, wss, nil) if err != nil { return fmt.Errorf("dialing websocket URL (%s): %s", c.url, err) } @@ -94,13 +100,6 @@ func (c *Client) Run() error { return fmt.Errorf("sending login message: %s", err) } - // Testing! - c.Send(messages.Message{ - Action: messages.ActionMessage, - Channel: "lobby", - Message: "Hello, world! BareBot client connected!", - }) - // Enter the Read Loop for { var msg messages.Message diff --git a/client/handlers.go b/client/handlers.go index a3d19f0..c9023f4 100644 --- a/client/handlers.go +++ b/client/handlers.go @@ -12,6 +12,9 @@ import ( "github.com/aichaos/rivescript-go" ) +// Number of recent chat messages to hold onto. +const ScrollbackBuffer = 500 + // BotHandlers holds onto a set of handler functions for the BareBot. type BotHandlers struct { rs *rivescript.RiveScript @@ -22,8 +25,15 @@ type BotHandlers struct { whoMu sync.RWMutex // Auto-greeter cooldowns - autoGreet map[string]time.Time - autoGreetMu sync.RWMutex + autoGreet map[string]time.Time + autoGreetCooldown time.Time // global cooldown between auto-greets + autoGreetMu sync.RWMutex + + // MessageID history. Keep a buffer of recent messages sent in + // case the robot needs to report one (which should generally + // happen immediately, if it does). + messageBuf []messages.Message + messageBufMu sync.RWMutex } // SetupChatbot configures a sensible set of default handlers for the BareBot application. @@ -37,7 +47,8 @@ func (c *Client) SetupChatbot() error { rs: rivescript.New(&rivescript.Config{ UTF8: true, }), - autoGreet: map[string]time.Time{}, + autoGreet: map[string]time.Time{}, + messageBuf: []messages.Message{}, } log.Info("Initializing RiveScript brain") @@ -85,6 +96,31 @@ func (h *BotHandlers) OnMe(msg messages.Message) { } } +// Buffer a message seen on chat for a while. +func (h *BotHandlers) cacheMessage(msg messages.Message) { + h.messageBufMu.Lock() + defer h.messageBufMu.Unlock() + + h.messageBuf = append(h.messageBuf, msg) + + if len(h.messageBuf) > ScrollbackBuffer { + h.messageBuf = h.messageBuf[len(h.messageBuf)-ScrollbackBuffer:] + } +} + +// Get a message by ID from the recent message buffer. +func (h *BotHandlers) getMessageByID(msgID int) (messages.Message, bool) { + h.messageBufMu.RLock() + defer h.messageBufMu.RUnlock() + for _, msg := range h.messageBuf { + if msg.MessageID == msgID { + return msg, true + } + } + + return messages.Message{}, false +} + // OnMessage handles Who List updates in chat. func (h *BotHandlers) OnMessage(msg messages.Message) { // Strip HTML. @@ -95,6 +131,9 @@ func (h *BotHandlers) OnMessage(msg messages.Message) { return } + // Cache it in our message buffer. + h.cacheMessage(msg) + // Do we send a reply to this? var ( sendReply bool @@ -210,6 +249,12 @@ func (h *BotHandlers) OnPresence(msg messages.Message) { // A join message? if strings.Contains(msg.Message, "has joined the room") { + // Global auto-greet cooldown. + if time.Now().Before(h.autoGreetCooldown) { + return + } + h.autoGreetCooldown = time.Now().Add(15 * time.Minute) + // Don't greet the same user too often in case of bouncing. h.autoGreetMu.Lock() if timeout, ok := h.autoGreet[msg.Username]; ok { diff --git a/client/rivescript_macros.go b/client/rivescript_macros.go index de5e093..3dbeb36 100644 --- a/client/rivescript_macros.go +++ b/client/rivescript_macros.go @@ -3,8 +3,10 @@ package client import ( "fmt" "strconv" + "strings" "time" + "git.kirsle.net/apps/barertc/pkg/log" "git.kirsle.net/apps/barertc/pkg/messages" "github.com/aichaos/rivescript-go" ) @@ -47,8 +49,74 @@ func (h *BotHandlers) setObjectMacros() { } else { return fmt.Sprintf("[react: %s]", err) } + return "" + } + return "[react: invalid number of parameters]" + }) + + // Takeback a message (admin action especially) + h.rs.SetSubroutine("takeback", func(rs *rivescript.RiveScript, args []string) string { + if len(args) >= 1 { + if msgID, err := strconv.Atoi(args[0]); err == nil { + // Take it back. + h.client.Send(messages.Message{ + Action: messages.ActionTakeback, + MessageID: msgID, + }) + } else { + return fmt.Sprintf("[takeback: %s]", err) + } + return "" + } + return "[takeback: invalid number of parameters]" + }) + + // Flag (report) a message on chat. + h.rs.SetSubroutine("report", func(rs *rivescript.RiveScript, args []string) string { + if len(args) >= 2 { + if msgID, err := strconv.Atoi(args[0]); err == nil { + var comment = strings.Join(args[1:], " ") + + // Look up this message. + if msg, ok := h.getMessageByID(msgID); ok { + // Report it with the custom comment. + h.client.Send(messages.Message{ + Action: messages.ActionReport, + Channel: msg.Channel, + Username: msg.Username, + Timestamp: "not recorded", + Reason: "Automated chatbot flag", + Message: msg.Message, + Comment: comment, + }) + return "" + } + + return "[msgID not found]" + } else { + return fmt.Sprintf("[report: %s]", err) + } + } + return "[report: invalid number of parameters]" + }) + + // Send a user a Direct Message. + h.rs.SetSubroutine("dm", func(rs *rivescript.RiveScript, args []string) string { + if len(args) >= 2 { + var ( + username = args[0] + message = strings.Join(args[1:], " ") + ) + + // Slide into their DMs. + log.Error("Send DM to [%s]: %s", username, message) + h.client.Send(messages.Message{ + Action: messages.ActionMessage, + Channel: "@" + username, + Message: message, + }) } else { - return "[react: invalid number of parameters]" + return "[dm: invalid number of parameters]" } return "" }) diff --git a/client/utils.go b/client/utils.go index 76c1a75..432d6e2 100644 --- a/client/utils.go +++ b/client/utils.go @@ -1,7 +1,9 @@ package client import ( + "errors" "fmt" + "net/url" "regexp" "strings" ) @@ -17,6 +19,25 @@ func StripHTML(s string) string { return strings.TrimSpace(reHTML.ReplaceAllString(s, "")) } +// WebSocketURL converts the BareRTC base (https) URL into the WebSocket link. +func WebSocketURL(baseURL string) (string, error) { + url, err := url.Parse(baseURL) + if err != nil { + return "", err + } + + switch url.Scheme { + case "https": + return fmt.Sprintf("wss://%s/ws", url.Host), nil + case "http": + return fmt.Sprintf("ws://%s/ws", url.Host), nil + case "ws", "wss": + return fmt.Sprintf("%s//%s/ws", url.Scheme, url.Host), nil + default: + return "", errors.New("unsupported URL scheme") + } +} + // AtMentioned checks if somebody has "at mentioned" your username (having your // name at the beginning or end of their message). Returns whether the at mention // was detected, along with the modified message without the at mention name on the diff --git a/cmd/BareBot/commands/run.go b/cmd/BareBot/commands/run.go index ba21540..5d5144c 100644 --- a/cmd/BareBot/commands/run.go +++ b/cmd/BareBot/commands/run.go @@ -54,7 +54,7 @@ func init() { // Get the JWT auth token. log.Info("Authenticating with BareRTC (getting JWT token)") - client, err := client.NewClient("ws://localhost:9000/ws", jwt.Claims{ + client, err := client.NewClient(config.Current.BareRTC.URL, jwt.Claims{ IsAdmin: config.Current.Profile.IsAdmin, Avatar: config.Current.Profile.AvatarURL, ProfileURL: config.Current.Profile.ProfileURL, diff --git a/docs/Chatbot.md b/docs/Chatbot.md index 7a554b0..43a83c2 100644 --- a/docs/Chatbot.md +++ b/docs/Chatbot.md @@ -122,6 +122,8 @@ You can invoke these by using the `` tag in your RiveScript responses -- s This command can reload the chatbot's RiveScript sources from disk, making it easy to iterate on your robot without rebooting the whole program. +Usage: `reload` + Example: ```rivescript @@ -136,6 +138,8 @@ It returns a message like "The RiveScript brain has been reloaded!" You can send an emoji reaction to a message ID. The current message ID is available in the `` tag of a RiveScript reply. +Usage: `react ` + Example: ```rivescript @@ -148,3 +152,62 @@ Example: Note: the `react` command returns no text (except on error). Couple it with a `` if you want to ensure the bot does not send a reply to the message in chat, but simply applies the reaction emoji. The reaction is delayed about 2.5 seconds. + +## DM + +Slide into a user's DMs and send them a Direct Message no matter what channel you saw their message in. + +Usage: `dm ` + +Example: say you have a global keyword trigger on public rooms and want to DM a user if they match one. + +```rivescript +> topic PublicChannel + + + [*] sensitive keywords [*] + - dm I saw you say something in the public channel! + ^ Please don't say that stuff on here! @ + +< topic +``` + +## Takeback + +Take back a message by its ID. This may be useful if you have a global moderator trigger set up so you can remove a user's message. + +Usage: `takeback ` + +Example: + +```rivescript +> topic PublicChannel + + + [*] sensitive keywords [*] + - takeback + ^ Please don't say that stuff on here! @ + +< topic +``` + +Note: the `takeback` command returns no text (except on error). + +## Report + +Send a BareRTC `report` action about a message ID. If your chat server is configured with a webhook URL to report messages to your website, those reports can be sent automatically by your bot. + +Usage: `report ` + +The message ID must have been recently sent: the chatbot holds a buffer of recently seen message IDs (last 500 or so). This should work OK since most likely you would report the current message automatically based on a keyword trigger. + +Example: + +```rivescript +> topic PublicChannel + + + [*] (sensitive|keywords) [*] + - report User has said the keyword '' + +< topic +``` + +Note: the `report` command returns no text (except on error).