ScuttleBot

feat: systembot, auditbot, herald, warden bots (#22 #23 #15 #16) systembot: logs NOTICE/JOIN/PART/QUIT/KICK/MODE to Store — complement to scribe which owns PRIVMSG. Separate EntryKind per event type. auditbot: immutable audit trail for configured message types. Record() method for direct registry injection (registration/rotation/ revocation events). Audit types filter: empty = audit all. herald: alert delivery bot. Emit(Event) queues events; deliverLoop() routes to IRC channels by longest-prefix match on event type. Token- bucket rate limiter (configurable rate + burst). Non-blocking queue with drop-and-warn on overflow. warden: channel moderation. Token-bucket rate limit per nick per channel. Malformed JSON envelopes (starts with '{') get a NOTICE warn. Rate violations escalate: warn → mute (+q) → kick with cool-down reset. Per-channel config with default fallback. Closes #22 #23 #15 #16

lmata 2026-03-31 06:34 trunk
Commit 8fe9b108958ff62594959d9e8d65fac449787bcfad46d0ca9ce80687190792e6
--- a/internal/bots/auditbot/auditbot.go
+++ b/internal/bots/auditbot/auditbot.go
@@ -0,0 +1,34 @@
1
+/ JOINreturn
2
+ }
3
+
4
+
5
+Join) {
6
+ return
7
+ }
8
+
9
+nick := extractNick(e)
10
+IRC,
11
+})
12
+})
13
+
14
+return
15
+ }
16
+ return
17
+nick := ""
18
+}
19
+IRC,
20
+MessageType: "user.part",
21
+ })
22
+failed to write audit entry",
23
+ "type", e.MessageType,
24
+ "nick", e.Nick,
25
+"kind", e.Kind,
26
+ "err", err,
27
+auditbot: failed to write entry", "err", errhost,
28
+ Port: port,
29
+ Nick: botNick,
30
+ User: botNick,
31
+ Name:SSL:var host string
32
+ var port int
33
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &portreturn host, port, nil
34
+}
--- a/internal/bots/auditbot/auditbot.go
+++ b/internal/bots/auditbot/auditbot.go
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/auditbot/auditbot.go
+++ b/internal/bots/auditbot/auditbot.go
@@ -0,0 +1,34 @@
1 / JOINreturn
2 }
3
4
5 Join) {
6 return
7 }
8
9 nick := extractNick(e)
10 IRC,
11 })
12 })
13
14 return
15 }
16 return
17 nick := ""
18 }
19 IRC,
20 MessageType: "user.part",
21 })
22 failed to write audit entry",
23 "type", e.MessageType,
24 "nick", e.Nick,
25 "kind", e.Kind,
26 "err", err,
27 auditbot: failed to write entry", "err", errhost,
28 Port: port,
29 Nick: botNick,
30 User: botNick,
31 Name:SSL:var host string
32 var port int
33 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &portreturn host, port, nil
34 }
--- a/internal/bots/auditbot/auditbot_test.go
+++ b/internal/bots/auditbot/auditbot_test.go
@@ -0,0 +1,111 @@
1
+package auditbot_test
2
+
3
+import (
4
+ "testing"
5
+
6
+ "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
7
+)
8
+
9
+func newBot(auditTypes ...string) (*auditbot.Bot, *auditbot.MemoryStore) {
10
+ s := &auditbot.MemoryStore{}
11
+ b := auditbot.New("localhost:6667", "pass", []string{"#fleet"}, auditTypes, s, nil)
12
+ return b, s
13
+}
14
+
15
+func TestBotNameAndNew(t *testing.T) {
16
+ b, _ := newBot()
17
+ if b.Name() != "auditbot" {
18
+ t.Errorf("Name(): got %q, want auditbot", b.Name())
19
+ }
20
+}
21
+
22
+func TestRecordRegistryEvent(t *testing.T) {
23
+ _, s := newBot("agent.registered")
24
+ // newBot uses nil logger; Record directly writes to store.
25
+ b, s2 := newBot("agent.registered")
26
+ b.Record("agent-01", "agent.registered", "new registration")
27
+
28
+ entries := s2.All()
29
+ if len(entries) != 1 {
30
+ t.Fatalf("expected 1 entry, got %d", len(entries))
31
+ }
32
+ e := entries[0]
33
+ if e.Kind != auditbot.KindRegistry {
34
+ t.Errorf("Kind: got %q, want registry", e.Kind)
35
+ }
36
+ if e.Nick != "agent-01" {
37
+ t.Errorf("Nick: got %q", e.Nick)
38
+ }
39
+ if e.MessageType != "agent.registered" {
40
+ t.Errorf("MessageType: got %q", e.MessageType)
41
+ }
42
+ if e.Detail != "new registration" {
43
+ t.Errorf("Detail: got %q", e.Detail)
44
+ }
45
+ _ = s
46
+}
47
+
48
+func TestRecordMultipleRegistryEvents(t *testing.T) {
49
+ b, s := newBot()
50
+ b.Record("agent-01", "agent.registered", "")
51
+ b.Record("agent-01", "credentials.rotated", "")
52
+ b.Record("agent-02", "agent.revoked", "policy violation")
53
+
54
+ entries := s.All()
55
+ if len(entries) != 3 {
56
+ t.Fatalf("expected 3 entries, got %d", len(entries))
57
+ }
58
+}
59
+
60
+func TestStoreIsAppendOnly(t *testing.T) {
61
+ s := &auditbot.MemoryStore{}
62
+ s.Append(auditbot.Entry{Nick: "a", MessageType: "task.create"})
63
+ s.Append(auditbot.Entry{Nick: "b", MessageType: "task.complete"})
64
+
65
+ entries := s.All()
66
+ if len(entries) != 2 {
67
+ t.Fatalf("expected 2 entries, got %d", len(entries))
68
+ }
69
+ // Modifying the snapshot should not affect the store.
70
+ entries[0].Nick = "tampered"
71
+ fresh := s.All()
72
+ if fresh[0].Nick == "tampered" {
73
+ t.Error("store should be immutable — snapshot modification should not affect store")
74
+ }
75
+}
76
+
77
+func TestAuditTypeFilter(t *testing.T) {
78
+ // Only task.create should be audited.
79
+ b, s := newBot("task.create")
80
+ // Record two types — only the audited one should appear.
81
+ // We can't inject IRC events directly, but we can verify that Record()
82
+ // with any type always writes (registry events bypass the filter).
83
+ b.Record("agent-01", "task.create", "")
84
+ b.Record("agent-01", "task.update", "") // registry events always written
85
+
86
+ entries := s.All()
87
+ if len(entries) != 2 {
88
+ t.Fatalf("expected 2 entries from Record(), got %d", len(entries))
89
+ }
90
+}
91
+
92
+func TestAuditAllWhenNoFilter(t *testing.T) {
93
+ b, s := newBot() // no filter = audit everything
94
+ b.Record("a", "task.create", "")
95
+ b.Record("b", "task.update", "")
96
+ b.Record("c", "agent.hello", "")
97
+
98
+ if got := len(s.All()); got != 3 {
99
+ t.Errorf("expected 3 entries, got %d", got)
100
+ }
101
+}
102
+
103
+func TestEntryTimestamp(t *testing.T) {
104
+ b, s := newBot()
105
+ b.Record("agent-01", "agent.registered", "")
106
+
107
+ entries := s.All()
108
+ if entries[0].At.IsZero() {
109
+ t.Error("entry timestamp should not be zero")
110
+ }
111
+}
--- a/internal/bots/auditbot/auditbot_test.go
+++ b/internal/bots/auditbot/auditbot_test.go
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/auditbot/auditbot_test.go
+++ b/internal/bots/auditbot/auditbot_test.go
@@ -0,0 +1,111 @@
1 package auditbot_test
2
3 import (
4 "testing"
5
6 "github.com/conflicthq/scuttlebot/internal/bots/auditbot"
7 )
8
9 func newBot(auditTypes ...string) (*auditbot.Bot, *auditbot.MemoryStore) {
10 s := &auditbot.MemoryStore{}
11 b := auditbot.New("localhost:6667", "pass", []string{"#fleet"}, auditTypes, s, nil)
12 return b, s
13 }
14
15 func TestBotNameAndNew(t *testing.T) {
16 b, _ := newBot()
17 if b.Name() != "auditbot" {
18 t.Errorf("Name(): got %q, want auditbot", b.Name())
19 }
20 }
21
22 func TestRecordRegistryEvent(t *testing.T) {
23 _, s := newBot("agent.registered")
24 // newBot uses nil logger; Record directly writes to store.
25 b, s2 := newBot("agent.registered")
26 b.Record("agent-01", "agent.registered", "new registration")
27
28 entries := s2.All()
29 if len(entries) != 1 {
30 t.Fatalf("expected 1 entry, got %d", len(entries))
31 }
32 e := entries[0]
33 if e.Kind != auditbot.KindRegistry {
34 t.Errorf("Kind: got %q, want registry", e.Kind)
35 }
36 if e.Nick != "agent-01" {
37 t.Errorf("Nick: got %q", e.Nick)
38 }
39 if e.MessageType != "agent.registered" {
40 t.Errorf("MessageType: got %q", e.MessageType)
41 }
42 if e.Detail != "new registration" {
43 t.Errorf("Detail: got %q", e.Detail)
44 }
45 _ = s
46 }
47
48 func TestRecordMultipleRegistryEvents(t *testing.T) {
49 b, s := newBot()
50 b.Record("agent-01", "agent.registered", "")
51 b.Record("agent-01", "credentials.rotated", "")
52 b.Record("agent-02", "agent.revoked", "policy violation")
53
54 entries := s.All()
55 if len(entries) != 3 {
56 t.Fatalf("expected 3 entries, got %d", len(entries))
57 }
58 }
59
60 func TestStoreIsAppendOnly(t *testing.T) {
61 s := &auditbot.MemoryStore{}
62 s.Append(auditbot.Entry{Nick: "a", MessageType: "task.create"})
63 s.Append(auditbot.Entry{Nick: "b", MessageType: "task.complete"})
64
65 entries := s.All()
66 if len(entries) != 2 {
67 t.Fatalf("expected 2 entries, got %d", len(entries))
68 }
69 // Modifying the snapshot should not affect the store.
70 entries[0].Nick = "tampered"
71 fresh := s.All()
72 if fresh[0].Nick == "tampered" {
73 t.Error("store should be immutable — snapshot modification should not affect store")
74 }
75 }
76
77 func TestAuditTypeFilter(t *testing.T) {
78 // Only task.create should be audited.
79 b, s := newBot("task.create")
80 // Record two types — only the audited one should appear.
81 // We can't inject IRC events directly, but we can verify that Record()
82 // with any type always writes (registry events bypass the filter).
83 b.Record("agent-01", "task.create", "")
84 b.Record("agent-01", "task.update", "") // registry events always written
85
86 entries := s.All()
87 if len(entries) != 2 {
88 t.Fatalf("expected 2 entries from Record(), got %d", len(entries))
89 }
90 }
91
92 func TestAuditAllWhenNoFilter(t *testing.T) {
93 b, s := newBot() // no filter = audit everything
94 b.Record("a", "task.create", "")
95 b.Record("b", "task.update", "")
96 b.Record("c", "agent.hello", "")
97
98 if got := len(s.All()); got != 3 {
99 t.Errorf("expected 3 entries, got %d", got)
100 }
101 }
102
103 func TestEntryTimestamp(t *testing.T) {
104 b, s := newBot()
105 b.Record("agent-01", "agent.registered", "")
106
107 entries := s.All()
108 if entries[0].At.IsZero() {
109 t.Error("entry timestamp should not be zero")
110 }
111 }
--- a/internal/bots/auditbot/store.go
+++ b/internal/bots/auditbot/store.go
@@ -0,0 +1,25 @@
1
+package auditbot
2
+
3
+import "sync"
4
+
5
+// MemoryStore is an append-only in-memory Store for testing.
6
+type MemoryStore struct {
7
+ mu sync.Mutex
8
+ entries []Entry
9
+}
10
+
11
+func (s *MemoryStore) Append(e Entry) error {
12
+ s.mu.Lock()
13
+ defer s.mu.Unlock()
14
+ s.entries = append(s.entries, e)
15
+ return nil
16
+}
17
+
18
+// All returns a snapshot of all audit entries.
19
+func (s *MemoryStore) All() []Entry {
20
+ s.mu.Lock()
21
+ defer s.mu.Unlock()
22
+ out := make([]Entry, len(s.entries))
23
+ copy(out, s.entries)
24
+ return out
25
+}
--- a/internal/bots/auditbot/store.go
+++ b/internal/bots/auditbot/store.go
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/auditbot/store.go
+++ b/internal/bots/auditbot/store.go
@@ -0,0 +1,25 @@
1 package auditbot
2
3 import "sync"
4
5 // MemoryStore is an append-only in-memory Store for testing.
6 type MemoryStore struct {
7 mu sync.Mutex
8 entries []Entry
9 }
10
11 func (s *MemoryStore) Append(e Entry) error {
12 s.mu.Lock()
13 defer s.mu.Unlock()
14 s.entries = append(s.entries, e)
15 return nil
16 }
17
18 // All returns a snapshot of all audit entries.
19 func (s *MemoryStore) All() []Entry {
20 s.mu.Lock()
21 defer s.mu.Unlock()
22 out := make([]Entry, len(s.entries))
23 copy(out, s.entries)
24 return out
25 }
--- a/internal/bots/herald/herald.go
+++ b/internal/bots/herald/herald.go
@@ -0,0 +1,3 @@
1
+SSL:var host string
2
+ var port int
3
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);
--- a/internal/bots/herald/herald.go
+++ b/internal/bots/herald/herald.go
@@ -0,0 +1,3 @@
 
 
 
--- a/internal/bots/herald/herald.go
+++ b/internal/bots/herald/herald.go
@@ -0,0 +1,3 @@
1 SSL:var host string
2 var port int
3 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);
--- a/internal/bots/herald/herald_test.go
+++ b/internal/bots/herald/herald_test.go
@@ -0,0 +1,66 @@
1
+package herald_test
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/bots/herald"
8
+)
9
+
10
+func newBot(routes herald.RouteConfig) *herald.Bot {
11
+ return herald.New("localhost:6667", "pass", routes, 100, 100, nil)
12
+}
13
+
14
+func TestBotName(t *testing.T) {
15
+ b := newBot(herald.RouteConfig{})
16
+ if b.Name() != "herald" {
17
+ t.Errorf("Name(): got %q", b.Name())
18
+ }
19
+}
20
+
21
+func TestEmitNonBlocking(t *testing.T) {
22
+ b := newBot(herald.RouteConfig{DefaultChannel: "#fleet"})
23
+ // Fill queue past capacity — should not block.
24
+ for i := 0; i < 300; i++ {
25
+ b.Emit(herald.Event{Type: "ci.build", Message: "build done"})
26
+ }
27
+}
28
+
29
+func TestRateLimiterAllows(t *testing.T) {
30
+ // High rate + high burst: all should be allowed immediately.
31
+ b := newBot(herald.RouteConfig{DefaultChannel: "#fleet"})
32
+ // Emit() just queues; actual rate limiting happens in deliver().
33
+ // We test that Emit is non-blocking and the bot is constructible.
34
+ b.Emit(herald.Event{Type: "ci.build", Message: "ok"})
35
+}
36
+
37
+func TestRouteConfig(t *testing.T) {
38
+ // Verify routing logic by checking bot construction accepts route maps.
39
+ routes := herald.RouteConfig{
40
+ Routes: map[string]string{
41
+ "ci.": "#builds",
42
+ "ci.build.": "#builds",
43
+ "deploy.": "#deploys",
44
+ },
45
+ DefaultChannel: "#alerts",
46
+ }
47
+ b := herald.New("localhost:6667", "pass", alhost:6667", "pass", nil, routes, 5, 20, nil)
48
+ if b == nil {
49
+ t.Fatal("expected non-nil bot")
50
+ }
51
+}
52
+
53
+func TestEmitDropsWhenQueueFull(t *testing.T) {
54
+ b := newBot(herald.RouteConfig{})
55
+ // Emit 1000 events — excess should be dropped without panic or block.
56
+ done := make(chan struct{})
57
+ go func() {
58
+ for i := 0; i < 1000; i++ {
59
+ b.Emit(herald.Event{Type: "x", Message: "y"})
60
+ }
61
+ close(done)
62
+ }()
63
+ select {
64
+ case <-done:
65
+ case <-time.After(2 * time.Second):
66
+ t.Error("Emit bl
--- a/internal/bots/herald/herald_test.go
+++ b/internal/bots/herald/herald_test.go
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/herald/herald_test.go
+++ b/internal/bots/herald/herald_test.go
@@ -0,0 +1,66 @@
1 package herald_test
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/conflicthq/scuttlebot/internal/bots/herald"
8 )
9
10 func newBot(routes herald.RouteConfig) *herald.Bot {
11 return herald.New("localhost:6667", "pass", routes, 100, 100, nil)
12 }
13
14 func TestBotName(t *testing.T) {
15 b := newBot(herald.RouteConfig{})
16 if b.Name() != "herald" {
17 t.Errorf("Name(): got %q", b.Name())
18 }
19 }
20
21 func TestEmitNonBlocking(t *testing.T) {
22 b := newBot(herald.RouteConfig{DefaultChannel: "#fleet"})
23 // Fill queue past capacity — should not block.
24 for i := 0; i < 300; i++ {
25 b.Emit(herald.Event{Type: "ci.build", Message: "build done"})
26 }
27 }
28
29 func TestRateLimiterAllows(t *testing.T) {
30 // High rate + high burst: all should be allowed immediately.
31 b := newBot(herald.RouteConfig{DefaultChannel: "#fleet"})
32 // Emit() just queues; actual rate limiting happens in deliver().
33 // We test that Emit is non-blocking and the bot is constructible.
34 b.Emit(herald.Event{Type: "ci.build", Message: "ok"})
35 }
36
37 func TestRouteConfig(t *testing.T) {
38 // Verify routing logic by checking bot construction accepts route maps.
39 routes := herald.RouteConfig{
40 Routes: map[string]string{
41 "ci.": "#builds",
42 "ci.build.": "#builds",
43 "deploy.": "#deploys",
44 },
45 DefaultChannel: "#alerts",
46 }
47 b := herald.New("localhost:6667", "pass", alhost:6667", "pass", nil, routes, 5, 20, nil)
48 if b == nil {
49 t.Fatal("expected non-nil bot")
50 }
51 }
52
53 func TestEmitDropsWhenQueueFull(t *testing.T) {
54 b := newBot(herald.RouteConfig{})
55 // Emit 1000 events — excess should be dropped without panic or block.
56 done := make(chan struct{})
57 go func() {
58 for i := 0; i < 1000; i++ {
59 b.Emit(herald.Event{Type: "x", Message: "y"})
60 }
61 close(done)
62 }()
63 select {
64 case <-done:
65 case <-time.After(2 * time.Second):
66 t.Error("Emit bl
--- a/internal/bots/systembot/store.go
+++ b/internal/bots/systembot/store.go
@@ -0,0 +1,25 @@
1
+package systembot
2
+
3
+import "sync"
4
+
5
+// MemoryStore is an in-memory Store implementation for testing.
6
+type MemoryStore struct {
7
+ mu sync.Mutex
8
+ entries []Entry
9
+}
10
+
11
+func (s *MemoryStore) Append(e Entry) error {
12
+ s.mu.Lock()
13
+ defer s.mu.Unlock()
14
+ s.entries = append(s.entries, e)
15
+ return nil
16
+}
17
+
18
+// All returns a snapshot of all entries.
19
+func (s *MemoryStore) All() []Entry {
20
+ s.mu.Lock()
21
+ defer s.mu.Unlock()
22
+ out := make([]Entry, len(s.entries))
23
+ copy(out, s.entries)
24
+ return out
25
+}
--- a/internal/bots/systembot/store.go
+++ b/internal/bots/systembot/store.go
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/systembot/store.go
+++ b/internal/bots/systembot/store.go
@@ -0,0 +1,25 @@
1 package systembot
2
3 import "sync"
4
5 // MemoryStore is an in-memory Store implementation for testing.
6 type MemoryStore struct {
7 mu sync.Mutex
8 entries []Entry
9 }
10
11 func (s *MemoryStore) Append(e Entry) error {
12 s.mu.Lock()
13 defer s.mu.Unlock()
14 s.entries = append(s.entries, e)
15 return nil
16 }
17
18 // All returns a snapshot of all entries.
19 func (s *MemoryStore) All() []Entry {
20 s.mu.Lock()
21 defer s.mu.Unlock()
22 out := make([]Entry, len(s.entries))
23 copy(out, s.entries)
24 return out
25 }
--- a/internal/bots/systembot/systembot.go
+++ b/internal/bots/systembot/systembot.go
@@ -0,0 +1,70 @@
1
+// Package systembot implements the systembot — IRC system event logger.
2
+//
3
+// systembot is the complement to scribe: where scribe owns the agent message
4
+// stream (PRIVMSG), systembot owns the system stream:
5
+// - NOTICE messages (server announcements, NickServ/ChanServ responses)
6
+// - Connection events: JOIN, PART, QUIT, KICK
7
+// - Mode changes: MODE
8
+//
9
+// Every event is written to a Store as a SystemEntry.
10
+package systembot
11
+
12
+import (
13
+ "context"
14
+ "fmt"
15
+ "log/slog"
16
+ "/slog"
17
+ "net"
18
+ "strconv"
19
+ "strings"
20
+ "time"
21
+
22
+ "github.com/lrstanley/girc"
23
+)
24
+
25
+const botNick = "systembot"
26
+
27
+// EntryKind classifies a system event.
28
+type EntryKind string
29
+
30
+constkick"
31
+ KindMode EntryKind = "mode"
32
+)
33
+
34
+// Entry is a single system event log record.
35
+type Entry struct {
36
+ At time.Time
37
+ Kind EntryKind
38
+ Channel string // empty for server-level events (QUIT, server NOTICE)
39
+ Nick string // who triggered the event; empty for server events
40
+ Text string // message text, mode string, kick reason, etc.
41
+}
42
+
43
+// Store is where system entries are written.
44
+type Store interface {
45
+ Append(Entry) error
46
+}
47
+
48
+// Bot is the systembot.
49
+type Bot struct {
50
+ ircAddr string
51
+ password string
52
+ channels []string
53
+ store Store
54
+ log *slog.Logger
55
+ client *girc.Client
56
+}
57
+
58
+// New creates a systembot.
59
+func New(ircAddr, password string, channels []string, store Store, log *slog.Logger) *Bot {
60
+ return &Bot{
61
+ ircAddr: ircAddr,
62
+ password: password,
63
+ channels: channels,
64
+ store: store,
65
+ log: log,
66
+ }
67
+}
68
+
69
+// Name returns the bot's IRC nick.
70
+func (b *Bot) Name() st
--- a/internal/bots/systembot/systembot.go
+++ b/internal/bots/systembot/systembot.go
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/systembot/systembot.go
+++ b/internal/bots/systembot/systembot.go
@@ -0,0 +1,70 @@
1 // Package systembot implements the systembot — IRC system event logger.
2 //
3 // systembot is the complement to scribe: where scribe owns the agent message
4 // stream (PRIVMSG), systembot owns the system stream:
5 // - NOTICE messages (server announcements, NickServ/ChanServ responses)
6 // - Connection events: JOIN, PART, QUIT, KICK
7 // - Mode changes: MODE
8 //
9 // Every event is written to a Store as a SystemEntry.
10 package systembot
11
12 import (
13 "context"
14 "fmt"
15 "log/slog"
16 "/slog"
17 "net"
18 "strconv"
19 "strings"
20 "time"
21
22 "github.com/lrstanley/girc"
23 )
24
25 const botNick = "systembot"
26
27 // EntryKind classifies a system event.
28 type EntryKind string
29
30 constkick"
31 KindMode EntryKind = "mode"
32 )
33
34 // Entry is a single system event log record.
35 type Entry struct {
36 At time.Time
37 Kind EntryKind
38 Channel string // empty for server-level events (QUIT, server NOTICE)
39 Nick string // who triggered the event; empty for server events
40 Text string // message text, mode string, kick reason, etc.
41 }
42
43 // Store is where system entries are written.
44 type Store interface {
45 Append(Entry) error
46 }
47
48 // Bot is the systembot.
49 type Bot struct {
50 ircAddr string
51 password string
52 channels []string
53 store Store
54 log *slog.Logger
55 client *girc.Client
56 }
57
58 // New creates a systembot.
59 func New(ircAddr, password string, channels []string, store Store, log *slog.Logger) *Bot {
60 return &Bot{
61 ircAddr: ircAddr,
62 password: password,
63 channels: channels,
64 store: store,
65 log: log,
66 }
67 }
68
69 // Name returns the bot's IRC nick.
70 func (b *Bot) Name() st
--- a/internal/bots/systembot/systembot_test.go
+++ b/internal/bots/systembot/systembot_test.go
@@ -0,0 +1,71 @@
1
+package systembot_test
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/bots/systembot"
8
+)
9
+
10
+func TestMemoryStoreAppendAndAll(t *testing.T) {
11
+ s := &systembot.MemoryStore{}
12
+ s.Append(systembot.Entry{Kind: systembot.KindNotice, Nick: "NickServ", Text: "Password accepted"})
13
+ s.Append(systembot.Entry{Kind: systembot.KindJoin, Channel: "#fleet", Nick: "agent-01"})
14
+
15
+ entries := s.All()
16
+ if len(entries) != 2 {
17
+ t.Fatalf("expected 2 entries, got %d", len(entries))
18
+ }
19
+ if entries[0].Kind != systembot.KindNotice {
20
+ t.Errorf("entry 0 kind: got %q, want %q", entries[0].Kind, systembot.KindNotice)
21
+ }
22
+ if entries[1].Kind != systembot.KindJoin {
23
+ t.Errorf("entry 1 kind: got %q, want %q", entries[1].Kind, systembot.KindJoin)
24
+ }
25
+}
26
+
27
+func TestEntryKinds(t *testing.T) {
28
+ kinds := []systembot.EntryKind{
29
+ systembot.KindNotice,
30
+ systembot.KindJoin,
31
+ systembot.KindPart,
32
+ systembot.KindQuit,
33
+ systembot.KindKick,
34
+ systembot.KindMode,
35
+ }
36
+ s := &systembot.MemoryStore{}
37
+ for _, k := range kinds {
38
+ if err := s.Append(systembot.Entry{Kind: k, At: time.Now()}); err != nil {
39
+ t.Errorf("Append %q: %v", k, err)
40
+ }
41
+ }
42
+ if got := len(s.All()); got != len(kinds) {
43
+ t.Errorf("expected %d entries, got %d", len(kinds), got)
44
+ }
45
+}
46
+
47
+func TestBotNameAndNew(t *testing.T) {
48
+ b := systembot.New("localhost:6667", "pass", []string{"#fleet"}, &systembot.MemoryStore{}, nil)
49
+ if b == nil {
50
+ t.Fatal("expected non-nil bot")
51
+ }
52
+ if b.Name() != "systembot" {
53
+ t.Errorf("Name(): got %q, want systembot", b.Name())
54
+ }
55
+}
56
+
57
+func TestPrivmsgIsNotLogged(t *testing.T) {
58
+ // systembot does not have a PRIVMSG handler — this is a design invariant.
59
+ // Verify that NOTICE and connection events ARE the only logged kinds.
60
+ // (The bot itself doesn't expose a direct way to inject events — this is
61
+ // a documentation test confirming the design intent.)
62
+ _ = []systembot.EntryKind{
63
+ systembot.KindNotice,
64
+ systembot.KindJoin,
65
+ systembot.KindPart,
66
+ systembot.KindQuit,
67
+ systembot.KindKick,
68
+ systembot.KindMode,
69
+ }
70
+ // PRIVMSG is NOT in the list — systembot should never log agent message stream events.
71
+}
--- a/internal/bots/systembot/systembot_test.go
+++ b/internal/bots/systembot/systembot_test.go
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/systembot/systembot_test.go
+++ b/internal/bots/systembot/systembot_test.go
@@ -0,0 +1,71 @@
1 package systembot_test
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/conflicthq/scuttlebot/internal/bots/systembot"
8 )
9
10 func TestMemoryStoreAppendAndAll(t *testing.T) {
11 s := &systembot.MemoryStore{}
12 s.Append(systembot.Entry{Kind: systembot.KindNotice, Nick: "NickServ", Text: "Password accepted"})
13 s.Append(systembot.Entry{Kind: systembot.KindJoin, Channel: "#fleet", Nick: "agent-01"})
14
15 entries := s.All()
16 if len(entries) != 2 {
17 t.Fatalf("expected 2 entries, got %d", len(entries))
18 }
19 if entries[0].Kind != systembot.KindNotice {
20 t.Errorf("entry 0 kind: got %q, want %q", entries[0].Kind, systembot.KindNotice)
21 }
22 if entries[1].Kind != systembot.KindJoin {
23 t.Errorf("entry 1 kind: got %q, want %q", entries[1].Kind, systembot.KindJoin)
24 }
25 }
26
27 func TestEntryKinds(t *testing.T) {
28 kinds := []systembot.EntryKind{
29 systembot.KindNotice,
30 systembot.KindJoin,
31 systembot.KindPart,
32 systembot.KindQuit,
33 systembot.KindKick,
34 systembot.KindMode,
35 }
36 s := &systembot.MemoryStore{}
37 for _, k := range kinds {
38 if err := s.Append(systembot.Entry{Kind: k, At: time.Now()}); err != nil {
39 t.Errorf("Append %q: %v", k, err)
40 }
41 }
42 if got := len(s.All()); got != len(kinds) {
43 t.Errorf("expected %d entries, got %d", len(kinds), got)
44 }
45 }
46
47 func TestBotNameAndNew(t *testing.T) {
48 b := systembot.New("localhost:6667", "pass", []string{"#fleet"}, &systembot.MemoryStore{}, nil)
49 if b == nil {
50 t.Fatal("expected non-nil bot")
51 }
52 if b.Name() != "systembot" {
53 t.Errorf("Name(): got %q, want systembot", b.Name())
54 }
55 }
56
57 func TestPrivmsgIsNotLogged(t *testing.T) {
58 // systembot does not have a PRIVMSG handler — this is a design invariant.
59 // Verify that NOTICE and connection events ARE the only logged kinds.
60 // (The bot itself doesn't expose a direct way to inject events — this is
61 // a documentation test confirming the design intent.)
62 _ = []systembot.EntryKind{
63 systembot.KindNotice,
64 systembot.KindJoin,
65 systembot.KindPart,
66 systembot.KindQuit,
67 systembot.KindKick,
68 systembot.KindMode,
69 }
70 // PRIVMSG is NOT in the list — systembot should never log agent message stream events.
71 }
--- a/internal/bots/warden/warden.go
+++ b/internal/bots/warden/warden.go
@@ -0,0 +1,105 @@
1
+// Package warden implements the warden bot — channel moderation and rate limiting.
2
+//
3
+// warden monitors channels for misbehaving agents:
4
+// - Malformed message envelopes → NOTICE to sender
5
+// - Excessive message rates → warn (NOTICE), then mute (+q), then kick
6
+//
7
+// Actions escalate: first violation warns, second mutes, third kicks.
8
+// Escalation state resets after a configurable cool-down period.
9
+package warden
10
+
11
+import (
12
+ "context"
13
+ "fmt"
14
+ "log/slog"
15
+ "/slog"
16
+ "net"
17
+ "strconv"
18
+ "strings"
19
+ "sync"
20
+ "time"
21
+
22
+ "github.com/lrstanley/girc"
23
+
24
+ "github.com/conflicthq/scuttlebot/.com/conflicthq/scuttlebot/pkg/protocol"
25
+)
26
+
27
+const botNick = "warden"
28
+
29
+// Action is an enforcement action taken against a nick.
30
+type Action string
31
+
32
+const (
33
+ ActionWarn Action = "warn"
34
+ ActionMute Action = "mute"
35
+ ActionKick Action = "kick"
36
+)
37
+
38
+// ChannelConfig configures warden's limits for a single channel.
39
+type ChannelConfig struct {
40
+ // MessagesPerSecond is the max sustained rate. Default: 5.
41
+ MessagesPerSecond float64
42
+
43
+ // Burst is the max burst above the rate. Default: 10.
44
+ Burst int
45
+
46
+ // CoolDown is how long before escalation state resets. Default: 60s.
47
+ CoolDown time.Duration
48
+}
49
+
50
+func (c *ChannelConfig) defaults() {
51
+ if c.MessagesPerSecond <= 0 {
52
+ c.MessagesPerSecond = 5
53
+ }
54
+ if c.Burst <= 0 {
55
+ c.Burst = 10
56
+ }
57
+ if c.CoolDown <= 0 {
58
+ c.CoolDown = 60 * time.Second
59
+ }
60
+}
61
+
62
+// nickState tracks per-nick rate limiting and escalation within a cha violations
63
+ nick string
64
+ text string
65
+}
66
+
67
+// channelState holds per-channel warden state.
68
+type chsync.Mutex
69
+ cfg ChannelConfig
70
+ nic ChannelConfig
71
+ nicks e(cfg ChannelConfig) *channelState {
72
+ cfg.defaults()
73
+ return &channelState{cfg: cfg, nicks: make(map[string]*nickState)}
74
+}
75
+
76
+// consume attempts to consume one token for nick. Returns true if allowed;
77
+// false if rate-limited.
78
+func (cs *channelState) consume(nick string) bool {
79
+ cs.mu.Lock()
80
+ defer cs.mu.Unlock()
81
+
82
+ ns, ok := cs.nicks[nick]
83
+ if !ok {
84
+ ns = &nickState{
85
+ tokens: float64(type botOptiont64(cs.cfg.Burst),
86
+ lastRefill: time.Now(),
87
+ }
88
+ cs.nicks[nick] = ns
89
+ }
90
+
91
+ // Refill tokens based on elapsed time.
92
+ now := time.Now()
93
+ elapsed :=ll = now
94
+ ns.tokens = minF(float64(cs.cfg.Burst), ns.tokens+elapsed*cs.cfg.MessagesPerSecond)
95
+
96
+ if ns.tokens >= 1 {
97
+ ns.tokens--
98
+ return true
99
+ }
100
+ return fal// Join all configured channels.)
101
+ }host,
102
+ Port: port, botNick,
103
+ User:M@190,lc@19R,1i5kj6;SSL:var host string
104
+ var port int
105
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);
--- a/internal/bots/warden/warden.go
+++ b/internal/bots/warden/warden.go
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/warden/warden.go
+++ b/internal/bots/warden/warden.go
@@ -0,0 +1,105 @@
1 // Package warden implements the warden bot — channel moderation and rate limiting.
2 //
3 // warden monitors channels for misbehaving agents:
4 // - Malformed message envelopes → NOTICE to sender
5 // - Excessive message rates → warn (NOTICE), then mute (+q), then kick
6 //
7 // Actions escalate: first violation warns, second mutes, third kicks.
8 // Escalation state resets after a configurable cool-down period.
9 package warden
10
11 import (
12 "context"
13 "fmt"
14 "log/slog"
15 "/slog"
16 "net"
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21
22 "github.com/lrstanley/girc"
23
24 "github.com/conflicthq/scuttlebot/.com/conflicthq/scuttlebot/pkg/protocol"
25 )
26
27 const botNick = "warden"
28
29 // Action is an enforcement action taken against a nick.
30 type Action string
31
32 const (
33 ActionWarn Action = "warn"
34 ActionMute Action = "mute"
35 ActionKick Action = "kick"
36 )
37
38 // ChannelConfig configures warden's limits for a single channel.
39 type ChannelConfig struct {
40 // MessagesPerSecond is the max sustained rate. Default: 5.
41 MessagesPerSecond float64
42
43 // Burst is the max burst above the rate. Default: 10.
44 Burst int
45
46 // CoolDown is how long before escalation state resets. Default: 60s.
47 CoolDown time.Duration
48 }
49
50 func (c *ChannelConfig) defaults() {
51 if c.MessagesPerSecond <= 0 {
52 c.MessagesPerSecond = 5
53 }
54 if c.Burst <= 0 {
55 c.Burst = 10
56 }
57 if c.CoolDown <= 0 {
58 c.CoolDown = 60 * time.Second
59 }
60 }
61
62 // nickState tracks per-nick rate limiting and escalation within a cha violations
63 nick string
64 text string
65 }
66
67 // channelState holds per-channel warden state.
68 type chsync.Mutex
69 cfg ChannelConfig
70 nic ChannelConfig
71 nicks e(cfg ChannelConfig) *channelState {
72 cfg.defaults()
73 return &channelState{cfg: cfg, nicks: make(map[string]*nickState)}
74 }
75
76 // consume attempts to consume one token for nick. Returns true if allowed;
77 // false if rate-limited.
78 func (cs *channelState) consume(nick string) bool {
79 cs.mu.Lock()
80 defer cs.mu.Unlock()
81
82 ns, ok := cs.nicks[nick]
83 if !ok {
84 ns = &nickState{
85 tokens: float64(type botOptiont64(cs.cfg.Burst),
86 lastRefill: time.Now(),
87 }
88 cs.nicks[nick] = ns
89 }
90
91 // Refill tokens based on elapsed time.
92 now := time.Now()
93 elapsed :=ll = now
94 ns.tokens = minF(float64(cs.cfg.Burst), ns.tokens+elapsed*cs.cfg.MessagesPerSecond)
95
96 if ns.tokens >= 1 {
97 ns.tokens--
98 return true
99 }
100 return fal// Join all configured channels.)
101 }host,
102 Port: port, botNick,
103 User:M@190,lc@19R,1i5kj6;SSL:var host string
104 var port int
105 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);
--- a/internal/bots/warden/warden_test.go
+++ b/internal/bots/warden/warden_test.go
@@ -0,0 +1,55 @@
1
+package warden_test
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/bots/warden"
8
+)
9
+
10
+func newBot() *warden.Bot {
11
+ return warden.Necalhost:6667", "pass", nil,
12
+ map[s
13
+ "#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second},
14
+ },
15
+ warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5},
16
+ nil,
17
+ )
18
+}
19
+
20
+func TestBotName(t *testing.T) {
21
+ b := newBot()
22
+ if b.Name() != "warden" {
23
+ t.Errorf("Name(): got %q", b.Name())
24
+ }
25
+}
26
+
27
+func TestBotNew(t *testing.T) {
28
+ b := newBot()
29
+ if b == nil {
30
+ t.Fatal("expected non-nil bot")
31
+ }
32
+}
33
+
34
+func TestChannelConfigDefaults(t *testing.T) {
35
+ // Zero-value config should get sane defaults applied.
36
+ b := warden.New("localhost:6667", "pass",calhost:6667", "pass", nil,
37
+ map[string]warden.ChannelConfig{"#fleet": cfg},
38
+ warden.ChannelConfig{},
39
+ nil,
40
+ )
41
+ if b == nil {
42
+ t.Fatal("expected non-nil bot")
43
+ }
44
+}
45
+
46
+func TestActionConstants(t *testing.T) {
47
+ // Ensure action constants are stable.
48
+ actions := map[warden.Action]string{
49
+ warden.ActionWarn: "warn",
50
+ warden.ActionMute: "mute",
51
+ warden.ActionKick: "kick",
52
+ }
53
+ for action, want := range actions {
54
+ if string(action) != want {
55
+ t.Errorf("Acti
--- a/internal/bots/warden/warden_test.go
+++ b/internal/bots/warden/warden_test.go
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/warden/warden_test.go
+++ b/internal/bots/warden/warden_test.go
@@ -0,0 +1,55 @@
1 package warden_test
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/conflicthq/scuttlebot/internal/bots/warden"
8 )
9
10 func newBot() *warden.Bot {
11 return warden.Necalhost:6667", "pass", nil,
12 map[s
13 "#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second},
14 },
15 warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5},
16 nil,
17 )
18 }
19
20 func TestBotName(t *testing.T) {
21 b := newBot()
22 if b.Name() != "warden" {
23 t.Errorf("Name(): got %q", b.Name())
24 }
25 }
26
27 func TestBotNew(t *testing.T) {
28 b := newBot()
29 if b == nil {
30 t.Fatal("expected non-nil bot")
31 }
32 }
33
34 func TestChannelConfigDefaults(t *testing.T) {
35 // Zero-value config should get sane defaults applied.
36 b := warden.New("localhost:6667", "pass",calhost:6667", "pass", nil,
37 map[string]warden.ChannelConfig{"#fleet": cfg},
38 warden.ChannelConfig{},
39 nil,
40 )
41 if b == nil {
42 t.Fatal("expected non-nil bot")
43 }
44 }
45
46 func TestActionConstants(t *testing.T) {
47 // Ensure action constants are stable.
48 actions := map[warden.Action]string{
49 warden.ActionWarn: "warn",
50 warden.ActionMute: "mute",
51 warden.ActionKick: "kick",
52 }
53 for action, want := range actions {
54 if string(action) != want {
55 t.Errorf("Acti

Keyboard Shortcuts

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