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).