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