ScuttleBot

feat: scroll bot (history replay via PM)

lmata 2026-03-31 05:01 trunk
Commit 7ddb0c47739857b4e3079e2e0ed4aa86149efeb92d1cb8b6712400fccdfa7174
--- a/internal/bots/scroll/scroll.go
+++ b/internal/bots/scroll/scroll.go
@@ -0,0 +1,59 @@
1
+// Package scroll implements the scroll bot — channel history replay via PM.
2
+//
3
+// Agents or humans send a PM to scroll requesting history for a channel.
4
+// scroll fetches from scribe's Store and delivers entries as PM messages,
5
+// never posting to the channel itself.
6
+//
7
+// Command format:
8
+//
9
+// replay #channel [last=N] [since=<unix_ms>]
10
+package scroll
11
+
12
+import (
13
+ "context"
14
+ "encoding/json"
15
+ "fmt"
16
+ "log/slog"
17
+ ""fmt"
18
+ "log/slog"
19
+ "net"
20
+ "strconv"
21
+ "strings"
22
+ "sync"
23
+ "time"
24
+
25
+ "github.com/lrstanley/girc"
26
+
27
+ "github.com/conflicthq/scuttlebot/internal/bots/scribe"
28
+c (b *Bot) Name() string { return botNick }
29
+
30
+// Start connects to IRC and begins handling replay requests. Blocks until ctx is cancelled.
31
+func (b *Bot) Start(ctx context.Context) error {
32
+ host != nil {
33
+ return fmt.Errorf("scroll: parse rateLimit sync.MapbotNick,
34
+ Name: "scuttlebot scroll",
35
+ SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
36
+ PingDelay: 30 * time.Second,
37
+ PingTimeout: SSL: false,
38
+ SupportedCaps: map[string][]string{
39
+ "draft/chathist"draft/chathistory"erver: r: until ctx is cancelled.
40
+fuostPort(b.ircAddr)
41
+ if err != nil {
42
+ return fmt.Errorf("scroll: parse irc addr: %w", err)
43
+ }
44
+
45
+ c := girc.New(girc.Config{
46
+ Server: r: host,
47
+ Port: port,
48
+ Nick: botNick,
49
+ User: botNick,
50
+ Name: "scuttlebot scroll",
51
+ SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
52
+ PingDelay: 30 * time.Second,
53
+ PingTimeout: 30 * time.Second,
54
+ host,
55
+ Port: port, botNick,
56
+ User:M@Ol,xO@PB,pUVWn;SSL:var host string
57
+ var port int
58
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);SASLPlain{User: botNickreturn host, port, nil
59
+}
--- a/internal/bots/scroll/scroll.go
+++ b/internal/bots/scroll/scroll.go
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/scroll/scroll.go
+++ b/internal/bots/scroll/scroll.go
@@ -0,0 +1,59 @@
1 // Package scroll implements the scroll bot — channel history replay via PM.
2 //
3 // Agents or humans send a PM to scroll requesting history for a channel.
4 // scroll fetches from scribe's Store and delivers entries as PM messages,
5 // never posting to the channel itself.
6 //
7 // Command format:
8 //
9 // replay #channel [last=N] [since=<unix_ms>]
10 package scroll
11
12 import (
13 "context"
14 "encoding/json"
15 "fmt"
16 "log/slog"
17 ""fmt"
18 "log/slog"
19 "net"
20 "strconv"
21 "strings"
22 "sync"
23 "time"
24
25 "github.com/lrstanley/girc"
26
27 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
28 c (b *Bot) Name() string { return botNick }
29
30 // Start connects to IRC and begins handling replay requests. Blocks until ctx is cancelled.
31 func (b *Bot) Start(ctx context.Context) error {
32 host != nil {
33 return fmt.Errorf("scroll: parse rateLimit sync.MapbotNick,
34 Name: "scuttlebot scroll",
35 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
36 PingDelay: 30 * time.Second,
37 PingTimeout: SSL: false,
38 SupportedCaps: map[string][]string{
39 "draft/chathist"draft/chathistory"erver: r: until ctx is cancelled.
40 fuostPort(b.ircAddr)
41 if err != nil {
42 return fmt.Errorf("scroll: parse irc addr: %w", err)
43 }
44
45 c := girc.New(girc.Config{
46 Server: r: host,
47 Port: port,
48 Nick: botNick,
49 User: botNick,
50 Name: "scuttlebot scroll",
51 SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
52 PingDelay: 30 * time.Second,
53 PingTimeout: 30 * time.Second,
54 host,
55 Port: port, botNick,
56 User:M@Ol,xO@PB,pUVWn;SSL:var host string
57 var port int
58 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);SASLPlain{User: botNickreturn host, port, nil
59 }
--- a/internal/bots/scroll/scroll_test.go
+++ b/internal/bots/scroll/scroll_test.go
@@ -0,0 +1,59 @@
1
+package scroll_test
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/bots/scroll"
8
+)
9
+
10
+func TestParseCommand(t *testing.T) {
11
+ cases := []struct {
12
+ name string
13
+ input string
14
+ wantCh string
15
+ wantLim int
16
+ wantErr bool
17
+ }{
18
+ {"basic", "replay #fleet", "#fleet", 50, false},
19
+ {"with last", "replay #fleet last=100", "#fleet", 100, false},
20
+ {"last capped at max", "replay #fleet last=9999", "#fleet", 500, false},
21
+ {"missing channel", "replay", "", 0, true},
22
+ {"no hash", "replay fleet", "", 0, true},
23
+ {"unknown command", "history #fleet", "", 0, true},
24
+ {"invalid last", "replay #fleet last=abc", "", 0, true},
25
+ {"unknown arg", "replay #fleet foo=bar", "", 0, true},
26
+ }
27
+
28
+ for _, tc := range cases {
29
+ t.Run(tc.name, func(t *testing.T) {
30
+ req, err := scroll.ParseCommand(tc.input)
31
+ if tc.wantErr {
32
+ if err == nil {
33
+ t.Errorf("ParseCommand(%q): expected error, got nil", tc.input)
34
+ }
35
+ return
36
+ }
37
+ if err != nil {
38
+ t.Fatalf("ParseCommand(%q): unexpected error: %v", tc.input, err)
39
+ }
40
+ if req.Channel != tc.wantCh {
41
+ t.Errorf("Channel: got %q, want %q", req.Channel, tc.wantCh)
42
+ }
43
+ if req.Limit != tc.wantLim {
44
+ t.Errorf("Limit: got %d, want %d", req.Limit, tc.wantLim)
45
+ }
46
+ })
47
+ }
48
+}
49
+
50
+func TestParseCommandCaseInsensitive(t *testing.T) {
51
+ req, err := scroll.ParseCommand("REPLAY #fleet last=10")
52
+ if err != nil {
53
+ t.Fatalf("ParseCommand: %v", err)
54
+ }
55
+ if req.Channel != "#fleet" {
56
+ t.Errorf("Channel: got %q", req.Channel)
57
+ }
58
+ _ = strings.ToLower // just confirming case insensitivity is tested
59
+}
--- a/internal/bots/scroll/scroll_test.go
+++ b/internal/bots/scroll/scroll_test.go
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/scroll/scroll_test.go
+++ b/internal/bots/scroll/scroll_test.go
@@ -0,0 +1,59 @@
1 package scroll_test
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/conflicthq/scuttlebot/internal/bots/scroll"
8 )
9
10 func TestParseCommand(t *testing.T) {
11 cases := []struct {
12 name string
13 input string
14 wantCh string
15 wantLim int
16 wantErr bool
17 }{
18 {"basic", "replay #fleet", "#fleet", 50, false},
19 {"with last", "replay #fleet last=100", "#fleet", 100, false},
20 {"last capped at max", "replay #fleet last=9999", "#fleet", 500, false},
21 {"missing channel", "replay", "", 0, true},
22 {"no hash", "replay fleet", "", 0, true},
23 {"unknown command", "history #fleet", "", 0, true},
24 {"invalid last", "replay #fleet last=abc", "", 0, true},
25 {"unknown arg", "replay #fleet foo=bar", "", 0, true},
26 }
27
28 for _, tc := range cases {
29 t.Run(tc.name, func(t *testing.T) {
30 req, err := scroll.ParseCommand(tc.input)
31 if tc.wantErr {
32 if err == nil {
33 t.Errorf("ParseCommand(%q): expected error, got nil", tc.input)
34 }
35 return
36 }
37 if err != nil {
38 t.Fatalf("ParseCommand(%q): unexpected error: %v", tc.input, err)
39 }
40 if req.Channel != tc.wantCh {
41 t.Errorf("Channel: got %q, want %q", req.Channel, tc.wantCh)
42 }
43 if req.Limit != tc.wantLim {
44 t.Errorf("Limit: got %d, want %d", req.Limit, tc.wantLim)
45 }
46 })
47 }
48 }
49
50 func TestParseCommandCaseInsensitive(t *testing.T) {
51 req, err := scroll.ParseCommand("REPLAY #fleet last=10")
52 if err != nil {
53 t.Fatalf("ParseCommand: %v", err)
54 }
55 if req.Channel != "#fleet" {
56 t.Errorf("Channel: got %q", req.Channel)
57 }
58 _ = strings.ToLower // just confirming case insensitivity is tested
59 }

Keyboard Shortcuts

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