ScuttleBot

feat: Bot interface and scribe bot (structured logging)

lmata 2026-03-31 04:59 trunk
Commit c12ba929c633a9780cc7453427160e00952cec2236248cdd93f304ca1dc3e9ee
--- internal/bots/bots.go
+++ internal/bots/bots.go
@@ -1,1 +1,17 @@
1
+// Package bots defines the Bot interface and shared types for all scuttlebot built-in bots.
12
package bots
3
+
4
+import "context"
5
+
6
+// Bot is the interface implemented by all scuttlebot built-in bots.
7
+type Bot interface {
8
+ // Name returns the bot's IRC nick.
9
+ Name() string
10
+
11
+ // Start connects the bot to IRC and begins processing messages.
12
+ // Blocks until ctx is cancelled or a fatal error occurs.
13
+ Start(ctx context.Context) error
14
+
15
+ // Stop gracefully disconnects the bot.
16
+ Stop()
17
+}
218
319
ADDED internal/bots/scribe/scribe.go
420
ADDED internal/bots/scribe/scribe_test.go
521
ADDED internal/bots/scribe/store.go
--- internal/bots/bots.go
+++ internal/bots/bots.go
@@ -1,1 +1,17 @@
 
1 package bots
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
3 DDED internal/bots/scribe/scribe.go
4 DDED internal/bots/scribe/scribe_test.go
5 DDED internal/bots/scribe/store.go
--- internal/bots/bots.go
+++ internal/bots/bots.go
@@ -1,1 +1,17 @@
1 // Package bots defines the Bot interface and shared types for all scuttlebot built-in bots.
2 package bots
3
4 import "context"
5
6 // Bot is the interface implemented by all scuttlebot built-in bots.
7 type Bot interface {
8 // Name returns the bot's IRC nick.
9 Name() string
10
11 // Start connects the bot to IRC and begins processing messages.
12 // Blocks until ctx is cancelled or a fatal error occurs.
13 Start(ctx context.Context) error
14
15 // Stop gracefully disconnects the bot.
16 Stop()
17 }
18
19 DDED internal/bots/scribe/scribe.go
20 DDED internal/bots/scribe/scribe_test.go
21 DDED internal/bots/scribe/store.go
--- a/internal/bots/scribe/scribe.go
+++ b/internal/bots/scribe/scribe.go
@@ -0,0 +1,18 @@
1
+// Package scribe implements the scribe bot — structured logging for all channel activity.
2
+//
3
+// scribe joins all configured channels, listens for PRIVMSG, and writes
4
+// structured log entries to a Store. Valid JSON envelopes are logged with
5
+// their parsed type and ID. Malformed messages are logged as raw entries
6
+// without crashing. NOTICE messages are ignored (system/human commentary only).
7
+package scribe
8
+
9
+import (
10
+ "context"
11
+ "fmt"
12
+ "log/slog"
13
+ "var host string
14
+ var port int
15
+ if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);e bot — structured logging for all channel activity.
16
+//
17
+// scribe joins all confreturn host, port, nil
18
+}
--- a/internal/bots/scribe/scribe.go
+++ b/internal/bots/scribe/scribe.go
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/scribe/scribe.go
+++ b/internal/bots/scribe/scribe.go
@@ -0,0 +1,18 @@
1 // Package scribe implements the scribe bot — structured logging for all channel activity.
2 //
3 // scribe joins all configured channels, listens for PRIVMSG, and writes
4 // structured log entries to a Store. Valid JSON envelopes are logged with
5 // their parsed type and ID. Malformed messages are logged as raw entries
6 // without crashing. NOTICE messages are ignored (system/human commentary only).
7 package scribe
8
9 import (
10 "context"
11 "fmt"
12 "log/slog"
13 "var host string
14 var port int
15 if _, err := fmt.Sscanf(addr, "%[^:]:%d", &host, &port);e bot — structured logging for all channel activity.
16 //
17 // scribe joins all confreturn host, port, nil
18 }
--- a/internal/bots/scribe/scribe_test.go
+++ b/internal/bots/scribe/scribe_test.go
@@ -0,0 +1,159 @@
1
+package scribe_test
2
+
3
+import (
4
+ "encoding/json"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/bots/scribe"
9
+ "github.com/conflicthq/scuttlebot/pkg/protocol"
10
+)
11
+
12
+func validEnvelopeJSON(t *testing.T, msgType, from string) string {
13
+ t.Helper()
14
+ env, err := protocol.New(msgType, from, map[string]string{"key": "val"})
15
+ if err != nil {
16
+ t.Fatalf("protocol.New: %v", err)
17
+ }
18
+ b, err := protocol.Marshal(env)
19
+ if err != nil {
20
+ t.Fatalf("protocol.Marshal: %v", err)
21
+ }
22
+ return string(b)
23
+}
24
+
25
+func TestStoreAppendAndQuery(t *testing.T) {
26
+ s := &scribe.MemoryStore{}
27
+
28
+ entries := []scribe.Entry{
29
+ {At: time.Now(), Channel: "#fleet", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "hello"},
30
+ {At: time.Now(), Channel: "#fleet", Nick: "gemini-01", Kind: scribe.EntryKindRaw, Raw: "world"},
31
+ {At: time.Now(), Channel: "#project.test", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "other channel"},
32
+ }
33
+ for _, e := range entries {
34
+ if err := s.Append(e); err != nil {
35
+ t.Fatalf("Append: %v", err)
36
+ }
37
+ }
38
+
39
+ fleet, err := s.Query("#fleet", 0)
40
+ if err != nil {
41
+ t.Fatalf("Query: %v", err)
42
+ }
43
+ if len(fleet) != 2 {
44
+ t.Errorf("Query #fleet: got %d entries, want 2", len(fleet))
45
+ }
46
+
47
+ all, err := s.Query("", 0)
48
+ if err != nil {
49
+ t.Fatalf("Query all: %v", err)
50
+ }
51
+ if len(all) != 3 {
52
+ t.Errorf("Query all: got %d entries, want 3", len(all))
53
+ }
54
+}
55
+
56
+func TestStoreQueryLimit(t *testing.T) {
57
+ s := &scribe.MemoryStore{}
58
+ for i := 0; i < 10; i++ {
59
+ _ = s.Append(scribe.Entry{Channel: "#fleet", Nick: "agent", Kind: scribe.EntryKindRaw})
60
+ }
61
+
62
+ got, err := s.Query("#fleet", 3)
63
+ if err != nil {
64
+ t.Fatalf("Query: %v", err)
65
+ }
66
+ if len(got) != 3 {
67
+ t.Errorf("Query with limit=3: got %d entries", len(got))
68
+ }
69
+}
70
+
71
+func TestEntryKindFromEnvelope(t *testing.T) {
72
+ // Test that a valid envelope JSON is detected as EntryKindEnvelope.
73
+ raw := validEnvelopeJSON(t, protocol.TypeTaskCreate, "claude-01")
74
+
75
+ env, err := protocol.Unmarshal([]byte(raw))
76
+ if err != nil {
77
+ t.Fatalf("Unmarshal: %v", err)
78
+ }
79
+
80
+ entry := scribe.Entry{
81
+ At: time.Now(),
82
+ Channel: "#fleet",
83
+ Nick: "claude-01",
84
+ Kind: scribe.EntryKindEnvelope,
85
+ MessageType: env.Type,
86
+ MessageID: env.ID,
87
+ Raw: raw,
88
+ }
89
+
90
+ if entry.MessageType != protocol.TypeTaskCreate {
91
+ t.Errorf("MessageType: got %q, want %q", entry.MessageType, protocol.TypeTaskCreate)
92
+ }
93
+ if entry.MessageID == "" {
94
+ t.Error("MessageID is empty")
95
+ }
96
+ if entry.Kind != scribe.EntryKindEnvelope {
97
+ t.Errorf("Kind: got %q, want %q", entry.Kind, scribe.EntryKindEnvelope)
98
+ }
99
+}
100
+
101
+func TestEntryKindRawForMalformed(t *testing.T) {
102
+ // Non-JSON and invalid envelopes should produce EntryKindRaw entries.
103
+ cases := []string{
104
+ "hello from a human",
105
+ "not json at all",
106
+ `{"incomplete": true}`, // valid JSON but not a valid envelope
107
+ }
108
+
109
+ for _, raw := range cases {
110
+ _, err := protocol.Unmarshal([]byte(raw))
111
+ if err == nil {
112
+ // Valid envelope — skip (this case tests malformed only)
113
+ continue
114
+ }
115
+ entry := scribe.Entry{
116
+ At: time.Now(),
117
+ Channel: "#fleet",
118
+ Nick: "agent",
119
+ Kind: scribe.EntryKindRaw,
120
+ Raw: raw,
121
+ }
122
+ if entry.Kind != scribe.EntryKindRaw {
123
+ t.Errorf("expected EntryKindRaw for %q", raw)
124
+ }
125
+ if entry.MessageType != "" {
126
+ t.Errorf("MessageType should be empty for raw entry")
127
+ }
128
+ }
129
+}
130
+
131
+func TestEntryJSONRoundTrip(t *testing.T) {
132
+ entry := scribe.Entry{
133
+ At: time.Now().Truncate(time.Millisecond),
134
+ Channel: "#project.test",
135
+ Nick: "claude-01",
136
+ Kind: scribe.EntryKindEnvelope,
137
+ MessageType: protocol.TypeAgentHello,
138
+ MessageID: "01HX123",
139
+ Raw: `{"v":1}`,
140
+ }
141
+
142
+ b, err := json.Marshal(entry)
143
+ if err != nil {
144
+ t.Fatalf("Marshal: %v", err)
145
+ }
146
+
147
+ var got scribe.Entry
148
+ if err := json.Unmarshal(b, &got); err != nil {
149
+ t.Fatalf("Unmarshal: %v", err)
150
+ }
151
+
152
+ if got.Channel != entry.Channel {
153
+ t.Errorf("Channel: got %q, want %q", got.Channel, entry.Channel)
154
+ }
155
+ if got.Kind != entry.Kind {
156
+ t.Errorf("Kind: got %q, want %q", got.Kind, entry.Kind)
157
+ }
158
+ if got.MessageType != entry.MessageType {
159
+ t.Errorf("MessageType: got %q, want %q", got.MessageTyp
--- a/internal/bots/scribe/scribe_test.go
+++ b/internal/bots/scribe/scribe_test.go
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/scribe/scribe_test.go
+++ b/internal/bots/scribe/scribe_test.go
@@ -0,0 +1,159 @@
1 package scribe_test
2
3 import (
4 "encoding/json"
5 "testing"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
9 "github.com/conflicthq/scuttlebot/pkg/protocol"
10 )
11
12 func validEnvelopeJSON(t *testing.T, msgType, from string) string {
13 t.Helper()
14 env, err := protocol.New(msgType, from, map[string]string{"key": "val"})
15 if err != nil {
16 t.Fatalf("protocol.New: %v", err)
17 }
18 b, err := protocol.Marshal(env)
19 if err != nil {
20 t.Fatalf("protocol.Marshal: %v", err)
21 }
22 return string(b)
23 }
24
25 func TestStoreAppendAndQuery(t *testing.T) {
26 s := &scribe.MemoryStore{}
27
28 entries := []scribe.Entry{
29 {At: time.Now(), Channel: "#fleet", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "hello"},
30 {At: time.Now(), Channel: "#fleet", Nick: "gemini-01", Kind: scribe.EntryKindRaw, Raw: "world"},
31 {At: time.Now(), Channel: "#project.test", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "other channel"},
32 }
33 for _, e := range entries {
34 if err := s.Append(e); err != nil {
35 t.Fatalf("Append: %v", err)
36 }
37 }
38
39 fleet, err := s.Query("#fleet", 0)
40 if err != nil {
41 t.Fatalf("Query: %v", err)
42 }
43 if len(fleet) != 2 {
44 t.Errorf("Query #fleet: got %d entries, want 2", len(fleet))
45 }
46
47 all, err := s.Query("", 0)
48 if err != nil {
49 t.Fatalf("Query all: %v", err)
50 }
51 if len(all) != 3 {
52 t.Errorf("Query all: got %d entries, want 3", len(all))
53 }
54 }
55
56 func TestStoreQueryLimit(t *testing.T) {
57 s := &scribe.MemoryStore{}
58 for i := 0; i < 10; i++ {
59 _ = s.Append(scribe.Entry{Channel: "#fleet", Nick: "agent", Kind: scribe.EntryKindRaw})
60 }
61
62 got, err := s.Query("#fleet", 3)
63 if err != nil {
64 t.Fatalf("Query: %v", err)
65 }
66 if len(got) != 3 {
67 t.Errorf("Query with limit=3: got %d entries", len(got))
68 }
69 }
70
71 func TestEntryKindFromEnvelope(t *testing.T) {
72 // Test that a valid envelope JSON is detected as EntryKindEnvelope.
73 raw := validEnvelopeJSON(t, protocol.TypeTaskCreate, "claude-01")
74
75 env, err := protocol.Unmarshal([]byte(raw))
76 if err != nil {
77 t.Fatalf("Unmarshal: %v", err)
78 }
79
80 entry := scribe.Entry{
81 At: time.Now(),
82 Channel: "#fleet",
83 Nick: "claude-01",
84 Kind: scribe.EntryKindEnvelope,
85 MessageType: env.Type,
86 MessageID: env.ID,
87 Raw: raw,
88 }
89
90 if entry.MessageType != protocol.TypeTaskCreate {
91 t.Errorf("MessageType: got %q, want %q", entry.MessageType, protocol.TypeTaskCreate)
92 }
93 if entry.MessageID == "" {
94 t.Error("MessageID is empty")
95 }
96 if entry.Kind != scribe.EntryKindEnvelope {
97 t.Errorf("Kind: got %q, want %q", entry.Kind, scribe.EntryKindEnvelope)
98 }
99 }
100
101 func TestEntryKindRawForMalformed(t *testing.T) {
102 // Non-JSON and invalid envelopes should produce EntryKindRaw entries.
103 cases := []string{
104 "hello from a human",
105 "not json at all",
106 `{"incomplete": true}`, // valid JSON but not a valid envelope
107 }
108
109 for _, raw := range cases {
110 _, err := protocol.Unmarshal([]byte(raw))
111 if err == nil {
112 // Valid envelope — skip (this case tests malformed only)
113 continue
114 }
115 entry := scribe.Entry{
116 At: time.Now(),
117 Channel: "#fleet",
118 Nick: "agent",
119 Kind: scribe.EntryKindRaw,
120 Raw: raw,
121 }
122 if entry.Kind != scribe.EntryKindRaw {
123 t.Errorf("expected EntryKindRaw for %q", raw)
124 }
125 if entry.MessageType != "" {
126 t.Errorf("MessageType should be empty for raw entry")
127 }
128 }
129 }
130
131 func TestEntryJSONRoundTrip(t *testing.T) {
132 entry := scribe.Entry{
133 At: time.Now().Truncate(time.Millisecond),
134 Channel: "#project.test",
135 Nick: "claude-01",
136 Kind: scribe.EntryKindEnvelope,
137 MessageType: protocol.TypeAgentHello,
138 MessageID: "01HX123",
139 Raw: `{"v":1}`,
140 }
141
142 b, err := json.Marshal(entry)
143 if err != nil {
144 t.Fatalf("Marshal: %v", err)
145 }
146
147 var got scribe.Entry
148 if err := json.Unmarshal(b, &got); err != nil {
149 t.Fatalf("Unmarshal: %v", err)
150 }
151
152 if got.Channel != entry.Channel {
153 t.Errorf("Channel: got %q, want %q", got.Channel, entry.Channel)
154 }
155 if got.Kind != entry.Kind {
156 t.Errorf("Kind: got %q, want %q", got.Kind, entry.Kind)
157 }
158 if got.MessageType != entry.MessageType {
159 t.Errorf("MessageType: got %q, want %q", got.MessageTyp
--- a/internal/bots/scribe/store.go
+++ b/internal/bots/scribe/store.go
@@ -0,0 +1,67 @@
1
+package scribe
2
+
3
+import (
4
+ "sync"
5
+ "time"
6
+)
7
+
8
+// EntryKind describes how a log entry was parsed.
9
+type EntryKind string
10
+
11
+const (
12
+ EntryKindEnvelope EntryKind = "envelope" // parsed as a valid JSON envelope
13
+ EntryKindRaw EntryKind = "raw" // could not be parsed, logged as-is
14
+)
15
+
16
+// Entry is a single structured log record written by scribe.
17
+type Entry struct {
18
+ At time.Time `json:"at"`
19
+ Channel string `json:"channel"`
20
+ Nick string `json:"nick"`
21
+ Kind EntryKind `json:"kind"`
22
+ MessageType string `json:"message_type,omitempty"` // envelope type if Kind == envelope
23
+ MessageID string `json:"message_id,omitempty"` // envelope ID if Kind == envelope
24
+ Raw string `json:"raw"`
25
+}
26
+
27
+// Store is the storage backend for scribe log entries.
28
+type Store interface {
29
+ Append(entry Entry) error
30
+ Query(channel string, limit int) ([]Entry, error)
31
+}
32
+
33
+// MemoryStore is an in-memory Store used for testing.
34
+type MemoryStore struct {
35
+ mu sync.RWMutex
36
+ entries []Entry
37
+}
38
+
39
+func (s *MemoryStore) Append(entry Entry) error {
40
+ s.mu.Lock()
41
+ defer s.mu.Unlock()
42
+ s.entries = append(s.entries, entry)
43
+ return nil
44
+}
45
+
46
+func (s *MemoryStore) Query(channel string, limit int) ([]Entry, error) {
47
+ s.mu.RLock()
48
+ defer s.mu.RUnlock()
49
+
50
+ var out []Entry
51
+ for _, e := range s.entries {
52
+ if channel == "" || e.Channel == channel {
53
+ out = append(out, e)
54
+ }
55
+ }
56
+ if limit > 0 && len(out) > limit {
57
+ out = out[len(out)-limit:]
58
+ }
59
+ return out, nil
60
+}
61
+
62
+// All returns all entries (test helper).
63
+func (s *MemoryStore) All() []Entry {
64
+ s.mu.RLock()
65
+ defer s.mu.RUnlock()
66
+ out := make([]Entry, len(s.entries))
67
+ copy(out
--- a/internal/bots/scribe/store.go
+++ b/internal/bots/scribe/store.go
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/scribe/store.go
+++ b/internal/bots/scribe/store.go
@@ -0,0 +1,67 @@
1 package scribe
2
3 import (
4 "sync"
5 "time"
6 )
7
8 // EntryKind describes how a log entry was parsed.
9 type EntryKind string
10
11 const (
12 EntryKindEnvelope EntryKind = "envelope" // parsed as a valid JSON envelope
13 EntryKindRaw EntryKind = "raw" // could not be parsed, logged as-is
14 )
15
16 // Entry is a single structured log record written by scribe.
17 type Entry struct {
18 At time.Time `json:"at"`
19 Channel string `json:"channel"`
20 Nick string `json:"nick"`
21 Kind EntryKind `json:"kind"`
22 MessageType string `json:"message_type,omitempty"` // envelope type if Kind == envelope
23 MessageID string `json:"message_id,omitempty"` // envelope ID if Kind == envelope
24 Raw string `json:"raw"`
25 }
26
27 // Store is the storage backend for scribe log entries.
28 type Store interface {
29 Append(entry Entry) error
30 Query(channel string, limit int) ([]Entry, error)
31 }
32
33 // MemoryStore is an in-memory Store used for testing.
34 type MemoryStore struct {
35 mu sync.RWMutex
36 entries []Entry
37 }
38
39 func (s *MemoryStore) Append(entry Entry) error {
40 s.mu.Lock()
41 defer s.mu.Unlock()
42 s.entries = append(s.entries, entry)
43 return nil
44 }
45
46 func (s *MemoryStore) Query(channel string, limit int) ([]Entry, error) {
47 s.mu.RLock()
48 defer s.mu.RUnlock()
49
50 var out []Entry
51 for _, e := range s.entries {
52 if channel == "" || e.Channel == channel {
53 out = append(out, e)
54 }
55 }
56 if limit > 0 && len(out) > limit {
57 out = out[len(out)-limit:]
58 }
59 return out, nil
60 }
61
62 // All returns all entries (test helper).
63 func (s *MemoryStore) All() []Entry {
64 s.mu.RLock()
65 defer s.mu.RUnlock()
66 out := make([]Entry, len(s.entries))
67 copy(out

Keyboard Shortcuts

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