ScuttleBot

scuttlebot / internal / bots / scroll / scroll.go
Source Blame History 315 lines
7ddb0c4… lmata 1 // Package scroll implements the scroll bot — channel history replay via PM.
7ddb0c4… lmata 2 //
7ddb0c4… lmata 3 // Agents or humans send a PM to scroll requesting history for a channel.
7ddb0c4… lmata 4 // scroll fetches from scribe's Store and delivers entries as PM messages,
7ddb0c4… lmata 5 // never posting to the channel itself.
7ddb0c4… lmata 6 //
7ddb0c4… lmata 7 // Command format:
7ddb0c4… lmata 8 //
7ddb0c4… lmata 9 // replay #channel [last=N] [since=<unix_ms>]
7ddb0c4… lmata 10 package scroll
7ddb0c4… lmata 11
7ddb0c4… lmata 12 import (
7ddb0c4… lmata 13 "context"
7ddb0c4… lmata 14 "encoding/json"
7ddb0c4… lmata 15 "fmt"
7ddb0c4… lmata 16 "log/slog"
18e8fef… lmata 17 "net"
7ddb0c4… lmata 18 "strconv"
7ddb0c4… lmata 19 "strings"
7ddb0c4… lmata 20 "sync"
7ddb0c4… lmata 21 "time"
7ddb0c4… lmata 22
7ddb0c4… lmata 23 "github.com/lrstanley/girc"
7ddb0c4… lmata 24
e8d318d… noreply 25 "github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
7ddb0c4… lmata 26 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
f64fe5f… noreply 27 "github.com/conflicthq/scuttlebot/pkg/chathistory"
a027855… noreply 28 "github.com/conflicthq/scuttlebot/pkg/toon"
7ddb0c4… lmata 29 )
7ddb0c4… lmata 30
7ddb0c4… lmata 31 const (
7ddb0c4… lmata 32 botNick = "scroll"
7ddb0c4… lmata 33 defaultLimit = 50
7ddb0c4… lmata 34 maxLimit = 500
7ddb0c4… lmata 35 rateLimitWindow = 10 * time.Second
7ddb0c4… lmata 36 )
7ddb0c4… lmata 37
7ddb0c4… lmata 38 // Bot is the scroll history-replay bot.
7ddb0c4… lmata 39 type Bot struct {
7ddb0c4… lmata 40 ircAddr string
7ddb0c4… lmata 41 password string
3420a83… lmata 42 channels []string
7ddb0c4… lmata 43 store scribe.Store
7ddb0c4… lmata 44 log *slog.Logger
7ddb0c4… lmata 45 client *girc.Client
f64fe5f… noreply 46 history *chathistory.Fetcher // nil until connected, if CHATHISTORY is available
f64fe5f… noreply 47 rateLimit sync.Map // nick → last request time
7ddb0c4… lmata 48 }
7ddb0c4… lmata 49
7ddb0c4… lmata 50 // New creates a scroll Bot backed by the given scribe Store.
3420a83… lmata 51 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
7ddb0c4… lmata 52 return &Bot{
7ddb0c4… lmata 53 ircAddr: ircAddr,
7ddb0c4… lmata 54 password: password,
3420a83… lmata 55 channels: channels,
7ddb0c4… lmata 56 store: store,
7ddb0c4… lmata 57 log: log,
7ddb0c4… lmata 58 }
7ddb0c4… lmata 59 }
7ddb0c4… lmata 60
7ddb0c4… lmata 61 // Name returns the bot's IRC nick.
7ddb0c4… lmata 62 func (b *Bot) Name() string { return botNick }
7ddb0c4… lmata 63
7ddb0c4… lmata 64 // Start connects to IRC and begins handling replay requests. Blocks until ctx is cancelled.
7ddb0c4… lmata 65 func (b *Bot) Start(ctx context.Context) error {
7ddb0c4… lmata 66 host, port, err := splitHostPort(b.ircAddr)
7ddb0c4… lmata 67 if err != nil {
7ddb0c4… lmata 68 return fmt.Errorf("scroll: parse irc addr: %w", err)
7ddb0c4… lmata 69 }
7ddb0c4… lmata 70
7ddb0c4… lmata 71 c := girc.New(girc.Config{
d924aea… lmata 72 Server: host,
d924aea… lmata 73 Port: port,
d924aea… lmata 74 Nick: botNick,
d924aea… lmata 75 User: botNick,
d924aea… lmata 76 Name: "scuttlebot scroll",
81587e6… lmata 77 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
81587e6… lmata 78 PingDelay: 30 * time.Second,
81587e6… lmata 79 PingTimeout: 30 * time.Second,
81587e6… lmata 80 SSL: false,
f64fe5f… noreply 81 SupportedCaps: map[string][]string{
f64fe5f… noreply 82 "draft/chathistory": nil,
f64fe5f… noreply 83 "chathistory": nil,
f64fe5f… noreply 84 },
3420a83… lmata 85 })
3420a83… lmata 86
f64fe5f… noreply 87 // Register CHATHISTORY batch handlers before connecting.
f64fe5f… noreply 88 b.history = chathistory.New(c)
f64fe5f… noreply 89
3420a83… lmata 90 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
f64fe5f… noreply 91 cl.Cmd.Mode(cl.GetNick(), "+B")
3420a83… lmata 92 for _, ch := range b.channels {
3420a83… lmata 93 cl.Cmd.Join(ch)
3420a83… lmata 94 }
f64fe5f… noreply 95 hasCH := cl.HasCapability("chathistory") || cl.HasCapability("draft/chathistory")
f64fe5f… noreply 96 b.log.Info("scroll connected", "channels", b.channels, "chathistory", hasCH)
f64fe5f… noreply 97 })
f64fe5f… noreply 98
e8d318d… noreply 99 router := cmdparse.NewRouter(botNick)
e8d318d… noreply 100 router.Register(cmdparse.Command{
e8d318d… noreply 101 Name: "replay",
e8d318d… noreply 102 Usage: "REPLAY [#channel] [count]",
e8d318d… noreply 103 Description: "replay recent channel messages",
e8d318d… noreply 104 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 105 })
e8d318d… noreply 106 router.Register(cmdparse.Command{
e8d318d… noreply 107 Name: "search",
e8d318d… noreply 108 Usage: "SEARCH [#channel] <term>",
e8d318d… noreply 109 Description: "search channel history",
e8d318d… noreply 110 Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
e8d318d… noreply 111 })
e8d318d… noreply 112
81587e6… lmata 113 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
6ebcaed… noreply 114 if len(e.Params) < 1 || e.Source == nil {
e8d318d… noreply 115 return
e8d318d… noreply 116 }
e8d318d… noreply 117 // Dispatch commands (DMs and channel messages).
e8d318d… noreply 118 if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
e8d318d… noreply 119 client.Cmd.Message(reply.Target, reply.Text)
7ddb0c4… lmata 120 return
7ddb0c4… lmata 121 }
7ddb0c4… lmata 122 target := e.Params[0]
7ddb0c4… lmata 123 if strings.HasPrefix(target, "#") {
7ddb0c4… lmata 124 return // channel message, ignore
7ddb0c4… lmata 125 }
7ddb0c4… lmata 126 nick := e.Source.Name
7ddb0c4… lmata 127 text := strings.TrimSpace(e.Last())
7ddb0c4… lmata 128 b.handle(client, nick, text)
7ddb0c4… lmata 129 })
7ddb0c4… lmata 130
7ddb0c4… lmata 131 b.client = c
7ddb0c4… lmata 132
7ddb0c4… lmata 133 errCh := make(chan error, 1)
7ddb0c4… lmata 134 go func() {
7ddb0c4… lmata 135 if err := c.Connect(); err != nil && ctx.Err() == nil {
7ddb0c4… lmata 136 errCh <- err
7ddb0c4… lmata 137 }
7ddb0c4… lmata 138 }()
7ddb0c4… lmata 139
7ddb0c4… lmata 140 select {
7ddb0c4… lmata 141 case <-ctx.Done():
7ddb0c4… lmata 142 c.Close()
7ddb0c4… lmata 143 return nil
7ddb0c4… lmata 144 case err := <-errCh:
7ddb0c4… lmata 145 return fmt.Errorf("scroll: irc connection: %w", err)
7ddb0c4… lmata 146 }
7ddb0c4… lmata 147 }
7ddb0c4… lmata 148
7ddb0c4… lmata 149 // Stop disconnects the bot.
7ddb0c4… lmata 150 func (b *Bot) Stop() {
7ddb0c4… lmata 151 if b.client != nil {
7ddb0c4… lmata 152 b.client.Close()
7ddb0c4… lmata 153 }
7ddb0c4… lmata 154 }
7ddb0c4… lmata 155
7ddb0c4… lmata 156 func (b *Bot) handle(client *girc.Client, nick, text string) {
7ddb0c4… lmata 157 if !b.checkRateLimit(nick) {
7ddb0c4… lmata 158 client.Cmd.Notice(nick, "rate limited — please wait before requesting again")
7ddb0c4… lmata 159 return
7ddb0c4… lmata 160 }
7ddb0c4… lmata 161
7ddb0c4… lmata 162 req, err := ParseCommand(text)
7ddb0c4… lmata 163 if err != nil {
7ddb0c4… lmata 164 client.Cmd.Notice(nick, fmt.Sprintf("error: %s", err))
a027855… noreply 165 client.Cmd.Notice(nick, "usage: replay #channel [last=N] [since=<unix_ms>] [format=json|toon]")
7ddb0c4… lmata 166 return
7ddb0c4… lmata 167 }
7ddb0c4… lmata 168
f64fe5f… noreply 169 entries, err := b.fetchHistory(req)
7ddb0c4… lmata 170 if err != nil {
7ddb0c4… lmata 171 client.Cmd.Notice(nick, fmt.Sprintf("error fetching history: %s", err))
7ddb0c4… lmata 172 return
7ddb0c4… lmata 173 }
7ddb0c4… lmata 174
7ddb0c4… lmata 175 if len(entries) == 0 {
7ddb0c4… lmata 176 client.Cmd.Notice(nick, fmt.Sprintf("no history found for %s", req.Channel))
7ddb0c4… lmata 177 return
7ddb0c4… lmata 178 }
7ddb0c4… lmata 179
a027855… noreply 180 if req.Format == "toon" {
a027855… noreply 181 toonEntries := make([]toon.Entry, len(entries))
a027855… noreply 182 for i, e := range entries {
a027855… noreply 183 toonEntries[i] = toon.Entry{
a027855… noreply 184 Nick: e.Nick,
a027855… noreply 185 MessageType: e.MessageType,
a027855… noreply 186 Text: e.Raw,
a027855… noreply 187 At: e.At,
a027855… noreply 188 }
a027855… noreply 189 }
a027855… noreply 190 output := toon.Format(toonEntries, toon.Options{Channel: req.Channel})
a027855… noreply 191 for _, line := range strings.Split(output, "\n") {
a027855… noreply 192 if line != "" {
a027855… noreply 193 client.Cmd.Notice(nick, line)
a027855… noreply 194 }
a027855… noreply 195 }
a027855… noreply 196 } else {
a027855… noreply 197 client.Cmd.Notice(nick, fmt.Sprintf("--- replay %s (%d entries) ---", req.Channel, len(entries)))
a027855… noreply 198 for _, e := range entries {
a027855… noreply 199 line, _ := json.Marshal(e)
a027855… noreply 200 client.Cmd.Notice(nick, string(line))
a027855… noreply 201 }
a027855… noreply 202 client.Cmd.Notice(nick, fmt.Sprintf("--- end replay %s ---", req.Channel))
f64fe5f… noreply 203 }
f64fe5f… noreply 204 }
f64fe5f… noreply 205
f64fe5f… noreply 206 // fetchHistory tries CHATHISTORY first, falls back to scribe store.
f64fe5f… noreply 207 func (b *Bot) fetchHistory(req *replayRequest) ([]scribe.Entry, error) {
f64fe5f… noreply 208 if b.history != nil && b.client != nil {
f64fe5f… noreply 209 hasCH := b.client.HasCapability("chathistory") || b.client.HasCapability("draft/chathistory")
f64fe5f… noreply 210 if hasCH {
f64fe5f… noreply 211 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
f64fe5f… noreply 212 defer cancel()
f64fe5f… noreply 213 msgs, err := b.history.Latest(ctx, req.Channel, req.Limit)
f64fe5f… noreply 214 if err == nil {
f64fe5f… noreply 215 entries := make([]scribe.Entry, len(msgs))
f64fe5f… noreply 216 for i, m := range msgs {
f64fe5f… noreply 217 entries[i] = scribe.Entry{
f64fe5f… noreply 218 At: m.At,
f64fe5f… noreply 219 Channel: req.Channel,
f64fe5f… noreply 220 Nick: m.Nick,
f64fe5f… noreply 221 Kind: scribe.EntryKindRaw,
f64fe5f… noreply 222 Raw: m.Text,
f64fe5f… noreply 223 }
f64fe5f… noreply 224 if m.Account != "" {
f64fe5f… noreply 225 entries[i].Nick = m.Account
f64fe5f… noreply 226 }
f64fe5f… noreply 227 }
f64fe5f… noreply 228 return entries, nil
f64fe5f… noreply 229 }
f64fe5f… noreply 230 b.log.Warn("chathistory failed, falling back to store", "err", err)
f64fe5f… noreply 231 }
7ddb0c4… lmata 232 }
f64fe5f… noreply 233 return b.store.Query(req.Channel, req.Limit)
7ddb0c4… lmata 234 }
7ddb0c4… lmata 235
7ddb0c4… lmata 236 func (b *Bot) checkRateLimit(nick string) bool {
7ddb0c4… lmata 237 now := time.Now()
7ddb0c4… lmata 238 if last, ok := b.rateLimit.Load(nick); ok {
7ddb0c4… lmata 239 if now.Sub(last.(time.Time)) < rateLimitWindow {
7ddb0c4… lmata 240 return false
7ddb0c4… lmata 241 }
7ddb0c4… lmata 242 }
7ddb0c4… lmata 243 b.rateLimit.Store(nick, now)
7ddb0c4… lmata 244 return true
7ddb0c4… lmata 245 }
7ddb0c4… lmata 246
7ddb0c4… lmata 247 // ReplayRequest is a parsed replay command.
7ddb0c4… lmata 248 type replayRequest struct {
7ddb0c4… lmata 249 Channel string
7ddb0c4… lmata 250 Limit int
a027855… noreply 251 Since int64 // unix ms, 0 = no filter
a027855… noreply 252 Format string // "json" (default) or "toon"
7ddb0c4… lmata 253 }
7ddb0c4… lmata 254
7ddb0c4… lmata 255 // ParseCommand parses a replay command string. Exported for testing.
7ddb0c4… lmata 256 func ParseCommand(text string) (*replayRequest, error) {
7ddb0c4… lmata 257 parts := strings.Fields(text)
7ddb0c4… lmata 258 if len(parts) < 2 || strings.ToLower(parts[0]) != "replay" {
7ddb0c4… lmata 259 return nil, fmt.Errorf("unknown command %q", parts[0])
7ddb0c4… lmata 260 }
7ddb0c4… lmata 261
7ddb0c4… lmata 262 channel := parts[1]
7ddb0c4… lmata 263 if !strings.HasPrefix(channel, "#") {
7ddb0c4… lmata 264 return nil, fmt.Errorf("channel must start with #")
7ddb0c4… lmata 265 }
7ddb0c4… lmata 266
7ddb0c4… lmata 267 req := &replayRequest{Channel: channel, Limit: defaultLimit}
7ddb0c4… lmata 268
7ddb0c4… lmata 269 for _, arg := range parts[2:] {
7ddb0c4… lmata 270 kv := strings.SplitN(arg, "=", 2)
7ddb0c4… lmata 271 if len(kv) != 2 {
7ddb0c4… lmata 272 return nil, fmt.Errorf("invalid argument %q (expected key=value)", arg)
7ddb0c4… lmata 273 }
7ddb0c4… lmata 274 switch strings.ToLower(kv[0]) {
7ddb0c4… lmata 275 case "last":
7ddb0c4… lmata 276 n, err := strconv.Atoi(kv[1])
7ddb0c4… lmata 277 if err != nil || n <= 0 {
7ddb0c4… lmata 278 return nil, fmt.Errorf("invalid last=%q (must be a positive integer)", kv[1])
7ddb0c4… lmata 279 }
7ddb0c4… lmata 280 if n > maxLimit {
7ddb0c4… lmata 281 n = maxLimit
7ddb0c4… lmata 282 }
7ddb0c4… lmata 283 req.Limit = n
7ddb0c4… lmata 284 case "since":
7ddb0c4… lmata 285 ts, err := strconv.ParseInt(kv[1], 10, 64)
7ddb0c4… lmata 286 if err != nil {
7ddb0c4… lmata 287 return nil, fmt.Errorf("invalid since=%q (must be unix milliseconds)", kv[1])
7ddb0c4… lmata 288 }
7ddb0c4… lmata 289 req.Since = ts
a027855… noreply 290 case "format":
a027855… noreply 291 switch strings.ToLower(kv[1]) {
a027855… noreply 292 case "json", "toon":
a027855… noreply 293 req.Format = strings.ToLower(kv[1])
a027855… noreply 294 default:
a027855… noreply 295 return nil, fmt.Errorf("unknown format %q (use json or toon)", kv[1])
a027855… noreply 296 }
7ddb0c4… lmata 297 default:
7ddb0c4… lmata 298 return nil, fmt.Errorf("unknown argument %q", kv[0])
7ddb0c4… lmata 299 }
7ddb0c4… lmata 300 }
7ddb0c4… lmata 301
7ddb0c4… lmata 302 return req, nil
7ddb0c4… lmata 303 }
7ddb0c4… lmata 304
7ddb0c4… lmata 305 func splitHostPort(addr string) (string, int, error) {
18e8fef… lmata 306 host, portStr, err := net.SplitHostPort(addr)
18e8fef… lmata 307 if err != nil {
7ddb0c4… lmata 308 return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
18e8fef… lmata 309 }
18e8fef… lmata 310 port, err := strconv.Atoi(portStr)
18e8fef… lmata 311 if err != nil {
18e8fef… lmata 312 return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
7ddb0c4… lmata 313 }
7ddb0c4… lmata 314 return host, port, nil
7ddb0c4… lmata 315 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button