ScuttleBot

Merge remote-tracking branch 'origin/main' into feature/100-agent-mode-assignment

lmata 2026-04-04 19:47 trunk merge
Commit 3420a833c0f50d7481dff776659dd7318c88f96ad934227fb988790c9bebede1
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -95,14 +95,15 @@
9595
Description: "Records all channel messages to a structured log store.",
9696
Nick: "scribe",
9797
JoinAllChannels: true,
9898
},
9999
{
100
- ID: "herald",
101
- Name: "Herald",
102
- Description: "Routes event notifications from external systems to IRC channels.",
103
- Nick: "herald",
100
+ ID: "herald",
101
+ Name: "Herald",
102
+ Description: "Routes event notifications from external systems to IRC channels.",
103
+ Nick: "herald",
104
+ JoinAllChannels: true,
104105
},
105106
{
106107
ID: "oracle",
107108
Name: "Oracle",
108109
Description: "On-demand channel summarisation via DM using an LLM.",
109110
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -95,14 +95,15 @@
95 Description: "Records all channel messages to a structured log store.",
96 Nick: "scribe",
97 JoinAllChannels: true,
98 },
99 {
100 ID: "herald",
101 Name: "Herald",
102 Description: "Routes event notifications from external systems to IRC channels.",
103 Nick: "herald",
 
104 },
105 {
106 ID: "oracle",
107 Name: "Oracle",
108 Description: "On-demand channel summarisation via DM using an LLM.",
109
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -95,14 +95,15 @@
95 Description: "Records all channel messages to a structured log store.",
96 Nick: "scribe",
97 JoinAllChannels: true,
98 },
99 {
100 ID: "herald",
101 Name: "Herald",
102 Description: "Routes event notifications from external systems to IRC channels.",
103 Nick: "herald",
104 JoinAllChannels: true,
105 },
106 {
107 ID: "oracle",
108 Name: "Oracle",
109 Description: "On-demand channel summarisation via DM using an LLM.",
110
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -204,14 +204,20 @@
204204
}
205205
channel := e.Params[0]
206206
if !strings.HasPrefix(channel, "#") {
207207
return // ignore DMs
208208
}
209
+ // Prefer account-tag (IRCv3) over source nick for sender identity.
210
+ nick := e.Source.Name
211
+ if acct, ok := e.Tags.Get("account"); ok && acct != "" {
212
+ nick = acct
213
+ }
214
+
209215
b.dispatch(Message{
210
- At: time.Now(),
216
+ At: e.Timestamp,
211217
Channel: channel,
212
- Nick: e.Source.Name,
218
+ Nick: nick,
213219
Text: e.Last(),
214220
})
215221
})
216222
217223
b.client = c
218224
219225
ADDED internal/bots/cmdparse/cmdparse.go
220226
ADDED internal/bots/cmdparse/cmdparse_test.go
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -204,14 +204,20 @@
204 }
205 channel := e.Params[0]
206 if !strings.HasPrefix(channel, "#") {
207 return // ignore DMs
208 }
 
 
 
 
 
 
209 b.dispatch(Message{
210 At: time.Now(),
211 Channel: channel,
212 Nick: e.Source.Name,
213 Text: e.Last(),
214 })
215 })
216
217 b.client = c
218
219 DDED internal/bots/cmdparse/cmdparse.go
220 DDED internal/bots/cmdparse/cmdparse_test.go
--- internal/bots/bridge/bridge.go
+++ internal/bots/bridge/bridge.go
@@ -204,14 +204,20 @@
204 }
205 channel := e.Params[0]
206 if !strings.HasPrefix(channel, "#") {
207 return // ignore DMs
208 }
209 // Prefer account-tag (IRCv3) over source nick for sender identity.
210 nick := e.Source.Name
211 if acct, ok := e.Tags.Get("account"); ok && acct != "" {
212 nick = acct
213 }
214
215 b.dispatch(Message{
216 At: e.Timestamp,
217 Channel: channel,
218 Nick: nick,
219 Text: e.Last(),
220 })
221 })
222
223 b.client = c
224
225 DDED internal/bots/cmdparse/cmdparse.go
226 DDED internal/bots/cmdparse/cmdparse_test.go
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,242 @@
1
+// Package cmdparse provides a shared command framework for system bots.
2
+//
3
+// It handles three IRC input forms:
4
+// - DM: /msg botname COMMAND args
5
+// - Fantasy: !command args (in a channel)
6
+// - Addressed: botname: command args (in a channel)
7
+//
8
+// Bots register commands into a CommandRouter, which dispatches incoming
9
+// messages and auto-generates HELP output.
10
+package cmdparse
11
+
12
+import (
13
+ "fmt"
14
+ "sort"
15
+ "strings"
16
+)
17
+
18
+// HandlerFunc is called when a command is matched.
19
+// args is everything after the command name, already trimmed.
20
+// Returns the text to send back (may be multi-line).
21
+type HandlerFunc func(ctx *Context, args string) string
22
+
23
+// Command describes a single bot command.
24
+type Command struct {
25
+ // Name is the canonical command name (case-insensitive matching).
26
+ Name string
27
+ // Usage is a one-line usage string shown in help, e.g. "replay #channel [last=N]".
28
+ Usage string
29
+ // Description is a short description shown in help.
30
+ Description string
31
+ // Handler is called when the command is matched.
32
+ Handler HandlerFunc
33
+}
34
+
35
+// Context carries information about the parsed incoming message.
36
+type Context struct {
37
+ // Nick is the sender's IRC nick.
38
+ Nick string
39
+ // Channel is the channel the message was sent in, or "" for a DM.
40
+ Channel string
41
+ // IsDM is true when the message was sent as a private message.
42
+ IsDM bool
43
+}
44
+
45
+// Reply is a response produced by the router after dispatching a message.
46
+type Reply struct {
47
+ // Target is where the reply should be sent (nick for DM, channel for channel).
48
+ Target string
49
+ // Text is the reply text (may be multi-line).
50
+ Text string
51
+}
52
+
53
+// CommandRouter dispatches IRC messages to registered command handlers.
54
+type CommandRouter struct {
55
+ botNick string
56
+ commands map[string]*Command // lowercase name → command
57
+}
58
+
59
+// NewRouter creates a CommandRouter for a bot with the given IRC nick.
60
+func NewRouter(botNick string) *CommandRouter {
61
+ return &CommandRouter{
62
+ botNick: strings.ToLower(botNick),
63
+ commands: make(map[string]*Command),
64
+ }
65
+}
66
+
67
+// Register adds a command to the router. Panics if name is empty or duplicate.
68
+func (r *CommandRouter) Register(cmd Command) {
69
+ key := strings.ToLower(cmd.Name)
70
+ if key == "" {
71
+ panic("cmdparse: command name must not be empty")
72
+ }
73
+ if _, ok := r.commands[key]; ok {
74
+ panic(fmt.Sprintf("cmdparse: duplicate command %q", cmd.Name))
75
+ }
76
+ r.commands[key] = &cmd
77
+}
78
+
79
+// Dispatch parses an IRC message and dispatches it to the appropriate handler.
80
+// Returns nil if the message is not a command addressed to this bot.
81
+//
82
+// Parameters:
83
+// - nick: sender's IRC nick
84
+// - target: IRC target param (channel name for channel messages, bot nick for DMs)
85
+// - text: the message body
86
+func (r *CommandRouter) Dispatch(nick, target, text string) *Reply {
87
+ text = strings.TrimSpace(text)
88
+ if text == "" {
89
+ return nil
90
+ }
91
+
92
+ var ctx Context
93
+ ctx.Nick = nick
94
+
95
+ isChannel := strings.HasPrefix(target, "#")
96
+
97
+ var cmdLine string
98
+
99
+ if !isChannel {
100
+ // DM — entire text is the command line.
101
+ ctx.IsDM = true
102
+ cmdLine = text
103
+ } else {
104
+ ctx.Channel = target
105
+ if strings.HasPrefix(text, "!") {
106
+ // Fantasy: !command args
107
+ cmdLine = text[1:]
108
+ } else if r.isAddressed(text) {
109
+ // Addressed: botname: command args
110
+ cmdLine = r.stripAddress(text)
111
+ } else {
112
+ return nil
113
+ }
114
+ }
115
+
116
+ cmdLine = strings.TrimSpace(cmdLine)
117
+ if cmdLine == "" {
118
+ return nil
119
+ }
120
+
121
+ cmdName, args := splitFirst(cmdLine)
122
+ cmdKey := strings.ToLower(cmdName)
123
+
124
+ // Built-in HELP.
125
+ if cmdKey == "help" {
126
+ return r.helpReply(&ctx, args)
127
+ }
128
+
129
+ cmd, ok := r.commands[cmdKey]
130
+ if !ok {
131
+ return r.unknownReply(&ctx, cmdName)
132
+ }
133
+
134
+ response := cmd.Handler(&ctx, args)
135
+ if response == "" {
136
+ return nil
137
+ }
138
+
139
+ return &Reply{
140
+ Target: r.replyTarget(&ctx),
141
+ Text: response,
142
+ }
143
+}
144
+
145
+func splitFirst(s string) (first, rest string) {
146
+ s = strings.TrimSpace(s)
147
+ idx := strings.IndexAny(s, " \t")
148
+ if idx < 0 {
149
+ return s, ""
150
+ }
151
+ return s[:idx], strings.TrimSpace(s[idx+1:])
152
+}
153
+
154
+func (r *CommandRouter) isAddressed(text string) bool {
155
+ lower := strings.ToLower(text)
156
+ for _, sep := range []string{": ", ","} {
157
+ prefix := r.botNick + sep
158
+ if strings.HasPrefix(lower, prefix) {
159
+ return true
160
+ }
161
+ }
162
+ // Also handle "botname:" with no space after colon.
163
+ if strings.HasPrefix(lower, r.botNick+":") {
164
+ return true
165
+ }
166
+ return false
167
+}
168
+
169
+func (r *CommandRouter) stripAddress(text string) string {
170
+ lower := strings.ToLower(text)
171
+ for _, sep := range []string{": ", ","} {
172
+ prefix := r.botNick + sep
173
+ if strings.HasPrefix(lower, prefix) {
174
+ return strings.TrimSpace(text[len(prefix):])
175
+ }
176
+ }
177
+ // Bare "botname:" with no space.
178
+ prefix := r.botNick + ":"
179
+ if strings.HasPrefix(lower, prefix) {
180
+ return strings.TrimSpace(text[len(prefix):])
181
+ }
182
+ return text
183
+}
184
+
185
+func (r *CommandRouter) replyTarget(ctx *Context) string {
186
+ if ctx.IsDM {
187
+ return ctx.Nick
188
+ }
189
+ return ctx.Channel
190
+}
191
+
192
+func (r *CommandRouter) helpReply(ctx *Context, args string) *Reply {
193
+ args = strings.TrimSpace(args)
194
+
195
+ if args != "" {
196
+ cmdKey := strings.ToLower(args)
197
+ cmd, ok := r.commands[cmdKey]
198
+ if !ok {
199
+ return &Reply{
200
+ Target: r.replyTarget(ctx),
201
+ Text: fmt.Sprintf("unknown command %q — type HELP for a list of commands", args),
202
+ }
203
+ }
204
+ return &Reply{
205
+ Target: r.replyTarget(ctx),
206
+ Text: fmt.Sprintf("%s — %s\nusage: %s", strings.ToUpper(cmd.Name), cmd.Description, cmd.Usage),
207
+ }
208
+ }
209
+
210
+ names := make([]string, 0, len(r.commands))
211
+ for k := range r.commands {
212
+ names = append(names, k)
213
+ }
214
+ sort.Strings(names)
215
+
216
+ var sb strings.Builder
217
+ sb.WriteString(fmt.Sprintf("commands for %s:\n", r.botNick))
218
+ for _, name := range names {
219
+ cmd := r.commands[name]
220
+ sb.WriteString(fmt.Sprintf(" %-12s %s\n", strings.ToUpper(cmd.Name), cmd.Description))
221
+ }
222
+ sb.WriteString("type HELP <command> for details")
223
+
224
+ return &Reply{
225
+ Target: r.replyTarget(ctx),
226
+ Text: sb.String(),
227
+ }
228
+}
229
+
230
+func (r *CommandRouter) unknownReply(ctx *Context, cmdName string) *Reply {
231
+ names := make([]string, 0, len(r.commands))
232
+ for k := range r.commands {
233
+ names = append(names, strings.ToUpper(k))
234
+ }
235
+ sort.Strings(names)
236
+
237
+ return &Reply{
238
+ Target: r.replyTarget(ctx),
239
+ Text: fmt.Sprintf("unknown command %q — available commands: %s. Type HELP for details.",
240
+ cmdName, strings.Join(names, ", ")),
241
+ }
242
+}
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/cmdparse/cmdparse.go
+++ b/internal/bots/cmdparse/cmdparse.go
@@ -0,0 +1,242 @@
1 // Package cmdparse provides a shared command framework for system bots.
2 //
3 // It handles three IRC input forms:
4 // - DM: /msg botname COMMAND args
5 // - Fantasy: !command args (in a channel)
6 // - Addressed: botname: command args (in a channel)
7 //
8 // Bots register commands into a CommandRouter, which dispatches incoming
9 // messages and auto-generates HELP output.
10 package cmdparse
11
12 import (
13 "fmt"
14 "sort"
15 "strings"
16 )
17
18 // HandlerFunc is called when a command is matched.
19 // args is everything after the command name, already trimmed.
20 // Returns the text to send back (may be multi-line).
21 type HandlerFunc func(ctx *Context, args string) string
22
23 // Command describes a single bot command.
24 type Command struct {
25 // Name is the canonical command name (case-insensitive matching).
26 Name string
27 // Usage is a one-line usage string shown in help, e.g. "replay #channel [last=N]".
28 Usage string
29 // Description is a short description shown in help.
30 Description string
31 // Handler is called when the command is matched.
32 Handler HandlerFunc
33 }
34
35 // Context carries information about the parsed incoming message.
36 type Context struct {
37 // Nick is the sender's IRC nick.
38 Nick string
39 // Channel is the channel the message was sent in, or "" for a DM.
40 Channel string
41 // IsDM is true when the message was sent as a private message.
42 IsDM bool
43 }
44
45 // Reply is a response produced by the router after dispatching a message.
46 type Reply struct {
47 // Target is where the reply should be sent (nick for DM, channel for channel).
48 Target string
49 // Text is the reply text (may be multi-line).
50 Text string
51 }
52
53 // CommandRouter dispatches IRC messages to registered command handlers.
54 type CommandRouter struct {
55 botNick string
56 commands map[string]*Command // lowercase name → command
57 }
58
59 // NewRouter creates a CommandRouter for a bot with the given IRC nick.
60 func NewRouter(botNick string) *CommandRouter {
61 return &CommandRouter{
62 botNick: strings.ToLower(botNick),
63 commands: make(map[string]*Command),
64 }
65 }
66
67 // Register adds a command to the router. Panics if name is empty or duplicate.
68 func (r *CommandRouter) Register(cmd Command) {
69 key := strings.ToLower(cmd.Name)
70 if key == "" {
71 panic("cmdparse: command name must not be empty")
72 }
73 if _, ok := r.commands[key]; ok {
74 panic(fmt.Sprintf("cmdparse: duplicate command %q", cmd.Name))
75 }
76 r.commands[key] = &cmd
77 }
78
79 // Dispatch parses an IRC message and dispatches it to the appropriate handler.
80 // Returns nil if the message is not a command addressed to this bot.
81 //
82 // Parameters:
83 // - nick: sender's IRC nick
84 // - target: IRC target param (channel name for channel messages, bot nick for DMs)
85 // - text: the message body
86 func (r *CommandRouter) Dispatch(nick, target, text string) *Reply {
87 text = strings.TrimSpace(text)
88 if text == "" {
89 return nil
90 }
91
92 var ctx Context
93 ctx.Nick = nick
94
95 isChannel := strings.HasPrefix(target, "#")
96
97 var cmdLine string
98
99 if !isChannel {
100 // DM — entire text is the command line.
101 ctx.IsDM = true
102 cmdLine = text
103 } else {
104 ctx.Channel = target
105 if strings.HasPrefix(text, "!") {
106 // Fantasy: !command args
107 cmdLine = text[1:]
108 } else if r.isAddressed(text) {
109 // Addressed: botname: command args
110 cmdLine = r.stripAddress(text)
111 } else {
112 return nil
113 }
114 }
115
116 cmdLine = strings.TrimSpace(cmdLine)
117 if cmdLine == "" {
118 return nil
119 }
120
121 cmdName, args := splitFirst(cmdLine)
122 cmdKey := strings.ToLower(cmdName)
123
124 // Built-in HELP.
125 if cmdKey == "help" {
126 return r.helpReply(&ctx, args)
127 }
128
129 cmd, ok := r.commands[cmdKey]
130 if !ok {
131 return r.unknownReply(&ctx, cmdName)
132 }
133
134 response := cmd.Handler(&ctx, args)
135 if response == "" {
136 return nil
137 }
138
139 return &Reply{
140 Target: r.replyTarget(&ctx),
141 Text: response,
142 }
143 }
144
145 func splitFirst(s string) (first, rest string) {
146 s = strings.TrimSpace(s)
147 idx := strings.IndexAny(s, " \t")
148 if idx < 0 {
149 return s, ""
150 }
151 return s[:idx], strings.TrimSpace(s[idx+1:])
152 }
153
154 func (r *CommandRouter) isAddressed(text string) bool {
155 lower := strings.ToLower(text)
156 for _, sep := range []string{": ", ","} {
157 prefix := r.botNick + sep
158 if strings.HasPrefix(lower, prefix) {
159 return true
160 }
161 }
162 // Also handle "botname:" with no space after colon.
163 if strings.HasPrefix(lower, r.botNick+":") {
164 return true
165 }
166 return false
167 }
168
169 func (r *CommandRouter) stripAddress(text string) string {
170 lower := strings.ToLower(text)
171 for _, sep := range []string{": ", ","} {
172 prefix := r.botNick + sep
173 if strings.HasPrefix(lower, prefix) {
174 return strings.TrimSpace(text[len(prefix):])
175 }
176 }
177 // Bare "botname:" with no space.
178 prefix := r.botNick + ":"
179 if strings.HasPrefix(lower, prefix) {
180 return strings.TrimSpace(text[len(prefix):])
181 }
182 return text
183 }
184
185 func (r *CommandRouter) replyTarget(ctx *Context) string {
186 if ctx.IsDM {
187 return ctx.Nick
188 }
189 return ctx.Channel
190 }
191
192 func (r *CommandRouter) helpReply(ctx *Context, args string) *Reply {
193 args = strings.TrimSpace(args)
194
195 if args != "" {
196 cmdKey := strings.ToLower(args)
197 cmd, ok := r.commands[cmdKey]
198 if !ok {
199 return &Reply{
200 Target: r.replyTarget(ctx),
201 Text: fmt.Sprintf("unknown command %q — type HELP for a list of commands", args),
202 }
203 }
204 return &Reply{
205 Target: r.replyTarget(ctx),
206 Text: fmt.Sprintf("%s — %s\nusage: %s", strings.ToUpper(cmd.Name), cmd.Description, cmd.Usage),
207 }
208 }
209
210 names := make([]string, 0, len(r.commands))
211 for k := range r.commands {
212 names = append(names, k)
213 }
214 sort.Strings(names)
215
216 var sb strings.Builder
217 sb.WriteString(fmt.Sprintf("commands for %s:\n", r.botNick))
218 for _, name := range names {
219 cmd := r.commands[name]
220 sb.WriteString(fmt.Sprintf(" %-12s %s\n", strings.ToUpper(cmd.Name), cmd.Description))
221 }
222 sb.WriteString("type HELP <command> for details")
223
224 return &Reply{
225 Target: r.replyTarget(ctx),
226 Text: sb.String(),
227 }
228 }
229
230 func (r *CommandRouter) unknownReply(ctx *Context, cmdName string) *Reply {
231 names := make([]string, 0, len(r.commands))
232 for k := range r.commands {
233 names = append(names, strings.ToUpper(k))
234 }
235 sort.Strings(names)
236
237 return &Reply{
238 Target: r.replyTarget(ctx),
239 Text: fmt.Sprintf("unknown command %q — available commands: %s. Type HELP for details.",
240 cmdName, strings.Join(names, ", ")),
241 }
242 }
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
1
+package cmdparse
2
+
3
+import (
4
+ "strings"
5
+ "testing"
6
+)
7
+
8
+func testRouter() *CommandRouter {
9
+ r := NewRouter("scroll")
10
+ r.Register(Command{
11
+ Name: "replay",
12
+ Usage: "replay #channel [last=N]",
13
+ Description: "replay channel history",
14
+ Handler: func(ctx *Context, args string) string {
15
+ return "replaying: " + args
16
+ },
17
+ })
18
+ r.Register(Command{
19
+ Name: "status",
20
+ Usage: "status",
21
+ Description: "show bot status",
22
+ Handler: func(ctx *Context, args string) string {
23
+ return "ok"
24
+ },
25
+ })
26
+ return r
27
+}
28
+
29
+// --- DM input form ---
30
+
31
+func TestDM_BasicCommand(t *testing.T) {
32
+ r := testRouter()
33
+ reply := r.Dispatch("alice", "scroll", "replay #general last=10")
34
+ if reply == nil {
35
+ t.Fatal("expected reply, got nil")
36
+ }
37
+ if reply.Target != "alice" {
38
+ t.Errorf("target = %q, want %q", reply.Target, "alice")
39
+ }
40
+ if reply.Text != "replaying: #general last=10" {
41
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #general last=10")
42
+ }
43
+}
44
+
45
+func TestDM_CaseInsensitive(t *testing.T) {
46
+ r := testRouter()
47
+ reply := r.Dispatch("alice", "scroll", "REPLAY #general")
48
+ if reply == nil {
49
+ t.Fatal("expected reply, got nil")
50
+ }
51
+ if reply.Text != "replaying: #general" {
52
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #general")
53
+ }
54
+}
55
+
56
+func TestDM_NoArgs(t *testing.T) {
57
+ r := testRouter()
58
+ reply := r.Dispatch("alice", "scroll", "status")
59
+ if reply == nil {
60
+ t.Fatal("expected reply, got nil")
61
+ }
62
+ if reply.Text != "ok" {
63
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
64
+ }
65
+}
66
+
67
+func TestDM_EmptyMessage(t *testing.T) {
68
+ r := testRouter()
69
+ reply := r.Dispatch("alice", "scroll", "")
70
+ if reply != nil {
71
+ t.Errorf("expected nil for empty message, got %+v", reply)
72
+ }
73
+}
74
+
75
+func TestDM_WhitespaceOnly(t *testing.T) {
76
+ r := testRouter()
77
+ reply := r.Dispatch("alice", "scroll", " ")
78
+ if reply != nil {
79
+ t.Errorf("expected nil for whitespace-only, got %+v", reply)
80
+ }
81
+}
82
+
83
+func TestDM_ContextFields(t *testing.T) {
84
+ r := NewRouter("testbot")
85
+ var gotCtx *Context
86
+ r.Register(Command{
87
+ Name: "ping",
88
+ Usage: "ping",
89
+ Description: "ping",
90
+ Handler: func(ctx *Context, args string) string {
91
+ gotCtx = ctx
92
+ return "pong"
93
+ },
94
+ })
95
+ r.Dispatch("bob", "testbot", "ping")
96
+ if gotCtx == nil {
97
+ t.Fatal("handler not called")
98
+ }
99
+ if !gotCtx.IsDM {
100
+ t.Error("expected IsDM=true")
101
+ }
102
+ if gotCtx.Channel != "" {
103
+ t.Errorf("channel = %q, want empty", gotCtx.Channel)
104
+ }
105
+ if gotCtx.Nick != "bob" {
106
+ t.Errorf("nick = %q, want %q", gotCtx.Nick, "bob")
107
+ }
108
+}
109
+
110
+// --- Fantasy input form ---
111
+
112
+func TestFantasy_BasicCommand(t *testing.T) {
113
+ r := testRouter()
114
+ reply := r.Dispatch("alice", "#general", "!replay #logs last=20")
115
+ if reply == nil {
116
+ t.Fatal("expected reply, got nil")
117
+ }
118
+ if reply.Target != "#general" {
119
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
120
+ }
121
+ if reply.Text != "replaying: #logs last=20" {
122
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs last=20")
123
+ }
124
+}
125
+
126
+func TestFantasy_CaseInsensitive(t *testing.T) {
127
+ r := testRouter()
128
+ reply := r.Dispatch("alice", "#general", "!STATUS")
129
+ if reply == nil {
130
+ t.Fatal("expected reply, got nil")
131
+ }
132
+ if reply.Text != "ok" {
133
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
134
+ }
135
+}
136
+
137
+func TestFantasy_ContextFields(t *testing.T) {
138
+ r := NewRouter("testbot")
139
+ var gotCtx *Context
140
+ r.Register(Command{
141
+ Name: "ping",
142
+ Usage: "ping",
143
+ Description: "ping",
144
+ Handler: func(ctx *Context, args string) string {
145
+ gotCtx = ctx
146
+ return "pong"
147
+ },
148
+ })
149
+ r.Dispatch("bob", "#dev", "!ping")
150
+ if gotCtx == nil {
151
+ t.Fatal("handler not called")
152
+ }
153
+ if gotCtx.IsDM {
154
+ t.Error("expected IsDM=false")
155
+ }
156
+ if gotCtx.Channel != "#dev" {
157
+ t.Errorf("channel = %q, want %q", gotCtx.Channel, "#dev")
158
+ }
159
+}
160
+
161
+func TestFantasy_BangOnly(t *testing.T) {
162
+ r := testRouter()
163
+ reply := r.Dispatch("alice", "#general", "!")
164
+ if reply != nil {
165
+ t.Errorf("expected nil for bare !, got %+v", reply)
166
+ }
167
+}
168
+
169
+func TestFantasy_NotAddressed(t *testing.T) {
170
+ r := testRouter()
171
+ reply := r.Dispatch("alice", "#general", "just a normal message")
172
+ if reply != nil {
173
+ t.Errorf("expected nil for unaddressed channel message, got %+v", reply)
174
+ }
175
+}
176
+
177
+// --- Addressed input form ---
178
+
179
+func TestAddressed_ColonSpace(t *testing.T) {
180
+ r := testRouter()
181
+ reply := r.Dispatch("alice", "#general", "scroll: replay #logs")
182
+ if reply == nil {
183
+ t.Fatal("expected reply, got nil")
184
+ }
185
+ if reply.Target != "#general" {
186
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
187
+ }
188
+ if reply.Text != "replaying: #logs" {
189
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
190
+ }
191
+}
192
+
193
+func TestAddressed_ColonNoSpace(t *testing.T) {
194
+ r := testRouter()
195
+ reply := r.Dispatch("alice", "#general", "scroll:replay #logs")
196
+ if reply == nil {
197
+ t.Fatal("expected reply, got nil")
198
+ }
199
+ if reply.Text != "replaying: #logs" {
200
+ t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
201
+ }
202
+}
203
+
204
+func TestAddressed_Comma(t *testing.T) {
205
+ r := testRouter()
206
+ reply := r.Dispatch("alice", "#general", "scroll, status")
207
+ if reply == nil {
208
+ t.Fatal("expected reply, got nil")
209
+ }
210
+ if reply.Text != "ok" {
211
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
212
+ }
213
+}
214
+
215
+func TestAddressed_CaseInsensitiveBotNick(t *testing.T) {
216
+ r := testRouter()
217
+ reply := r.Dispatch("alice", "#general", "Scroll: status")
218
+ if reply == nil {
219
+ t.Fatal("expected reply, got nil")
220
+ }
221
+ if reply.Text != "ok" {
222
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
223
+ }
224
+}
225
+
226
+func TestAddressed_ContextFields(t *testing.T) {
227
+ r := NewRouter("testbot")
228
+ var gotCtx *Context
229
+ r.Register(Command{
230
+ Name: "ping",
231
+ Usage: "ping",
232
+ Description: "ping",
233
+ Handler: func(ctx *Context, args string) string {
234
+ gotCtx = ctx
235
+ return "pong"
236
+ },
237
+ })
238
+ r.Dispatch("bob", "#ops", "testbot: ping")
239
+ if gotCtx == nil {
240
+ t.Fatal("handler not called")
241
+ }
242
+ if gotCtx.IsDM {
243
+ t.Error("expected IsDM=false")
244
+ }
245
+ if gotCtx.Channel != "#ops" {
246
+ t.Errorf("channel = %q, want %q", gotCtx.Channel, "#ops")
247
+ }
248
+}
249
+
250
+// --- HELP generation ---
251
+
252
+func TestHelp_DM(t *testing.T) {
253
+ r := testRouter()
254
+ reply := r.Dispatch("alice", "scroll", "help")
255
+ if reply == nil {
256
+ t.Fatal("expected reply, got nil")
257
+ }
258
+ if reply.Target != "alice" {
259
+ t.Errorf("target = %q, want %q", reply.Target, "alice")
260
+ }
261
+ if !strings.Contains(reply.Text, "REPLAY") {
262
+ t.Errorf("help should list REPLAY, got: %s", reply.Text)
263
+ }
264
+ if !strings.Contains(reply.Text, "STATUS") {
265
+ t.Errorf("help should list STATUS, got: %s", reply.Text)
266
+ }
267
+ if !strings.Contains(reply.Text, "commands for scroll") {
268
+ t.Errorf("help should include bot name, got: %s", reply.Text)
269
+ }
270
+}
271
+
272
+func TestHelp_Fantasy(t *testing.T) {
273
+ r := testRouter()
274
+ reply := r.Dispatch("alice", "#general", "!help")
275
+ if reply == nil {
276
+ t.Fatal("expected reply, got nil")
277
+ }
278
+ if reply.Target != "#general" {
279
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
280
+ }
281
+ if !strings.Contains(reply.Text, "REPLAY") {
282
+ t.Errorf("help should list REPLAY, got: %s", reply.Text)
283
+ }
284
+}
285
+
286
+func TestHelp_Addressed(t *testing.T) {
287
+ r := testRouter()
288
+ reply := r.Dispatch("alice", "#general", "scroll: help")
289
+ if reply == nil {
290
+ t.Fatal("expected reply, got nil")
291
+ }
292
+ if reply.Target != "#general" {
293
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
294
+ }
295
+}
296
+
297
+func TestHelp_SpecificCommand(t *testing.T) {
298
+ r := testRouter()
299
+ reply := r.Dispatch("alice", "scroll", "help replay")
300
+ if reply == nil {
301
+ t.Fatal("expected reply, got nil")
302
+ }
303
+ if !strings.Contains(reply.Text, "replay channel history") {
304
+ t.Errorf("help replay should show description, got: %s", reply.Text)
305
+ }
306
+ if !strings.Contains(reply.Text, "replay #channel [last=N]") {
307
+ t.Errorf("help replay should show usage, got: %s", reply.Text)
308
+ }
309
+}
310
+
311
+func TestHelp_SpecificCommandCaseInsensitive(t *testing.T) {
312
+ r := testRouter()
313
+ reply := r.Dispatch("alice", "scroll", "HELP REPLAY")
314
+ if reply == nil {
315
+ t.Fatal("expected reply, got nil")
316
+ }
317
+ if !strings.Contains(reply.Text, "replay channel history") {
318
+ t.Errorf("expected description, got: %s", reply.Text)
319
+ }
320
+}
321
+
322
+func TestHelp_UnknownCommand(t *testing.T) {
323
+ r := testRouter()
324
+ reply := r.Dispatch("alice", "scroll", "help nosuchcmd")
325
+ if reply == nil {
326
+ t.Fatal("expected reply, got nil")
327
+ }
328
+ if !strings.Contains(reply.Text, "unknown command") {
329
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
330
+ }
331
+}
332
+
333
+// --- Unknown command handling ---
334
+
335
+func TestUnknown_DM(t *testing.T) {
336
+ r := testRouter()
337
+ reply := r.Dispatch("alice", "scroll", "frobnicate something")
338
+ if reply == nil {
339
+ t.Fatal("expected reply, got nil")
340
+ }
341
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
342
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
343
+ }
344
+ if !strings.Contains(reply.Text, "REPLAY") {
345
+ t.Errorf("should list available commands, got: %s", reply.Text)
346
+ }
347
+ if !strings.Contains(reply.Text, "STATUS") {
348
+ t.Errorf("should list available commands, got: %s", reply.Text)
349
+ }
350
+}
351
+
352
+func TestUnknown_Fantasy(t *testing.T) {
353
+ r := testRouter()
354
+ reply := r.Dispatch("alice", "#general", "!frobnicate")
355
+ if reply == nil {
356
+ t.Fatal("expected reply, got nil")
357
+ }
358
+ if reply.Target != "#general" {
359
+ t.Errorf("target = %q, want %q", reply.Target, "#general")
360
+ }
361
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
362
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
363
+ }
364
+}
365
+
366
+func TestUnknown_Addressed(t *testing.T) {
367
+ r := testRouter()
368
+ reply := r.Dispatch("alice", "#general", "scroll: frobnicate")
369
+ if reply == nil {
370
+ t.Fatal("expected reply, got nil")
371
+ }
372
+ if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
373
+ t.Errorf("expected unknown command message, got: %s", reply.Text)
374
+ }
375
+}
376
+
377
+// --- Edge cases ---
378
+
379
+func TestRegister_EmptyNamePanics(t *testing.T) {
380
+ r := NewRouter("bot")
381
+ defer func() {
382
+ if r := recover(); r == nil {
383
+ t.Error("expected panic for empty command name")
384
+ }
385
+ }()
386
+ r.Register(Command{Name: "", Handler: func(*Context, string) string { return "" }})
387
+}
388
+
389
+func TestRegister_DuplicatePanics(t *testing.T) {
390
+ r := NewRouter("bot")
391
+ r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
392
+ defer func() {
393
+ if r := recover(); r == nil {
394
+ t.Error("expected panic for duplicate command")
395
+ }
396
+ }()
397
+ r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
398
+}
399
+
400
+func TestHandlerReturnsEmpty(t *testing.T) {
401
+ r := NewRouter("bot")
402
+ r.Register(Command{
403
+ Name: "quiet",
404
+ Handler: func(*Context, string) string { return "" },
405
+ })
406
+ reply := r.Dispatch("alice", "bot", "quiet")
407
+ if reply != nil {
408
+ t.Errorf("expected nil reply for empty handler return, got %+v", reply)
409
+ }
410
+}
411
+
412
+func TestLeadingTrailingWhitespace(t *testing.T) {
413
+ r := testRouter()
414
+ reply := r.Dispatch("alice", "scroll", " status ")
415
+ if reply == nil {
416
+ t.Fatal("expected reply, got nil")
417
+ }
418
+ if reply.Text != "ok" {
419
+ t.Errorf("text = %q, want %q", reply.Text, "ok")
420
+ }
421
+}
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/bots/cmdparse/cmdparse_test.go
+++ b/internal/bots/cmdparse/cmdparse_test.go
@@ -0,0 +1,421 @@
1 package cmdparse
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func testRouter() *CommandRouter {
9 r := NewRouter("scroll")
10 r.Register(Command{
11 Name: "replay",
12 Usage: "replay #channel [last=N]",
13 Description: "replay channel history",
14 Handler: func(ctx *Context, args string) string {
15 return "replaying: " + args
16 },
17 })
18 r.Register(Command{
19 Name: "status",
20 Usage: "status",
21 Description: "show bot status",
22 Handler: func(ctx *Context, args string) string {
23 return "ok"
24 },
25 })
26 return r
27 }
28
29 // --- DM input form ---
30
31 func TestDM_BasicCommand(t *testing.T) {
32 r := testRouter()
33 reply := r.Dispatch("alice", "scroll", "replay #general last=10")
34 if reply == nil {
35 t.Fatal("expected reply, got nil")
36 }
37 if reply.Target != "alice" {
38 t.Errorf("target = %q, want %q", reply.Target, "alice")
39 }
40 if reply.Text != "replaying: #general last=10" {
41 t.Errorf("text = %q, want %q", reply.Text, "replaying: #general last=10")
42 }
43 }
44
45 func TestDM_CaseInsensitive(t *testing.T) {
46 r := testRouter()
47 reply := r.Dispatch("alice", "scroll", "REPLAY #general")
48 if reply == nil {
49 t.Fatal("expected reply, got nil")
50 }
51 if reply.Text != "replaying: #general" {
52 t.Errorf("text = %q, want %q", reply.Text, "replaying: #general")
53 }
54 }
55
56 func TestDM_NoArgs(t *testing.T) {
57 r := testRouter()
58 reply := r.Dispatch("alice", "scroll", "status")
59 if reply == nil {
60 t.Fatal("expected reply, got nil")
61 }
62 if reply.Text != "ok" {
63 t.Errorf("text = %q, want %q", reply.Text, "ok")
64 }
65 }
66
67 func TestDM_EmptyMessage(t *testing.T) {
68 r := testRouter()
69 reply := r.Dispatch("alice", "scroll", "")
70 if reply != nil {
71 t.Errorf("expected nil for empty message, got %+v", reply)
72 }
73 }
74
75 func TestDM_WhitespaceOnly(t *testing.T) {
76 r := testRouter()
77 reply := r.Dispatch("alice", "scroll", " ")
78 if reply != nil {
79 t.Errorf("expected nil for whitespace-only, got %+v", reply)
80 }
81 }
82
83 func TestDM_ContextFields(t *testing.T) {
84 r := NewRouter("testbot")
85 var gotCtx *Context
86 r.Register(Command{
87 Name: "ping",
88 Usage: "ping",
89 Description: "ping",
90 Handler: func(ctx *Context, args string) string {
91 gotCtx = ctx
92 return "pong"
93 },
94 })
95 r.Dispatch("bob", "testbot", "ping")
96 if gotCtx == nil {
97 t.Fatal("handler not called")
98 }
99 if !gotCtx.IsDM {
100 t.Error("expected IsDM=true")
101 }
102 if gotCtx.Channel != "" {
103 t.Errorf("channel = %q, want empty", gotCtx.Channel)
104 }
105 if gotCtx.Nick != "bob" {
106 t.Errorf("nick = %q, want %q", gotCtx.Nick, "bob")
107 }
108 }
109
110 // --- Fantasy input form ---
111
112 func TestFantasy_BasicCommand(t *testing.T) {
113 r := testRouter()
114 reply := r.Dispatch("alice", "#general", "!replay #logs last=20")
115 if reply == nil {
116 t.Fatal("expected reply, got nil")
117 }
118 if reply.Target != "#general" {
119 t.Errorf("target = %q, want %q", reply.Target, "#general")
120 }
121 if reply.Text != "replaying: #logs last=20" {
122 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs last=20")
123 }
124 }
125
126 func TestFantasy_CaseInsensitive(t *testing.T) {
127 r := testRouter()
128 reply := r.Dispatch("alice", "#general", "!STATUS")
129 if reply == nil {
130 t.Fatal("expected reply, got nil")
131 }
132 if reply.Text != "ok" {
133 t.Errorf("text = %q, want %q", reply.Text, "ok")
134 }
135 }
136
137 func TestFantasy_ContextFields(t *testing.T) {
138 r := NewRouter("testbot")
139 var gotCtx *Context
140 r.Register(Command{
141 Name: "ping",
142 Usage: "ping",
143 Description: "ping",
144 Handler: func(ctx *Context, args string) string {
145 gotCtx = ctx
146 return "pong"
147 },
148 })
149 r.Dispatch("bob", "#dev", "!ping")
150 if gotCtx == nil {
151 t.Fatal("handler not called")
152 }
153 if gotCtx.IsDM {
154 t.Error("expected IsDM=false")
155 }
156 if gotCtx.Channel != "#dev" {
157 t.Errorf("channel = %q, want %q", gotCtx.Channel, "#dev")
158 }
159 }
160
161 func TestFantasy_BangOnly(t *testing.T) {
162 r := testRouter()
163 reply := r.Dispatch("alice", "#general", "!")
164 if reply != nil {
165 t.Errorf("expected nil for bare !, got %+v", reply)
166 }
167 }
168
169 func TestFantasy_NotAddressed(t *testing.T) {
170 r := testRouter()
171 reply := r.Dispatch("alice", "#general", "just a normal message")
172 if reply != nil {
173 t.Errorf("expected nil for unaddressed channel message, got %+v", reply)
174 }
175 }
176
177 // --- Addressed input form ---
178
179 func TestAddressed_ColonSpace(t *testing.T) {
180 r := testRouter()
181 reply := r.Dispatch("alice", "#general", "scroll: replay #logs")
182 if reply == nil {
183 t.Fatal("expected reply, got nil")
184 }
185 if reply.Target != "#general" {
186 t.Errorf("target = %q, want %q", reply.Target, "#general")
187 }
188 if reply.Text != "replaying: #logs" {
189 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
190 }
191 }
192
193 func TestAddressed_ColonNoSpace(t *testing.T) {
194 r := testRouter()
195 reply := r.Dispatch("alice", "#general", "scroll:replay #logs")
196 if reply == nil {
197 t.Fatal("expected reply, got nil")
198 }
199 if reply.Text != "replaying: #logs" {
200 t.Errorf("text = %q, want %q", reply.Text, "replaying: #logs")
201 }
202 }
203
204 func TestAddressed_Comma(t *testing.T) {
205 r := testRouter()
206 reply := r.Dispatch("alice", "#general", "scroll, status")
207 if reply == nil {
208 t.Fatal("expected reply, got nil")
209 }
210 if reply.Text != "ok" {
211 t.Errorf("text = %q, want %q", reply.Text, "ok")
212 }
213 }
214
215 func TestAddressed_CaseInsensitiveBotNick(t *testing.T) {
216 r := testRouter()
217 reply := r.Dispatch("alice", "#general", "Scroll: status")
218 if reply == nil {
219 t.Fatal("expected reply, got nil")
220 }
221 if reply.Text != "ok" {
222 t.Errorf("text = %q, want %q", reply.Text, "ok")
223 }
224 }
225
226 func TestAddressed_ContextFields(t *testing.T) {
227 r := NewRouter("testbot")
228 var gotCtx *Context
229 r.Register(Command{
230 Name: "ping",
231 Usage: "ping",
232 Description: "ping",
233 Handler: func(ctx *Context, args string) string {
234 gotCtx = ctx
235 return "pong"
236 },
237 })
238 r.Dispatch("bob", "#ops", "testbot: ping")
239 if gotCtx == nil {
240 t.Fatal("handler not called")
241 }
242 if gotCtx.IsDM {
243 t.Error("expected IsDM=false")
244 }
245 if gotCtx.Channel != "#ops" {
246 t.Errorf("channel = %q, want %q", gotCtx.Channel, "#ops")
247 }
248 }
249
250 // --- HELP generation ---
251
252 func TestHelp_DM(t *testing.T) {
253 r := testRouter()
254 reply := r.Dispatch("alice", "scroll", "help")
255 if reply == nil {
256 t.Fatal("expected reply, got nil")
257 }
258 if reply.Target != "alice" {
259 t.Errorf("target = %q, want %q", reply.Target, "alice")
260 }
261 if !strings.Contains(reply.Text, "REPLAY") {
262 t.Errorf("help should list REPLAY, got: %s", reply.Text)
263 }
264 if !strings.Contains(reply.Text, "STATUS") {
265 t.Errorf("help should list STATUS, got: %s", reply.Text)
266 }
267 if !strings.Contains(reply.Text, "commands for scroll") {
268 t.Errorf("help should include bot name, got: %s", reply.Text)
269 }
270 }
271
272 func TestHelp_Fantasy(t *testing.T) {
273 r := testRouter()
274 reply := r.Dispatch("alice", "#general", "!help")
275 if reply == nil {
276 t.Fatal("expected reply, got nil")
277 }
278 if reply.Target != "#general" {
279 t.Errorf("target = %q, want %q", reply.Target, "#general")
280 }
281 if !strings.Contains(reply.Text, "REPLAY") {
282 t.Errorf("help should list REPLAY, got: %s", reply.Text)
283 }
284 }
285
286 func TestHelp_Addressed(t *testing.T) {
287 r := testRouter()
288 reply := r.Dispatch("alice", "#general", "scroll: help")
289 if reply == nil {
290 t.Fatal("expected reply, got nil")
291 }
292 if reply.Target != "#general" {
293 t.Errorf("target = %q, want %q", reply.Target, "#general")
294 }
295 }
296
297 func TestHelp_SpecificCommand(t *testing.T) {
298 r := testRouter()
299 reply := r.Dispatch("alice", "scroll", "help replay")
300 if reply == nil {
301 t.Fatal("expected reply, got nil")
302 }
303 if !strings.Contains(reply.Text, "replay channel history") {
304 t.Errorf("help replay should show description, got: %s", reply.Text)
305 }
306 if !strings.Contains(reply.Text, "replay #channel [last=N]") {
307 t.Errorf("help replay should show usage, got: %s", reply.Text)
308 }
309 }
310
311 func TestHelp_SpecificCommandCaseInsensitive(t *testing.T) {
312 r := testRouter()
313 reply := r.Dispatch("alice", "scroll", "HELP REPLAY")
314 if reply == nil {
315 t.Fatal("expected reply, got nil")
316 }
317 if !strings.Contains(reply.Text, "replay channel history") {
318 t.Errorf("expected description, got: %s", reply.Text)
319 }
320 }
321
322 func TestHelp_UnknownCommand(t *testing.T) {
323 r := testRouter()
324 reply := r.Dispatch("alice", "scroll", "help nosuchcmd")
325 if reply == nil {
326 t.Fatal("expected reply, got nil")
327 }
328 if !strings.Contains(reply.Text, "unknown command") {
329 t.Errorf("expected unknown command message, got: %s", reply.Text)
330 }
331 }
332
333 // --- Unknown command handling ---
334
335 func TestUnknown_DM(t *testing.T) {
336 r := testRouter()
337 reply := r.Dispatch("alice", "scroll", "frobnicate something")
338 if reply == nil {
339 t.Fatal("expected reply, got nil")
340 }
341 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
342 t.Errorf("expected unknown command message, got: %s", reply.Text)
343 }
344 if !strings.Contains(reply.Text, "REPLAY") {
345 t.Errorf("should list available commands, got: %s", reply.Text)
346 }
347 if !strings.Contains(reply.Text, "STATUS") {
348 t.Errorf("should list available commands, got: %s", reply.Text)
349 }
350 }
351
352 func TestUnknown_Fantasy(t *testing.T) {
353 r := testRouter()
354 reply := r.Dispatch("alice", "#general", "!frobnicate")
355 if reply == nil {
356 t.Fatal("expected reply, got nil")
357 }
358 if reply.Target != "#general" {
359 t.Errorf("target = %q, want %q", reply.Target, "#general")
360 }
361 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
362 t.Errorf("expected unknown command message, got: %s", reply.Text)
363 }
364 }
365
366 func TestUnknown_Addressed(t *testing.T) {
367 r := testRouter()
368 reply := r.Dispatch("alice", "#general", "scroll: frobnicate")
369 if reply == nil {
370 t.Fatal("expected reply, got nil")
371 }
372 if !strings.Contains(reply.Text, `unknown command "frobnicate"`) {
373 t.Errorf("expected unknown command message, got: %s", reply.Text)
374 }
375 }
376
377 // --- Edge cases ---
378
379 func TestRegister_EmptyNamePanics(t *testing.T) {
380 r := NewRouter("bot")
381 defer func() {
382 if r := recover(); r == nil {
383 t.Error("expected panic for empty command name")
384 }
385 }()
386 r.Register(Command{Name: "", Handler: func(*Context, string) string { return "" }})
387 }
388
389 func TestRegister_DuplicatePanics(t *testing.T) {
390 r := NewRouter("bot")
391 r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
392 defer func() {
393 if r := recover(); r == nil {
394 t.Error("expected panic for duplicate command")
395 }
396 }()
397 r.Register(Command{Name: "ping", Handler: func(*Context, string) string { return "" }})
398 }
399
400 func TestHandlerReturnsEmpty(t *testing.T) {
401 r := NewRouter("bot")
402 r.Register(Command{
403 Name: "quiet",
404 Handler: func(*Context, string) string { return "" },
405 })
406 reply := r.Dispatch("alice", "bot", "quiet")
407 if reply != nil {
408 t.Errorf("expected nil reply for empty handler return, got %+v", reply)
409 }
410 }
411
412 func TestLeadingTrailingWhitespace(t *testing.T) {
413 r := testRouter()
414 reply := r.Dispatch("alice", "scroll", " status ")
415 if reply == nil {
416 t.Fatal("expected reply, got nil")
417 }
418 if reply.Text != "ok" {
419 t.Errorf("text = %q, want %q", reply.Text, "ok")
420 }
421 }
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -86,10 +86,11 @@
8686
8787
// Bot is the herald bot.
8888
type Bot struct {
8989
ircAddr string
9090
password string
91
+ channels []string
9192
routes RouteConfig
9293
limiter *RateLimiter
9394
queue chan Event
9495
log *slog.Logger
9596
client *girc.Client
@@ -97,20 +98,21 @@
9798
9899
const defaultQueueSize = 256
99100
100101
// New creates a herald bot. ratePerSec and burst configure the token-bucket
101102
// rate limiter (e.g. 5 messages/sec with burst of 20).
102
-func New(ircAddr, password string, routes RouteConfig, ratePerSec float64, burst int, log *slog.Logger) *Bot {
103
+func New(ircAddr, password string, channels []string, routes RouteConfig, ratePerSec float64, burst int, log *slog.Logger) *Bot {
103104
if ratePerSec <= 0 {
104105
ratePerSec = 5
105106
}
106107
if burst <= 0 {
107108
burst = 20
108109
}
109110
return &Bot{
110111
ircAddr: ircAddr,
111112
password: password,
113
+ channels: channels,
112114
routes: routes,
113115
limiter: newRateLimiter(ratePerSec, burst),
114116
queue: make(chan Event, defaultQueueSize),
115117
log: log,
116118
}
@@ -148,13 +150,16 @@
148150
PingDelay: 30 * time.Second,
149151
PingTimeout: 30 * time.Second,
150152
SSL: false,
151153
})
152154
153
- c.Handlers.AddBg(girc.CONNECTED, func(_ *girc.Client, _ girc.Event) {
155
+ c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156
+ for _, ch := range b.channels {
157
+ cl.Cmd.Join(ch)
158
+ }
154159
if b.log != nil {
155
- b.log.Info("herald connected")
160
+ b.log.Info("herald connected", "channels", b.channels)
156161
}
157162
})
158163
159164
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
160165
if ch := e.Last(); strings.HasPrefix(ch, "#") {
161166
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -86,10 +86,11 @@
86
87 // Bot is the herald bot.
88 type Bot struct {
89 ircAddr string
90 password string
 
91 routes RouteConfig
92 limiter *RateLimiter
93 queue chan Event
94 log *slog.Logger
95 client *girc.Client
@@ -97,20 +98,21 @@
97
98 const defaultQueueSize = 256
99
100 // New creates a herald bot. ratePerSec and burst configure the token-bucket
101 // rate limiter (e.g. 5 messages/sec with burst of 20).
102 func New(ircAddr, password string, routes RouteConfig, ratePerSec float64, burst int, log *slog.Logger) *Bot {
103 if ratePerSec <= 0 {
104 ratePerSec = 5
105 }
106 if burst <= 0 {
107 burst = 20
108 }
109 return &Bot{
110 ircAddr: ircAddr,
111 password: password,
 
112 routes: routes,
113 limiter: newRateLimiter(ratePerSec, burst),
114 queue: make(chan Event, defaultQueueSize),
115 log: log,
116 }
@@ -148,13 +150,16 @@
148 PingDelay: 30 * time.Second,
149 PingTimeout: 30 * time.Second,
150 SSL: false,
151 })
152
153 c.Handlers.AddBg(girc.CONNECTED, func(_ *girc.Client, _ girc.Event) {
 
 
 
154 if b.log != nil {
155 b.log.Info("herald connected")
156 }
157 })
158
159 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
160 if ch := e.Last(); strings.HasPrefix(ch, "#") {
161
--- internal/bots/herald/herald.go
+++ internal/bots/herald/herald.go
@@ -86,10 +86,11 @@
86
87 // Bot is the herald bot.
88 type Bot struct {
89 ircAddr string
90 password string
91 channels []string
92 routes RouteConfig
93 limiter *RateLimiter
94 queue chan Event
95 log *slog.Logger
96 client *girc.Client
@@ -97,20 +98,21 @@
98
99 const defaultQueueSize = 256
100
101 // New creates a herald bot. ratePerSec and burst configure the token-bucket
102 // rate limiter (e.g. 5 messages/sec with burst of 20).
103 func New(ircAddr, password string, channels []string, routes RouteConfig, ratePerSec float64, burst int, log *slog.Logger) *Bot {
104 if ratePerSec <= 0 {
105 ratePerSec = 5
106 }
107 if burst <= 0 {
108 burst = 20
109 }
110 return &Bot{
111 ircAddr: ircAddr,
112 password: password,
113 channels: channels,
114 routes: routes,
115 limiter: newRateLimiter(ratePerSec, burst),
116 queue: make(chan Event, defaultQueueSize),
117 log: log,
118 }
@@ -148,13 +150,16 @@
150 PingDelay: 30 * time.Second,
151 PingTimeout: 30 * time.Second,
152 SSL: false,
153 })
154
155 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
156 for _, ch := range b.channels {
157 cl.Cmd.Join(ch)
158 }
159 if b.log != nil {
160 b.log.Info("herald connected", "channels", b.channels)
161 }
162 })
163
164 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
165 if ch := e.Last(); strings.HasPrefix(ch, "#") {
166
--- internal/bots/herald/herald_test.go
+++ internal/bots/herald/herald_test.go
@@ -6,11 +6,11 @@
66
77
"github.com/conflicthq/scuttlebot/internal/bots/herald"
88
)
99
1010
func newBot(routes herald.RouteConfig) *herald.Bot {
11
- return herald.New("localhost:6667", "pass", routes, 100, 100, nil)
11
+ return herald.New("localhost:6667", "pass", nil, routes, 100, 100, nil)
1212
}
1313
1414
func TestBotName(t *testing.T) {
1515
b := newBot(herald.RouteConfig{})
1616
if b.Name() != "herald" {
@@ -42,11 +42,11 @@
4242
"ci.build.": "#builds",
4343
"deploy.": "#deploys",
4444
},
4545
DefaultChannel: "#alerts",
4646
}
47
- b := herald.New("localhost:6667", "pass", routes, 5, 20, nil)
47
+ b := herald.New("localhost:6667", "pass", nil, routes, 5, 20, nil)
4848
if b == nil {
4949
t.Fatal("expected non-nil bot")
5050
}
5151
}
5252
5353
--- internal/bots/herald/herald_test.go
+++ internal/bots/herald/herald_test.go
@@ -6,11 +6,11 @@
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" {
@@ -42,11 +42,11 @@
42 "ci.build.": "#builds",
43 "deploy.": "#deploys",
44 },
45 DefaultChannel: "#alerts",
46 }
47 b := herald.New("localhost:6667", "pass", routes, 5, 20, nil)
48 if b == nil {
49 t.Fatal("expected non-nil bot")
50 }
51 }
52
53
--- internal/bots/herald/herald_test.go
+++ internal/bots/herald/herald_test.go
@@ -6,11 +6,11 @@
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", nil, routes, 100, 100, nil)
12 }
13
14 func TestBotName(t *testing.T) {
15 b := newBot(herald.RouteConfig{})
16 if b.Name() != "herald" {
@@ -42,11 +42,11 @@
42 "ci.build.": "#builds",
43 "deploy.": "#deploys",
44 },
45 DefaultChannel: "#alerts",
46 }
47 b := herald.New("localhost:6667", "pass", nil, routes, 5, 20, nil)
48 if b == nil {
49 t.Fatal("expected non-nil bot")
50 }
51 }
52
53
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -233,26 +233,27 @@
233233
AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
234234
FloodMessages: cfgInt(cfg, "flood_messages", 10),
235235
FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second,
236236
JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5),
237237
JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second,
238
+ Channels: channels,
238239
}, m.log), nil
239240
240241
case "warden":
241
- return warden.New(m.ircAddr, pass, nil, warden.ChannelConfig{
242
+ return warden.New(m.ircAddr, pass, channels, nil, warden.ChannelConfig{
242243
MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5),
243244
Burst: cfgInt(cfg, "burst", 10),
244245
}, m.log), nil
245246
246247
case "scroll":
247
- return scroll.New(m.ircAddr, pass, &scribe.MemoryStore{}, m.log), nil
248
+ return scroll.New(m.ircAddr, pass, channels, &scribe.MemoryStore{}, m.log), nil
248249
249250
case "systembot":
250251
return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil
251252
252253
case "herald":
253
- return herald.New(m.ircAddr, pass, herald.RouteConfig{
254
+ return herald.New(m.ircAddr, pass, channels, herald.RouteConfig{
254255
DefaultChannel: cfgStr(cfg, "default_channel", ""),
255256
}, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil
256257
257258
case "oracle":
258259
// Resolve API key — prefer direct api_key, fall back to api_key_env for
@@ -282,11 +283,11 @@
282283
// Read from the same dir scribe writes to.
283284
scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
284285
fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
285286
history := &scribeHistoryAdapter{store: fs}
286287
287
- return oracle.New(m.ircAddr, pass, history, provider, m.log), nil
288
+ return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
288289
289290
case "sentinel":
290291
apiKey := cfgStr(cfg, "api_key", "")
291292
if apiKey == "" {
292293
if env := cfgStr(cfg, "api_key_env", ""); env != "" {
@@ -316,10 +317,11 @@
316317
Policy: cfgStr(cfg, "policy", ""),
317318
WindowSize: cfgInt(cfg, "window_size", 20),
318319
WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
319320
CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
320321
MinSeverity: cfgStr(cfg, "min_severity", "medium"),
322
+ Channels: channels,
321323
}, provider, m.log), nil
322324
323325
case "steward":
324326
return steward.New(steward.Config{
325327
IRCAddr: m.ircAddr,
@@ -330,10 +332,11 @@
330332
DMOnAction: cfgBool(cfg, "dm_on_action", false),
331333
AutoAct: cfgBool(cfg, "auto_act", true),
332334
MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
333335
WarnOnLow: cfgBool(cfg, "warn_on_low", true),
334336
CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
337
+ Channels: channels,
335338
}, m.log), nil
336339
337340
default:
338341
return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
339342
}
340343
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -233,26 +233,27 @@
233 AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
234 FloodMessages: cfgInt(cfg, "flood_messages", 10),
235 FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second,
236 JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5),
237 JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second,
 
238 }, m.log), nil
239
240 case "warden":
241 return warden.New(m.ircAddr, pass, nil, warden.ChannelConfig{
242 MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5),
243 Burst: cfgInt(cfg, "burst", 10),
244 }, m.log), nil
245
246 case "scroll":
247 return scroll.New(m.ircAddr, pass, &scribe.MemoryStore{}, m.log), nil
248
249 case "systembot":
250 return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil
251
252 case "herald":
253 return herald.New(m.ircAddr, pass, herald.RouteConfig{
254 DefaultChannel: cfgStr(cfg, "default_channel", ""),
255 }, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil
256
257 case "oracle":
258 // Resolve API key — prefer direct api_key, fall back to api_key_env for
@@ -282,11 +283,11 @@
282 // Read from the same dir scribe writes to.
283 scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
284 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
285 history := &scribeHistoryAdapter{store: fs}
286
287 return oracle.New(m.ircAddr, pass, history, provider, m.log), nil
288
289 case "sentinel":
290 apiKey := cfgStr(cfg, "api_key", "")
291 if apiKey == "" {
292 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
@@ -316,10 +317,11 @@
316 Policy: cfgStr(cfg, "policy", ""),
317 WindowSize: cfgInt(cfg, "window_size", 20),
318 WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
319 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
320 MinSeverity: cfgStr(cfg, "min_severity", "medium"),
 
321 }, provider, m.log), nil
322
323 case "steward":
324 return steward.New(steward.Config{
325 IRCAddr: m.ircAddr,
@@ -330,10 +332,11 @@
330 DMOnAction: cfgBool(cfg, "dm_on_action", false),
331 AutoAct: cfgBool(cfg, "auto_act", true),
332 MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
333 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
334 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
 
335 }, m.log), nil
336
337 default:
338 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
339 }
340
--- internal/bots/manager/manager.go
+++ internal/bots/manager/manager.go
@@ -233,26 +233,27 @@
233 AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
234 FloodMessages: cfgInt(cfg, "flood_messages", 10),
235 FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second,
236 JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5),
237 JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second,
238 Channels: channels,
239 }, m.log), nil
240
241 case "warden":
242 return warden.New(m.ircAddr, pass, channels, nil, warden.ChannelConfig{
243 MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5),
244 Burst: cfgInt(cfg, "burst", 10),
245 }, m.log), nil
246
247 case "scroll":
248 return scroll.New(m.ircAddr, pass, channels, &scribe.MemoryStore{}, m.log), nil
249
250 case "systembot":
251 return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil
252
253 case "herald":
254 return herald.New(m.ircAddr, pass, channels, herald.RouteConfig{
255 DefaultChannel: cfgStr(cfg, "default_channel", ""),
256 }, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil
257
258 case "oracle":
259 // Resolve API key — prefer direct api_key, fall back to api_key_env for
@@ -282,11 +283,11 @@
283 // Read from the same dir scribe writes to.
284 scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
285 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
286 history := &scribeHistoryAdapter{store: fs}
287
288 return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
289
290 case "sentinel":
291 apiKey := cfgStr(cfg, "api_key", "")
292 if apiKey == "" {
293 if env := cfgStr(cfg, "api_key_env", ""); env != "" {
@@ -316,10 +317,11 @@
317 Policy: cfgStr(cfg, "policy", ""),
318 WindowSize: cfgInt(cfg, "window_size", 20),
319 WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
320 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
321 MinSeverity: cfgStr(cfg, "min_severity", "medium"),
322 Channels: channels,
323 }, provider, m.log), nil
324
325 case "steward":
326 return steward.New(steward.Config{
327 IRCAddr: m.ircAddr,
@@ -330,10 +332,11 @@
332 DMOnAction: cfgBool(cfg, "dm_on_action", false),
333 AutoAct: cfgBool(cfg, "auto_act", true),
334 MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
335 WarnOnLow: cfgBool(cfg, "warn_on_low", true),
336 CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
337 Channels: channels,
338 }, m.log), nil
339
340 default:
341 return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
342 }
343
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -117,23 +117,25 @@
117117
118118
// Bot is the oracle bot.
119119
type Bot struct {
120120
ircAddr string
121121
password string
122
+ channels []string
122123
history HistoryFetcher
123124
llm LLMProvider
124125
log *slog.Logger
125126
mu sync.Mutex
126127
lastReq map[string]time.Time // nick → last request time
127128
client *girc.Client
128129
}
129130
130131
// New creates an oracle bot.
131
-func New(ircAddr, password string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
132
+func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
132133
return &Bot{
133134
ircAddr: ircAddr,
134135
password: password,
136
+ channels: channels,
135137
history: history,
136138
llm: llm,
137139
log: log,
138140
lastReq: make(map[string]time.Time),
139141
}
@@ -159,13 +161,16 @@
159161
PingDelay: 30 * time.Second,
160162
PingTimeout: 30 * time.Second,
161163
SSL: false,
162164
})
163165
164
- c.Handlers.AddBg(girc.CONNECTED, func(_ *girc.Client, _ girc.Event) {
166
+ c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
167
+ for _, ch := range b.channels {
168
+ cl.Cmd.Join(ch)
169
+ }
165170
if b.log != nil {
166
- b.log.Info("oracle connected")
171
+ b.log.Info("oracle connected", "channels", b.channels)
167172
}
168173
})
169174
170175
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
171176
if ch := e.Last(); strings.HasPrefix(ch, "#") {
172177
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -117,23 +117,25 @@
117
118 // Bot is the oracle bot.
119 type Bot struct {
120 ircAddr string
121 password string
 
122 history HistoryFetcher
123 llm LLMProvider
124 log *slog.Logger
125 mu sync.Mutex
126 lastReq map[string]time.Time // nick → last request time
127 client *girc.Client
128 }
129
130 // New creates an oracle bot.
131 func New(ircAddr, password string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
132 return &Bot{
133 ircAddr: ircAddr,
134 password: password,
 
135 history: history,
136 llm: llm,
137 log: log,
138 lastReq: make(map[string]time.Time),
139 }
@@ -159,13 +161,16 @@
159 PingDelay: 30 * time.Second,
160 PingTimeout: 30 * time.Second,
161 SSL: false,
162 })
163
164 c.Handlers.AddBg(girc.CONNECTED, func(_ *girc.Client, _ girc.Event) {
 
 
 
165 if b.log != nil {
166 b.log.Info("oracle connected")
167 }
168 })
169
170 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
171 if ch := e.Last(); strings.HasPrefix(ch, "#") {
172
--- internal/bots/oracle/oracle.go
+++ internal/bots/oracle/oracle.go
@@ -117,23 +117,25 @@
117
118 // Bot is the oracle bot.
119 type Bot struct {
120 ircAddr string
121 password string
122 channels []string
123 history HistoryFetcher
124 llm LLMProvider
125 log *slog.Logger
126 mu sync.Mutex
127 lastReq map[string]time.Time // nick → last request time
128 client *girc.Client
129 }
130
131 // New creates an oracle bot.
132 func New(ircAddr, password string, channels []string, history HistoryFetcher, llm LLMProvider, log *slog.Logger) *Bot {
133 return &Bot{
134 ircAddr: ircAddr,
135 password: password,
136 channels: channels,
137 history: history,
138 llm: llm,
139 log: log,
140 lastReq: make(map[string]time.Time),
141 }
@@ -159,13 +161,16 @@
161 PingDelay: 30 * time.Second,
162 PingTimeout: 30 * time.Second,
163 SSL: false,
164 })
165
166 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
167 for _, ch := range b.channels {
168 cl.Cmd.Join(ch)
169 }
170 if b.log != nil {
171 b.log.Info("oracle connected", "channels", b.channels)
172 }
173 })
174
175 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
176 if ch := e.Last(); strings.HasPrefix(ch, "#") {
177
--- internal/bots/oracle/oracle_test.go
+++ internal/bots/oracle/oracle_test.go
@@ -91,11 +91,11 @@
9191
}
9292
9393
// --- Bot construction ---
9494
9595
func TestBotName(t *testing.T) {
96
- b := oracle.New("localhost:6667", "pass",
96
+ b := oracle.New("localhost:6667", "pass", nil,
9797
newHistory("#fleet", nil),
9898
&oracle.StubProvider{Response: "summary"},
9999
nil,
100100
)
101101
if b.Name() != "oracle" {
102102
--- internal/bots/oracle/oracle_test.go
+++ internal/bots/oracle/oracle_test.go
@@ -91,11 +91,11 @@
91 }
92
93 // --- Bot construction ---
94
95 func TestBotName(t *testing.T) {
96 b := oracle.New("localhost:6667", "pass",
97 newHistory("#fleet", nil),
98 &oracle.StubProvider{Response: "summary"},
99 nil,
100 )
101 if b.Name() != "oracle" {
102
--- internal/bots/oracle/oracle_test.go
+++ internal/bots/oracle/oracle_test.go
@@ -91,11 +91,11 @@
91 }
92
93 // --- Bot construction ---
94
95 func TestBotName(t *testing.T) {
96 b := oracle.New("localhost:6667", "pass", nil,
97 newHistory("#fleet", nil),
98 &oracle.StubProvider{Response: "summary"},
99 nil,
100 )
101 if b.Name() != "oracle" {
102
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -34,21 +34,23 @@
3434
3535
// Bot is the scroll history-replay bot.
3636
type Bot struct {
3737
ircAddr string
3838
password string
39
+ channels []string
3940
store scribe.Store
4041
log *slog.Logger
4142
client *girc.Client
4243
rateLimit sync.Map // nick → last request time
4344
}
4445
4546
// New creates a scroll Bot backed by the given scribe Store.
46
-func New(ircAddr, password string, store scribe.Store, log *slog.Logger) *Bot {
47
+func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
4748
return &Bot{
4849
ircAddr: ircAddr,
4950
password: password,
51
+ channels: channels,
5052
store: store,
5153
log: log,
5254
}
5355
}
5456
@@ -72,12 +74,15 @@
7274
PingDelay: 30 * time.Second,
7375
PingTimeout: 30 * time.Second,
7476
SSL: false,
7577
})
7678
77
- c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
78
- b.log.Info("scroll connected")
79
+ c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
80
+ for _, ch := range b.channels {
81
+ cl.Cmd.Join(ch)
82
+ }
83
+ b.log.Info("scroll connected", "channels", b.channels)
7984
})
8085
8186
// Only respond to DMs — ignore anything in a channel.
8287
c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
8388
if len(e.Params) < 1 {
8489
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -34,21 +34,23 @@
34
35 // Bot is the scroll history-replay bot.
36 type Bot struct {
37 ircAddr string
38 password string
 
39 store scribe.Store
40 log *slog.Logger
41 client *girc.Client
42 rateLimit sync.Map // nick → last request time
43 }
44
45 // New creates a scroll Bot backed by the given scribe Store.
46 func New(ircAddr, password string, store scribe.Store, log *slog.Logger) *Bot {
47 return &Bot{
48 ircAddr: ircAddr,
49 password: password,
 
50 store: store,
51 log: log,
52 }
53 }
54
@@ -72,12 +74,15 @@
72 PingDelay: 30 * time.Second,
73 PingTimeout: 30 * time.Second,
74 SSL: false,
75 })
76
77 c.Handlers.AddBg(girc.CONNECTED, func(client *girc.Client, e girc.Event) {
78 b.log.Info("scroll connected")
 
 
 
79 })
80
81 // Only respond to DMs — ignore anything in a channel.
82 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
83 if len(e.Params) < 1 {
84
--- internal/bots/scroll/scroll.go
+++ internal/bots/scroll/scroll.go
@@ -34,21 +34,23 @@
34
35 // Bot is the scroll history-replay bot.
36 type Bot struct {
37 ircAddr string
38 password string
39 channels []string
40 store scribe.Store
41 log *slog.Logger
42 client *girc.Client
43 rateLimit sync.Map // nick → last request time
44 }
45
46 // New creates a scroll Bot backed by the given scribe Store.
47 func New(ircAddr, password string, channels []string, store scribe.Store, log *slog.Logger) *Bot {
48 return &Bot{
49 ircAddr: ircAddr,
50 password: password,
51 channels: channels,
52 store: store,
53 log: log,
54 }
55 }
56
@@ -72,12 +74,15 @@
74 PingDelay: 30 * time.Second,
75 PingTimeout: 30 * time.Second,
76 SSL: false,
77 })
78
79 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, e girc.Event) {
80 for _, ch := range b.channels {
81 cl.Cmd.Join(ch)
82 }
83 b.log.Info("scroll connected", "channels", b.channels)
84 })
85
86 // Only respond to DMs — ignore anything in a channel.
87 c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) {
88 if len(e.Params) < 1 {
89
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -61,10 +61,13 @@
6161
// Default: 10 minutes.
6262
CooldownPerNick time.Duration
6363
// MinSeverity controls which severities trigger a report.
6464
// "low", "medium", "high" — default: "medium".
6565
MinSeverity string
66
+
67
+ // Channels is the list of channels to join on connect.
68
+ Channels []string
6669
}
6770
6871
func (c *Config) setDefaults() {
6972
if c.Nick == "" {
7073
c.Nick = defaultNick
@@ -143,14 +146,17 @@
143146
PingDelay: 30 * time.Second,
144147
PingTimeout: 30 * time.Second,
145148
})
146149
147150
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
148
- if b.log != nil {
149
- b.log.Info("sentinel connected")
151
+ for _, ch := range b.cfg.Channels {
152
+ cl.Cmd.Join(ch)
150153
}
151154
cl.Cmd.Join(b.cfg.ModChannel)
155
+ if b.log != nil {
156
+ b.log.Info("sentinel connected", "channels", b.cfg.Channels)
157
+ }
152158
})
153159
154160
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
155161
if ch := e.Last(); strings.HasPrefix(ch, "#") {
156162
cl.Cmd.Join(ch)
157163
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -61,10 +61,13 @@
61 // Default: 10 minutes.
62 CooldownPerNick time.Duration
63 // MinSeverity controls which severities trigger a report.
64 // "low", "medium", "high" — default: "medium".
65 MinSeverity string
 
 
 
66 }
67
68 func (c *Config) setDefaults() {
69 if c.Nick == "" {
70 c.Nick = defaultNick
@@ -143,14 +146,17 @@
143 PingDelay: 30 * time.Second,
144 PingTimeout: 30 * time.Second,
145 })
146
147 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
148 if b.log != nil {
149 b.log.Info("sentinel connected")
150 }
151 cl.Cmd.Join(b.cfg.ModChannel)
 
 
 
152 })
153
154 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
155 if ch := e.Last(); strings.HasPrefix(ch, "#") {
156 cl.Cmd.Join(ch)
157
--- internal/bots/sentinel/sentinel.go
+++ internal/bots/sentinel/sentinel.go
@@ -61,10 +61,13 @@
61 // Default: 10 minutes.
62 CooldownPerNick time.Duration
63 // MinSeverity controls which severities trigger a report.
64 // "low", "medium", "high" — default: "medium".
65 MinSeverity string
66
67 // Channels is the list of channels to join on connect.
68 Channels []string
69 }
70
71 func (c *Config) setDefaults() {
72 if c.Nick == "" {
73 c.Nick = defaultNick
@@ -143,14 +146,17 @@
146 PingDelay: 30 * time.Second,
147 PingTimeout: 30 * time.Second,
148 })
149
150 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
151 for _, ch := range b.cfg.Channels {
152 cl.Cmd.Join(ch)
153 }
154 cl.Cmd.Join(b.cfg.ModChannel)
155 if b.log != nil {
156 b.log.Info("sentinel connected", "channels", b.cfg.Channels)
157 }
158 })
159
160 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
161 if ch := e.Last(); strings.HasPrefix(ch, "#") {
162 cl.Cmd.Join(ch)
163
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -45,10 +45,13 @@
4545
FloodWindow time.Duration
4646
// JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5.
4747
JoinPartThreshold int
4848
// JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
4949
JoinPartWindow time.Duration
50
+
51
+ // Channels is the list of channels to join on connect.
52
+ Channels []string
5053
}
5154
5255
func (c *Config) setDefaults() {
5356
if c.Nick == "" {
5457
c.Nick = defaultNick
@@ -132,16 +135,19 @@
132135
PingDelay: 30 * time.Second,
133136
PingTimeout: 30 * time.Second,
134137
})
135138
136139
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137
- if b.log != nil {
138
- b.log.Info("snitch connected")
140
+ for _, ch := range b.cfg.Channels {
141
+ cl.Cmd.Join(ch)
139142
}
140143
if b.cfg.AlertChannel != "" {
141144
cl.Cmd.Join(b.cfg.AlertChannel)
142145
}
146
+ if b.log != nil {
147
+ b.log.Info("snitch connected", "channels", b.cfg.Channels)
148
+ }
143149
})
144150
145151
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
146152
if ch := e.Last(); strings.HasPrefix(ch, "#") {
147153
cl.Cmd.Join(ch)
148154
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -45,10 +45,13 @@
45 FloodWindow time.Duration
46 // JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5.
47 JoinPartThreshold int
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
 
 
 
50 }
51
52 func (c *Config) setDefaults() {
53 if c.Nick == "" {
54 c.Nick = defaultNick
@@ -132,16 +135,19 @@
132 PingDelay: 30 * time.Second,
133 PingTimeout: 30 * time.Second,
134 })
135
136 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
137 if b.log != nil {
138 b.log.Info("snitch connected")
139 }
140 if b.cfg.AlertChannel != "" {
141 cl.Cmd.Join(b.cfg.AlertChannel)
142 }
 
 
 
143 })
144
145 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
146 if ch := e.Last(); strings.HasPrefix(ch, "#") {
147 cl.Cmd.Join(ch)
148
--- internal/bots/snitch/snitch.go
+++ internal/bots/snitch/snitch.go
@@ -45,10 +45,13 @@
45 FloodWindow time.Duration
46 // JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5.
47 JoinPartThreshold int
48 // JoinPartWindow is the rolling window for join/part cycling. Default: 30s.
49 JoinPartWindow time.Duration
50
51 // Channels is the list of channels to join on connect.
52 Channels []string
53 }
54
55 func (c *Config) setDefaults() {
56 if c.Nick == "" {
57 c.Nick = defaultNick
@@ -132,16 +135,19 @@
135 PingDelay: 30 * time.Second,
136 PingTimeout: 30 * time.Second,
137 })
138
139 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
140 for _, ch := range b.cfg.Channels {
141 cl.Cmd.Join(ch)
142 }
143 if b.cfg.AlertChannel != "" {
144 cl.Cmd.Join(b.cfg.AlertChannel)
145 }
146 if b.log != nil {
147 b.log.Info("snitch connected", "channels", b.cfg.Channels)
148 }
149 })
150
151 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
152 if ch := e.Last(); strings.HasPrefix(ch, "#") {
153 cl.Cmd.Join(ch)
154
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -65,10 +65,13 @@
6565
DMOnAction bool
6666
6767
// CooldownPerNick is the minimum time between automated actions on the
6868
// same nick. Default: 5 minutes.
6969
CooldownPerNick time.Duration
70
+
71
+ // Channels is the list of channels to join on connect.
72
+ Channels []string
7073
}
7174
7275
func (c *Config) setDefaults() {
7376
if c.Nick == "" {
7477
c.Nick = defaultNick
@@ -127,14 +130,17 @@
127130
PingDelay: 30 * time.Second,
128131
PingTimeout: 30 * time.Second,
129132
})
130133
131134
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
132
- if b.log != nil {
133
- b.log.Info("steward connected")
135
+ for _, ch := range b.cfg.Channels {
136
+ cl.Cmd.Join(ch)
134137
}
135138
cl.Cmd.Join(b.cfg.ModChannel)
139
+ if b.log != nil {
140
+ b.log.Info("steward connected", "channels", b.cfg.Channels)
141
+ }
136142
})
137143
138144
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
139145
if ch := e.Last(); strings.HasPrefix(ch, "#") {
140146
cl.Cmd.Join(ch)
141147
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -65,10 +65,13 @@
65 DMOnAction bool
66
67 // CooldownPerNick is the minimum time between automated actions on the
68 // same nick. Default: 5 minutes.
69 CooldownPerNick time.Duration
 
 
 
70 }
71
72 func (c *Config) setDefaults() {
73 if c.Nick == "" {
74 c.Nick = defaultNick
@@ -127,14 +130,17 @@
127 PingDelay: 30 * time.Second,
128 PingTimeout: 30 * time.Second,
129 })
130
131 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
132 if b.log != nil {
133 b.log.Info("steward connected")
134 }
135 cl.Cmd.Join(b.cfg.ModChannel)
 
 
 
136 })
137
138 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
139 if ch := e.Last(); strings.HasPrefix(ch, "#") {
140 cl.Cmd.Join(ch)
141
--- internal/bots/steward/steward.go
+++ internal/bots/steward/steward.go
@@ -65,10 +65,13 @@
65 DMOnAction bool
66
67 // CooldownPerNick is the minimum time between automated actions on the
68 // same nick. Default: 5 minutes.
69 CooldownPerNick time.Duration
70
71 // Channels is the list of channels to join on connect.
72 Channels []string
73 }
74
75 func (c *Config) setDefaults() {
76 if c.Nick == "" {
77 c.Nick = defaultNick
@@ -127,14 +130,17 @@
130 PingDelay: 30 * time.Second,
131 PingTimeout: 30 * time.Second,
132 })
133
134 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
135 for _, ch := range b.cfg.Channels {
136 cl.Cmd.Join(ch)
137 }
138 cl.Cmd.Join(b.cfg.ModChannel)
139 if b.log != nil {
140 b.log.Info("steward connected", "channels", b.cfg.Channels)
141 }
142 })
143
144 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
145 if ch := e.Last(); strings.HasPrefix(ch, "#") {
146 cl.Cmd.Join(ch)
147
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,10 +138,11 @@
138138
139139
// Bot is the warden.
140140
type Bot struct {
141141
ircAddr string
142142
password string
143
+ initChannels []string // channels to join on connect
143144
channelConfigs map[string]ChannelConfig // keyed by channel name
144145
defaultConfig ChannelConfig
145146
mu sync.RWMutex
146147
channels map[string]*channelState
147148
log *slog.Logger
@@ -162,15 +163,16 @@
162163
Record(ActionRecord)
163164
}
164165
165166
// New creates a warden bot. channelConfigs overrides per-channel limits;
166167
// defaultConfig is used for channels not in the map.
167
-func New(ircAddr, password string, channelConfigs map[string]ChannelConfig, defaultConfig ChannelConfig, log *slog.Logger) *Bot {
168
+func New(ircAddr, password string, channels []string, channelConfigs map[string]ChannelConfig, defaultConfig ChannelConfig, log *slog.Logger) *Bot {
168169
defaultConfig.defaults()
169170
return &Bot{
170171
ircAddr: ircAddr,
171172
password: password,
173
+ initChannels: channels,
172174
channelConfigs: channelConfigs,
173175
defaultConfig: defaultConfig,
174176
channels: make(map[string]*channelState),
175177
log: log,
176178
}
@@ -197,16 +199,18 @@
197199
PingTimeout: 30 * time.Second,
198200
SSL: false,
199201
})
200202
201203
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
202
- // Join all configured channels.
204
+ for _, ch := range b.initChannels {
205
+ cl.Cmd.Join(ch)
206
+ }
203207
for ch := range b.channelConfigs {
204208
cl.Cmd.Join(ch)
205209
}
206210
if b.log != nil {
207
- b.log.Info("warden connected")
211
+ b.log.Info("warden connected", "channels", b.initChannels)
208212
}
209213
})
210214
211215
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
212216
if ch := e.Last(); strings.HasPrefix(ch, "#") {
213217
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,10 +138,11 @@
138
139 // Bot is the warden.
140 type Bot struct {
141 ircAddr string
142 password string
 
143 channelConfigs map[string]ChannelConfig // keyed by channel name
144 defaultConfig ChannelConfig
145 mu sync.RWMutex
146 channels map[string]*channelState
147 log *slog.Logger
@@ -162,15 +163,16 @@
162 Record(ActionRecord)
163 }
164
165 // New creates a warden bot. channelConfigs overrides per-channel limits;
166 // defaultConfig is used for channels not in the map.
167 func New(ircAddr, password string, channelConfigs map[string]ChannelConfig, defaultConfig ChannelConfig, log *slog.Logger) *Bot {
168 defaultConfig.defaults()
169 return &Bot{
170 ircAddr: ircAddr,
171 password: password,
 
172 channelConfigs: channelConfigs,
173 defaultConfig: defaultConfig,
174 channels: make(map[string]*channelState),
175 log: log,
176 }
@@ -197,16 +199,18 @@
197 PingTimeout: 30 * time.Second,
198 SSL: false,
199 })
200
201 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
202 // Join all configured channels.
 
 
203 for ch := range b.channelConfigs {
204 cl.Cmd.Join(ch)
205 }
206 if b.log != nil {
207 b.log.Info("warden connected")
208 }
209 })
210
211 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
212 if ch := e.Last(); strings.HasPrefix(ch, "#") {
213
--- internal/bots/warden/warden.go
+++ internal/bots/warden/warden.go
@@ -138,10 +138,11 @@
138
139 // Bot is the warden.
140 type Bot struct {
141 ircAddr string
142 password string
143 initChannels []string // channels to join on connect
144 channelConfigs map[string]ChannelConfig // keyed by channel name
145 defaultConfig ChannelConfig
146 mu sync.RWMutex
147 channels map[string]*channelState
148 log *slog.Logger
@@ -162,15 +163,16 @@
163 Record(ActionRecord)
164 }
165
166 // New creates a warden bot. channelConfigs overrides per-channel limits;
167 // defaultConfig is used for channels not in the map.
168 func New(ircAddr, password string, channels []string, channelConfigs map[string]ChannelConfig, defaultConfig ChannelConfig, log *slog.Logger) *Bot {
169 defaultConfig.defaults()
170 return &Bot{
171 ircAddr: ircAddr,
172 password: password,
173 initChannels: channels,
174 channelConfigs: channelConfigs,
175 defaultConfig: defaultConfig,
176 channels: make(map[string]*channelState),
177 log: log,
178 }
@@ -197,16 +199,18 @@
199 PingTimeout: 30 * time.Second,
200 SSL: false,
201 })
202
203 c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
204 for _, ch := range b.initChannels {
205 cl.Cmd.Join(ch)
206 }
207 for ch := range b.channelConfigs {
208 cl.Cmd.Join(ch)
209 }
210 if b.log != nil {
211 b.log.Info("warden connected", "channels", b.initChannels)
212 }
213 })
214
215 c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
216 if ch := e.Last(); strings.HasPrefix(ch, "#") {
217
--- internal/bots/warden/warden_test.go
+++ internal/bots/warden/warden_test.go
@@ -6,11 +6,11 @@
66
77
"github.com/conflicthq/scuttlebot/internal/bots/warden"
88
)
99
1010
func newBot() *warden.Bot {
11
- return warden.New("localhost:6667", "pass",
11
+ return warden.New("localhost:6667", "pass", nil,
1212
map[string]warden.ChannelConfig{
1313
"#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second},
1414
},
1515
warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5},
1616
nil,
@@ -31,11 +31,11 @@
3131
}
3232
}
3333
3434
func TestChannelConfigDefaults(t *testing.T) {
3535
// Zero-value config should get sane defaults applied.
36
- b := warden.New("localhost:6667", "pass",
36
+ b := warden.New("localhost:6667", "pass", nil,
3737
nil,
3838
warden.ChannelConfig{}, // zero — should default
3939
nil,
4040
)
4141
if b == nil {
@@ -50,11 +50,11 @@
5050
cfg := warden.ChannelConfig{
5151
MessagesPerSecond: 10,
5252
Burst: 20,
5353
CoolDown: 30 * time.Second,
5454
}
55
- b := warden.New("localhost:6667", "pass",
55
+ b := warden.New("localhost:6667", "pass", nil,
5656
map[string]warden.ChannelConfig{"#fleet": cfg},
5757
warden.ChannelConfig{},
5858
nil,
5959
)
6060
if b == nil {
6161
--- internal/bots/warden/warden_test.go
+++ internal/bots/warden/warden_test.go
@@ -6,11 +6,11 @@
6
7 "github.com/conflicthq/scuttlebot/internal/bots/warden"
8 )
9
10 func newBot() *warden.Bot {
11 return warden.New("localhost:6667", "pass",
12 map[string]warden.ChannelConfig{
13 "#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second},
14 },
15 warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5},
16 nil,
@@ -31,11 +31,11 @@
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",
37 nil,
38 warden.ChannelConfig{}, // zero — should default
39 nil,
40 )
41 if b == nil {
@@ -50,11 +50,11 @@
50 cfg := warden.ChannelConfig{
51 MessagesPerSecond: 10,
52 Burst: 20,
53 CoolDown: 30 * time.Second,
54 }
55 b := warden.New("localhost:6667", "pass",
56 map[string]warden.ChannelConfig{"#fleet": cfg},
57 warden.ChannelConfig{},
58 nil,
59 )
60 if b == nil {
61
--- internal/bots/warden/warden_test.go
+++ internal/bots/warden/warden_test.go
@@ -6,11 +6,11 @@
6
7 "github.com/conflicthq/scuttlebot/internal/bots/warden"
8 )
9
10 func newBot() *warden.Bot {
11 return warden.New("localhost:6667", "pass", nil,
12 map[string]warden.ChannelConfig{
13 "#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second},
14 },
15 warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5},
16 nil,
@@ -31,11 +31,11 @@
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", nil,
37 nil,
38 warden.ChannelConfig{}, // zero — should default
39 nil,
40 )
41 if b == nil {
@@ -50,11 +50,11 @@
50 cfg := warden.ChannelConfig{
51 MessagesPerSecond: 10,
52 Burst: 20,
53 CoolDown: 30 * time.Second,
54 }
55 b := warden.New("localhost:6667", "pass", nil,
56 map[string]warden.ChannelConfig{"#fleet": cfg},
57 warden.ChannelConfig{},
58 nil,
59 )
60 if b == nil {
61

Keyboard Shortcuts

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