ScuttleBot
fix: all system bots join channels on connect (#71) 7 of 10 bots connected to IRC but never joined channels because their constructors didn't accept or use the channel list from the manager. Add channels parameter to herald, oracle, scroll, warden constructors and Channels field to snitch, sentinel, steward Config structs. Each bot's CONNECTED handler now joins channels on connect. Also fix herald default policy to include join_all_channels: true.
Commit
d605172b52ae0048189f5c95d7e506b7ae7895c3d54cbfa50731f9019fe5cbd9
Parent
699b1090c0c49b7…
12 files changed
+5
-4
+8
-3
+2
-2
+7
-4
+8
-3
+1
-1
+8
-3
+8
-2
+8
-2
+8
-2
+7
-3
+3
-3
~
internal/api/policies.go
~
internal/bots/herald/herald.go
~
internal/bots/herald/herald_test.go
~
internal/bots/manager/manager.go
~
internal/bots/oracle/oracle.go
~
internal/bots/oracle/oracle_test.go
~
internal/bots/scroll/scroll.go
~
internal/bots/sentinel/sentinel.go
~
internal/bots/snitch/snitch.go
~
internal/bots/steward/steward.go
~
internal/bots/warden/warden.go
~
internal/bots/warden/warden_test.go
+5
-4
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -95,14 +95,15 @@ | ||
| 95 | 95 | Description: "Records all channel messages to a structured log store.", |
| 96 | 96 | Nick: "scribe", |
| 97 | 97 | JoinAllChannels: true, |
| 98 | 98 | }, |
| 99 | 99 | { |
| 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, | |
| 104 | 105 | }, |
| 105 | 106 | { |
| 106 | 107 | ID: "oracle", |
| 107 | 108 | Name: "Oracle", |
| 108 | 109 | Description: "On-demand channel summarisation via DM using an LLM.", |
| 109 | 110 |
| --- 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 |
+8
-3
| --- internal/bots/herald/herald.go | ||
| +++ internal/bots/herald/herald.go | ||
| @@ -86,10 +86,11 @@ | ||
| 86 | 86 | |
| 87 | 87 | // Bot is the herald bot. |
| 88 | 88 | type Bot struct { |
| 89 | 89 | ircAddr string |
| 90 | 90 | password string |
| 91 | + channels []string | |
| 91 | 92 | routes RouteConfig |
| 92 | 93 | limiter *RateLimiter |
| 93 | 94 | queue chan Event |
| 94 | 95 | log *slog.Logger |
| 95 | 96 | client *girc.Client |
| @@ -97,20 +98,21 @@ | ||
| 97 | 98 | |
| 98 | 99 | const defaultQueueSize = 256 |
| 99 | 100 | |
| 100 | 101 | // New creates a herald bot. ratePerSec and burst configure the token-bucket |
| 101 | 102 | // 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 { | |
| 103 | 104 | if ratePerSec <= 0 { |
| 104 | 105 | ratePerSec = 5 |
| 105 | 106 | } |
| 106 | 107 | if burst <= 0 { |
| 107 | 108 | burst = 20 |
| 108 | 109 | } |
| 109 | 110 | return &Bot{ |
| 110 | 111 | ircAddr: ircAddr, |
| 111 | 112 | password: password, |
| 113 | + channels: channels, | |
| 112 | 114 | routes: routes, |
| 113 | 115 | limiter: newRateLimiter(ratePerSec, burst), |
| 114 | 116 | queue: make(chan Event, defaultQueueSize), |
| 115 | 117 | log: log, |
| 116 | 118 | } |
| @@ -148,13 +150,16 @@ | ||
| 148 | 150 | PingDelay: 30 * time.Second, |
| 149 | 151 | PingTimeout: 30 * time.Second, |
| 150 | 152 | SSL: false, |
| 151 | 153 | }) |
| 152 | 154 | |
| 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 | + } | |
| 154 | 159 | if b.log != nil { |
| 155 | - b.log.Info("herald connected") | |
| 160 | + b.log.Info("herald connected", "channels", b.channels) | |
| 156 | 161 | } |
| 157 | 162 | }) |
| 158 | 163 | |
| 159 | 164 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 160 | 165 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 161 | 166 |
| --- 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | "github.com/conflicthq/scuttlebot/internal/bots/herald" |
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | 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) | |
| 12 | 12 | } |
| 13 | 13 | |
| 14 | 14 | func TestBotName(t *testing.T) { |
| 15 | 15 | b := newBot(herald.RouteConfig{}) |
| 16 | 16 | if b.Name() != "herald" { |
| @@ -42,11 +42,11 @@ | ||
| 42 | 42 | "ci.build.": "#builds", |
| 43 | 43 | "deploy.": "#deploys", |
| 44 | 44 | }, |
| 45 | 45 | DefaultChannel: "#alerts", |
| 46 | 46 | } |
| 47 | - b := herald.New("localhost:6667", "pass", routes, 5, 20, nil) | |
| 47 | + b := herald.New("localhost:6667", "pass", nil, routes, 5, 20, nil) | |
| 48 | 48 | if b == nil { |
| 49 | 49 | t.Fatal("expected non-nil bot") |
| 50 | 50 | } |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 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", 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 |
+7
-4
| --- internal/bots/manager/manager.go | ||
| +++ internal/bots/manager/manager.go | ||
| @@ -233,26 +233,27 @@ | ||
| 233 | 233 | AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")), |
| 234 | 234 | FloodMessages: cfgInt(cfg, "flood_messages", 10), |
| 235 | 235 | FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second, |
| 236 | 236 | JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5), |
| 237 | 237 | JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second, |
| 238 | + Channels: channels, | |
| 238 | 239 | }, m.log), nil |
| 239 | 240 | |
| 240 | 241 | case "warden": |
| 241 | - return warden.New(m.ircAddr, pass, nil, warden.ChannelConfig{ | |
| 242 | + return warden.New(m.ircAddr, pass, channels, nil, warden.ChannelConfig{ | |
| 242 | 243 | MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5), |
| 243 | 244 | Burst: cfgInt(cfg, "burst", 10), |
| 244 | 245 | }, m.log), nil |
| 245 | 246 | |
| 246 | 247 | 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 | |
| 248 | 249 | |
| 249 | 250 | case "systembot": |
| 250 | 251 | return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil |
| 251 | 252 | |
| 252 | 253 | case "herald": |
| 253 | - return herald.New(m.ircAddr, pass, herald.RouteConfig{ | |
| 254 | + return herald.New(m.ircAddr, pass, channels, herald.RouteConfig{ | |
| 254 | 255 | DefaultChannel: cfgStr(cfg, "default_channel", ""), |
| 255 | 256 | }, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil |
| 256 | 257 | |
| 257 | 258 | case "oracle": |
| 258 | 259 | // Resolve API key — prefer direct api_key, fall back to api_key_env for |
| @@ -282,11 +283,11 @@ | ||
| 282 | 283 | // Read from the same dir scribe writes to. |
| 283 | 284 | scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe")) |
| 284 | 285 | fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"}) |
| 285 | 286 | history := &scribeHistoryAdapter{store: fs} |
| 286 | 287 | |
| 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 | |
| 288 | 289 | |
| 289 | 290 | case "sentinel": |
| 290 | 291 | apiKey := cfgStr(cfg, "api_key", "") |
| 291 | 292 | if apiKey == "" { |
| 292 | 293 | if env := cfgStr(cfg, "api_key_env", ""); env != "" { |
| @@ -316,10 +317,11 @@ | ||
| 316 | 317 | Policy: cfgStr(cfg, "policy", ""), |
| 317 | 318 | WindowSize: cfgInt(cfg, "window_size", 20), |
| 318 | 319 | WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second, |
| 319 | 320 | CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second, |
| 320 | 321 | MinSeverity: cfgStr(cfg, "min_severity", "medium"), |
| 322 | + Channels: channels, | |
| 321 | 323 | }, provider, m.log), nil |
| 322 | 324 | |
| 323 | 325 | case "steward": |
| 324 | 326 | return steward.New(steward.Config{ |
| 325 | 327 | IRCAddr: m.ircAddr, |
| @@ -330,10 +332,11 @@ | ||
| 330 | 332 | DMOnAction: cfgBool(cfg, "dm_on_action", false), |
| 331 | 333 | AutoAct: cfgBool(cfg, "auto_act", true), |
| 332 | 334 | MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second, |
| 333 | 335 | WarnOnLow: cfgBool(cfg, "warn_on_low", true), |
| 334 | 336 | CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second, |
| 337 | + Channels: channels, | |
| 335 | 338 | }, m.log), nil |
| 336 | 339 | |
| 337 | 340 | default: |
| 338 | 341 | return nil, fmt.Errorf("unknown bot ID %q", spec.ID) |
| 339 | 342 | } |
| 340 | 343 |
| --- 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 |
+8
-3
| --- internal/bots/oracle/oracle.go | ||
| +++ internal/bots/oracle/oracle.go | ||
| @@ -117,23 +117,25 @@ | ||
| 117 | 117 | |
| 118 | 118 | // Bot is the oracle bot. |
| 119 | 119 | type Bot struct { |
| 120 | 120 | ircAddr string |
| 121 | 121 | password string |
| 122 | + channels []string | |
| 122 | 123 | history HistoryFetcher |
| 123 | 124 | llm LLMProvider |
| 124 | 125 | log *slog.Logger |
| 125 | 126 | mu sync.Mutex |
| 126 | 127 | lastReq map[string]time.Time // nick → last request time |
| 127 | 128 | client *girc.Client |
| 128 | 129 | } |
| 129 | 130 | |
| 130 | 131 | // 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 { | |
| 132 | 133 | return &Bot{ |
| 133 | 134 | ircAddr: ircAddr, |
| 134 | 135 | password: password, |
| 136 | + channels: channels, | |
| 135 | 137 | history: history, |
| 136 | 138 | llm: llm, |
| 137 | 139 | log: log, |
| 138 | 140 | lastReq: make(map[string]time.Time), |
| 139 | 141 | } |
| @@ -159,13 +161,16 @@ | ||
| 159 | 161 | PingDelay: 30 * time.Second, |
| 160 | 162 | PingTimeout: 30 * time.Second, |
| 161 | 163 | SSL: false, |
| 162 | 164 | }) |
| 163 | 165 | |
| 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 | + } | |
| 165 | 170 | if b.log != nil { |
| 166 | - b.log.Info("oracle connected") | |
| 171 | + b.log.Info("oracle connected", "channels", b.channels) | |
| 167 | 172 | } |
| 168 | 173 | }) |
| 169 | 174 | |
| 170 | 175 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 171 | 176 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 172 | 177 |
| --- 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 @@ | ||
| 91 | 91 | } |
| 92 | 92 | |
| 93 | 93 | // --- Bot construction --- |
| 94 | 94 | |
| 95 | 95 | func TestBotName(t *testing.T) { |
| 96 | - b := oracle.New("localhost:6667", "pass", | |
| 96 | + b := oracle.New("localhost:6667", "pass", nil, | |
| 97 | 97 | newHistory("#fleet", nil), |
| 98 | 98 | &oracle.StubProvider{Response: "summary"}, |
| 99 | 99 | nil, |
| 100 | 100 | ) |
| 101 | 101 | if b.Name() != "oracle" { |
| 102 | 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", |
| 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 |
+8
-3
| --- internal/bots/scroll/scroll.go | ||
| +++ internal/bots/scroll/scroll.go | ||
| @@ -34,21 +34,23 @@ | ||
| 34 | 34 | |
| 35 | 35 | // Bot is the scroll history-replay bot. |
| 36 | 36 | type Bot struct { |
| 37 | 37 | ircAddr string |
| 38 | 38 | password string |
| 39 | + channels []string | |
| 39 | 40 | store scribe.Store |
| 40 | 41 | log *slog.Logger |
| 41 | 42 | client *girc.Client |
| 42 | 43 | rateLimit sync.Map // nick → last request time |
| 43 | 44 | } |
| 44 | 45 | |
| 45 | 46 | // 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 { | |
| 47 | 48 | return &Bot{ |
| 48 | 49 | ircAddr: ircAddr, |
| 49 | 50 | password: password, |
| 51 | + channels: channels, | |
| 50 | 52 | store: store, |
| 51 | 53 | log: log, |
| 52 | 54 | } |
| 53 | 55 | } |
| 54 | 56 | |
| @@ -72,12 +74,15 @@ | ||
| 72 | 74 | PingDelay: 30 * time.Second, |
| 73 | 75 | PingTimeout: 30 * time.Second, |
| 74 | 76 | SSL: false, |
| 75 | 77 | }) |
| 76 | 78 | |
| 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) | |
| 79 | 84 | }) |
| 80 | 85 | |
| 81 | 86 | // Only respond to DMs — ignore anything in a channel. |
| 82 | 87 | c.Handlers.AddBg(girc.PRIVMSG, func(client *girc.Client, e girc.Event) { |
| 83 | 88 | if len(e.Params) < 1 { |
| 84 | 89 |
| --- 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 @@ | ||
| 61 | 61 | // Default: 10 minutes. |
| 62 | 62 | CooldownPerNick time.Duration |
| 63 | 63 | // MinSeverity controls which severities trigger a report. |
| 64 | 64 | // "low", "medium", "high" — default: "medium". |
| 65 | 65 | MinSeverity string |
| 66 | + | |
| 67 | + // Channels is the list of channels to join on connect. | |
| 68 | + Channels []string | |
| 66 | 69 | } |
| 67 | 70 | |
| 68 | 71 | func (c *Config) setDefaults() { |
| 69 | 72 | if c.Nick == "" { |
| 70 | 73 | c.Nick = defaultNick |
| @@ -143,14 +146,17 @@ | ||
| 143 | 146 | PingDelay: 30 * time.Second, |
| 144 | 147 | PingTimeout: 30 * time.Second, |
| 145 | 148 | }) |
| 146 | 149 | |
| 147 | 150 | 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) | |
| 150 | 153 | } |
| 151 | 154 | cl.Cmd.Join(b.cfg.ModChannel) |
| 155 | + if b.log != nil { | |
| 156 | + b.log.Info("sentinel connected", "channels", b.cfg.Channels) | |
| 157 | + } | |
| 152 | 158 | }) |
| 153 | 159 | |
| 154 | 160 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 155 | 161 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 156 | 162 | cl.Cmd.Join(ch) |
| 157 | 163 |
| --- 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 |
+8
-2
| --- internal/bots/snitch/snitch.go | ||
| +++ internal/bots/snitch/snitch.go | ||
| @@ -45,10 +45,13 @@ | ||
| 45 | 45 | FloodWindow time.Duration |
| 46 | 46 | // JoinPartThreshold is join+part events in JoinPartWindow to trigger alert. Default: 5. |
| 47 | 47 | JoinPartThreshold int |
| 48 | 48 | // JoinPartWindow is the rolling window for join/part cycling. Default: 30s. |
| 49 | 49 | JoinPartWindow time.Duration |
| 50 | + | |
| 51 | + // Channels is the list of channels to join on connect. | |
| 52 | + Channels []string | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | func (c *Config) setDefaults() { |
| 53 | 56 | if c.Nick == "" { |
| 54 | 57 | c.Nick = defaultNick |
| @@ -132,16 +135,19 @@ | ||
| 132 | 135 | PingDelay: 30 * time.Second, |
| 133 | 136 | PingTimeout: 30 * time.Second, |
| 134 | 137 | }) |
| 135 | 138 | |
| 136 | 139 | 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) | |
| 139 | 142 | } |
| 140 | 143 | if b.cfg.AlertChannel != "" { |
| 141 | 144 | cl.Cmd.Join(b.cfg.AlertChannel) |
| 142 | 145 | } |
| 146 | + if b.log != nil { | |
| 147 | + b.log.Info("snitch connected", "channels", b.cfg.Channels) | |
| 148 | + } | |
| 143 | 149 | }) |
| 144 | 150 | |
| 145 | 151 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 146 | 152 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 147 | 153 | cl.Cmd.Join(ch) |
| 148 | 154 |
| --- 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 |
+8
-2
| --- internal/bots/steward/steward.go | ||
| +++ internal/bots/steward/steward.go | ||
| @@ -65,10 +65,13 @@ | ||
| 65 | 65 | DMOnAction bool |
| 66 | 66 | |
| 67 | 67 | // CooldownPerNick is the minimum time between automated actions on the |
| 68 | 68 | // same nick. Default: 5 minutes. |
| 69 | 69 | CooldownPerNick time.Duration |
| 70 | + | |
| 71 | + // Channels is the list of channels to join on connect. | |
| 72 | + Channels []string | |
| 70 | 73 | } |
| 71 | 74 | |
| 72 | 75 | func (c *Config) setDefaults() { |
| 73 | 76 | if c.Nick == "" { |
| 74 | 77 | c.Nick = defaultNick |
| @@ -127,14 +130,17 @@ | ||
| 127 | 130 | PingDelay: 30 * time.Second, |
| 128 | 131 | PingTimeout: 30 * time.Second, |
| 129 | 132 | }) |
| 130 | 133 | |
| 131 | 134 | 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) | |
| 134 | 137 | } |
| 135 | 138 | cl.Cmd.Join(b.cfg.ModChannel) |
| 139 | + if b.log != nil { | |
| 140 | + b.log.Info("steward connected", "channels", b.cfg.Channels) | |
| 141 | + } | |
| 136 | 142 | }) |
| 137 | 143 | |
| 138 | 144 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 139 | 145 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 140 | 146 | cl.Cmd.Join(ch) |
| 141 | 147 |
| --- 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 |
+7
-3
| --- internal/bots/warden/warden.go | ||
| +++ internal/bots/warden/warden.go | ||
| @@ -138,10 +138,11 @@ | ||
| 138 | 138 | |
| 139 | 139 | // Bot is the warden. |
| 140 | 140 | type Bot struct { |
| 141 | 141 | ircAddr string |
| 142 | 142 | password string |
| 143 | + initChannels []string // channels to join on connect | |
| 143 | 144 | channelConfigs map[string]ChannelConfig // keyed by channel name |
| 144 | 145 | defaultConfig ChannelConfig |
| 145 | 146 | mu sync.RWMutex |
| 146 | 147 | channels map[string]*channelState |
| 147 | 148 | log *slog.Logger |
| @@ -162,15 +163,16 @@ | ||
| 162 | 163 | Record(ActionRecord) |
| 163 | 164 | } |
| 164 | 165 | |
| 165 | 166 | // New creates a warden bot. channelConfigs overrides per-channel limits; |
| 166 | 167 | // 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 { | |
| 168 | 169 | defaultConfig.defaults() |
| 169 | 170 | return &Bot{ |
| 170 | 171 | ircAddr: ircAddr, |
| 171 | 172 | password: password, |
| 173 | + initChannels: channels, | |
| 172 | 174 | channelConfigs: channelConfigs, |
| 173 | 175 | defaultConfig: defaultConfig, |
| 174 | 176 | channels: make(map[string]*channelState), |
| 175 | 177 | log: log, |
| 176 | 178 | } |
| @@ -197,16 +199,18 @@ | ||
| 197 | 199 | PingTimeout: 30 * time.Second, |
| 198 | 200 | SSL: false, |
| 199 | 201 | }) |
| 200 | 202 | |
| 201 | 203 | 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 | + } | |
| 203 | 207 | for ch := range b.channelConfigs { |
| 204 | 208 | cl.Cmd.Join(ch) |
| 205 | 209 | } |
| 206 | 210 | if b.log != nil { |
| 207 | - b.log.Info("warden connected") | |
| 211 | + b.log.Info("warden connected", "channels", b.initChannels) | |
| 208 | 212 | } |
| 209 | 213 | }) |
| 210 | 214 | |
| 211 | 215 | c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) { |
| 212 | 216 | if ch := e.Last(); strings.HasPrefix(ch, "#") { |
| 213 | 217 |
| --- 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | "github.com/conflicthq/scuttlebot/internal/bots/warden" |
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | 10 | func newBot() *warden.Bot { |
| 11 | - return warden.New("localhost:6667", "pass", | |
| 11 | + return warden.New("localhost:6667", "pass", nil, | |
| 12 | 12 | map[string]warden.ChannelConfig{ |
| 13 | 13 | "#fleet": {MessagesPerSecond: 5, Burst: 10, CoolDown: 60 * time.Second}, |
| 14 | 14 | }, |
| 15 | 15 | warden.ChannelConfig{MessagesPerSecond: 2, Burst: 5}, |
| 16 | 16 | nil, |
| @@ -31,11 +31,11 @@ | ||
| 31 | 31 | } |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | func TestChannelConfigDefaults(t *testing.T) { |
| 35 | 35 | // Zero-value config should get sane defaults applied. |
| 36 | - b := warden.New("localhost:6667", "pass", | |
| 36 | + b := warden.New("localhost:6667", "pass", nil, | |
| 37 | 37 | nil, |
| 38 | 38 | warden.ChannelConfig{}, // zero — should default |
| 39 | 39 | nil, |
| 40 | 40 | ) |
| 41 | 41 | if b == nil { |
| @@ -50,11 +50,11 @@ | ||
| 50 | 50 | cfg := warden.ChannelConfig{ |
| 51 | 51 | MessagesPerSecond: 10, |
| 52 | 52 | Burst: 20, |
| 53 | 53 | CoolDown: 30 * time.Second, |
| 54 | 54 | } |
| 55 | - b := warden.New("localhost:6667", "pass", | |
| 55 | + b := warden.New("localhost:6667", "pass", nil, | |
| 56 | 56 | map[string]warden.ChannelConfig{"#fleet": cfg}, |
| 57 | 57 | warden.ChannelConfig{}, |
| 58 | 58 | nil, |
| 59 | 59 | ) |
| 60 | 60 | if b == nil { |
| 61 | 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", |
| 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 |