ScuttleBot
Merge pull request #130 from ConflictHQ/fix/71-bot-channel-join fix: all system bots join channels on connect
Commit
2bdb8bbe4af0139d0acf5e3fc0edf8f63ea76c4ee18e499f1b05718148e2718a
Parent
94d9bef464f37c9…
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 |