ScuttleBot

scuttlebot / internal / bots / manager / manager.go
Blame History Raw 501 lines
1
// Package manager starts and stops system bots based on policy configuration.
2
package manager
3
4
import (
5
"context"
6
"crypto/rand"
7
"encoding/hex"
8
"encoding/json"
9
"fmt"
10
"log/slog"
11
"os"
12
"path/filepath"
13
"strings"
14
"time"
15
16
"github.com/conflicthq/scuttlebot/internal/bots/auditbot"
17
"github.com/conflicthq/scuttlebot/internal/bots/herald"
18
"github.com/conflicthq/scuttlebot/internal/bots/oracle"
19
"github.com/conflicthq/scuttlebot/internal/bots/scribe"
20
"github.com/conflicthq/scuttlebot/internal/bots/scroll"
21
"github.com/conflicthq/scuttlebot/internal/bots/sentinel"
22
"github.com/conflicthq/scuttlebot/internal/bots/shepherd"
23
"github.com/conflicthq/scuttlebot/internal/bots/snitch"
24
"github.com/conflicthq/scuttlebot/internal/bots/steward"
25
"github.com/conflicthq/scuttlebot/internal/bots/systembot"
26
"github.com/conflicthq/scuttlebot/internal/bots/warden"
27
"github.com/conflicthq/scuttlebot/internal/llm"
28
)
29
30
// scribeHistoryAdapter adapts scribe.FileStore to oracle.HistoryFetcher.
31
type scribeHistoryAdapter struct {
32
store *scribe.FileStore
33
}
34
35
func (a *scribeHistoryAdapter) Query(channel string, limit int) ([]oracle.HistoryEntry, error) {
36
entries, err := a.store.Query(channel, limit)
37
if err != nil {
38
return nil, err
39
}
40
out := make([]oracle.HistoryEntry, len(entries))
41
for i, e := range entries {
42
out[i] = oracle.HistoryEntry{
43
Nick: e.Nick,
44
MessageType: e.MessageType,
45
Raw: e.Raw,
46
}
47
}
48
return out, nil
49
}
50
51
// BotSpec mirrors api.BehaviorConfig without importing the api package.
52
type BotSpec struct {
53
ID string
54
Nick string
55
Enabled bool
56
JoinAllChannels bool
57
RequiredChannels []string
58
Config map[string]any
59
}
60
61
// Provisioner can register and change passwords for IRC accounts.
62
type Provisioner interface {
63
RegisterAccount(name, pass string) error
64
ChangePassword(name, pass string) error
65
}
66
67
// ChannelLister can enumerate active IRC channels.
68
type ChannelLister interface {
69
ListChannels() ([]string, error)
70
}
71
72
// bot is the common interface all bots satisfy.
73
type bot interface {
74
Start(ctx context.Context) error
75
}
76
77
// Manager starts and stops bots based on BotSpec slices.
78
type Manager struct {
79
ircAddr string
80
dataDir string
81
prov Provisioner
82
channels ChannelLister
83
log *slog.Logger
84
passwords map[string]string // nick → password, persisted
85
running map[string]context.CancelFunc
86
}
87
88
// New creates a Manager.
89
func New(ircAddr, dataDir string, prov Provisioner, channels ChannelLister, log *slog.Logger) *Manager {
90
m := &Manager{
91
ircAddr: ircAddr,
92
dataDir: dataDir,
93
prov: prov,
94
channels: channels,
95
log: log,
96
passwords: make(map[string]string),
97
running: make(map[string]context.CancelFunc),
98
}
99
_ = m.loadPasswords()
100
return m
101
}
102
103
// Running returns the nicks of currently running bots.
104
func (m *Manager) Running() []string {
105
out := make([]string, 0, len(m.running))
106
for nick := range m.running {
107
out = append(out, nick)
108
}
109
return out
110
}
111
112
// Sync starts enabled+not-running bots and stops disabled+running bots.
113
func (m *Manager) Sync(ctx context.Context, specs []BotSpec) {
114
desired := make(map[string]BotSpec, len(specs))
115
for _, s := range specs {
116
desired[s.Nick] = s
117
}
118
119
// Stop bots that are running but should be disabled.
120
for nick, cancel := range m.running {
121
spec, ok := desired[nick]
122
if !ok || !spec.Enabled {
123
m.log.Info("manager: stopping bot", "nick", nick)
124
cancel()
125
delete(m.running, nick)
126
}
127
}
128
129
// Start bots that are enabled but not running.
130
for _, spec := range specs {
131
if !spec.Enabled {
132
continue
133
}
134
if _, running := m.running[spec.Nick]; running {
135
continue
136
}
137
138
pass, err := m.ensurePassword(spec.Nick)
139
if err != nil {
140
m.log.Error("manager: ensure password", "nick", spec.Nick, "err", err)
141
continue
142
}
143
144
if err := m.ensureAccount(spec.Nick, pass); err != nil {
145
m.log.Error("manager: ensure account", "nick", spec.Nick, "err", err)
146
continue
147
}
148
149
channels, err := m.resolveChannels(spec)
150
if err != nil {
151
m.log.Warn("manager: list channels failed, using required", "nick", spec.Nick, "err", err)
152
}
153
154
b, err := m.buildBot(spec, pass, channels)
155
if err != nil {
156
m.log.Error("manager: build bot", "nick", spec.Nick, "err", err)
157
continue
158
}
159
if b == nil {
160
continue
161
}
162
163
botCtx, cancel := context.WithCancel(ctx)
164
m.running[spec.Nick] = cancel
165
166
go func(nick string, b bot, ctx context.Context) {
167
m.log.Info("manager: starting bot", "nick", nick)
168
if err := b.Start(ctx); err != nil && ctx.Err() == nil {
169
m.log.Error("manager: bot exited with error", "nick", nick, "err", err)
170
}
171
}(spec.Nick, b, botCtx)
172
}
173
}
174
175
func (m *Manager) resolveChannels(spec BotSpec) ([]string, error) {
176
if spec.JoinAllChannels {
177
ch, err := m.channels.ListChannels()
178
if err != nil {
179
return spec.RequiredChannels, err
180
}
181
return ch, nil
182
}
183
return spec.RequiredChannels, nil
184
}
185
186
func (m *Manager) ensurePassword(nick string) (string, error) {
187
if pass, ok := m.passwords[nick]; ok {
188
return pass, nil
189
}
190
pass, err := genPassword()
191
if err != nil {
192
return "", err
193
}
194
m.passwords[nick] = pass
195
if err := m.savePasswords(); err != nil {
196
return "", err
197
}
198
return pass, nil
199
}
200
201
func (m *Manager) ensureAccount(nick, pass string) error {
202
if err := m.prov.RegisterAccount(nick, pass); err != nil {
203
if strings.Contains(err.Error(), "ACCOUNT_EXISTS") {
204
return m.prov.ChangePassword(nick, pass)
205
}
206
return err
207
}
208
return nil
209
}
210
211
func (m *Manager) buildBot(spec BotSpec, pass string, channels []string) (bot, error) {
212
cfg := spec.Config
213
switch spec.ID {
214
case "scribe":
215
store := scribe.NewFileStore(scribe.FileStoreConfig{
216
Dir: cfgStr(cfg, "dir", filepath.Join(m.dataDir, "logs", "scribe")),
217
Format: cfgStr(cfg, "format", "jsonl"),
218
Rotation: cfgStr(cfg, "rotation", "none"),
219
MaxSizeMB: cfgInt(cfg, "max_size_mb", 0),
220
PerChannel: cfgBool(cfg, "per_channel", false),
221
MaxAgeDays: cfgInt(cfg, "max_age_days", 0),
222
})
223
return scribe.New(m.ircAddr, pass, channels, store, m.log), nil
224
225
case "auditbot":
226
return auditbot.New(m.ircAddr, pass, channels, nil, &auditbot.MemoryStore{}, m.log), nil
227
228
case "snitch":
229
return snitch.New(snitch.Config{
230
IRCAddr: m.ircAddr,
231
Nick: spec.Nick,
232
Password: pass,
233
AlertChannel: cfgStr(cfg, "alert_channel", ""),
234
AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
235
FloodMessages: cfgInt(cfg, "flood_messages", 10),
236
FloodWindow: time.Duration(cfgInt(cfg, "flood_window_sec", 5)) * time.Second,
237
JoinPartThreshold: cfgInt(cfg, "join_part_threshold", 5),
238
JoinPartWindow: time.Duration(cfgInt(cfg, "join_part_window_sec", 30)) * time.Second,
239
Channels: channels,
240
}, m.log), nil
241
242
case "warden":
243
return warden.New(m.ircAddr, pass, channels, nil, warden.ChannelConfig{
244
MessagesPerSecond: cfgFloat(cfg, "messages_per_second", 5),
245
Burst: cfgInt(cfg, "burst", 10),
246
}, m.log), nil
247
248
case "scroll":
249
return scroll.New(m.ircAddr, pass, channels, &scribe.MemoryStore{}, m.log), nil
250
251
case "systembot":
252
return systembot.New(m.ircAddr, pass, channels, &systembot.MemoryStore{}, m.log), nil
253
254
case "herald":
255
return herald.New(m.ircAddr, pass, channels, herald.RouteConfig{
256
DefaultChannel: cfgStr(cfg, "default_channel", ""),
257
}, cfgFloat(cfg, "rate_limit", 1), cfgInt(cfg, "burst", 5), m.log), nil
258
259
case "oracle":
260
// Resolve API key — prefer direct api_key, fall back to api_key_env for
261
// backwards compatibility with existing configs.
262
apiKey := cfgStr(cfg, "api_key", "")
263
if apiKey == "" {
264
apiKeyEnv := cfgStr(cfg, "api_key_env", "")
265
if apiKeyEnv != "" {
266
apiKey = os.Getenv(apiKeyEnv)
267
}
268
}
269
270
llmCfg := llm.BackendConfig{
271
Backend: cfgStr(cfg, "backend", "openai"),
272
APIKey: apiKey,
273
BaseURL: cfgStr(cfg, "base_url", ""),
274
Model: cfgStr(cfg, "model", ""),
275
Region: cfgStr(cfg, "region", ""),
276
AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
277
AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
278
}
279
provider, err := llm.New(llmCfg)
280
if err != nil {
281
return nil, fmt.Errorf("oracle: build llm provider: %w", err)
282
}
283
284
// Read from the same dir scribe writes to.
285
scribeDir := cfgStr(cfg, "scribe_dir", filepath.Join(m.dataDir, "logs", "scribe"))
286
fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: scribeDir, Format: "jsonl"})
287
history := &scribeHistoryAdapter{store: fs}
288
289
return oracle.New(m.ircAddr, pass, channels, history, provider, m.log), nil
290
291
case "sentinel":
292
apiKey := cfgStr(cfg, "api_key", "")
293
if apiKey == "" {
294
if env := cfgStr(cfg, "api_key_env", ""); env != "" {
295
apiKey = os.Getenv(env)
296
}
297
}
298
llmCfg := llm.BackendConfig{
299
Backend: cfgStr(cfg, "backend", "openai"),
300
APIKey: apiKey,
301
BaseURL: cfgStr(cfg, "base_url", ""),
302
Model: cfgStr(cfg, "model", ""),
303
Region: cfgStr(cfg, "region", ""),
304
AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
305
AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
306
}
307
provider, err := llm.New(llmCfg)
308
if err != nil {
309
return nil, fmt.Errorf("sentinel: build llm provider: %w", err)
310
}
311
return sentinel.New(sentinel.Config{
312
IRCAddr: m.ircAddr,
313
Nick: spec.Nick,
314
Password: pass,
315
ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
316
DMOperators: cfgBool(cfg, "dm_operators", false),
317
AlertNicks: splitCSV(cfgStr(cfg, "alert_nicks", "")),
318
Policy: cfgStr(cfg, "policy", ""),
319
WindowSize: cfgInt(cfg, "window_size", 20),
320
WindowAge: time.Duration(cfgInt(cfg, "window_age_sec", 300)) * time.Second,
321
CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 600)) * time.Second,
322
MinSeverity: cfgStr(cfg, "min_severity", "medium"),
323
Channels: channels,
324
}, provider, m.log), nil
325
326
case "steward":
327
return steward.New(steward.Config{
328
IRCAddr: m.ircAddr,
329
Nick: spec.Nick,
330
Password: pass,
331
ModChannel: cfgStr(cfg, "mod_channel", "#moderation"),
332
OperatorNicks: splitCSV(cfgStr(cfg, "operator_nicks", "")),
333
DMOnAction: cfgBool(cfg, "dm_on_action", false),
334
AutoAct: cfgBool(cfg, "auto_act", true),
335
MuteDuration: time.Duration(cfgInt(cfg, "mute_duration_sec", 600)) * time.Second,
336
WarnOnLow: cfgBool(cfg, "warn_on_low", true),
337
CooldownPerNick: time.Duration(cfgInt(cfg, "cooldown_sec", 300)) * time.Second,
338
Channels: channels,
339
}, m.log), nil
340
341
case "shepherd":
342
apiKey := cfgStr(cfg, "api_key", "")
343
if apiKey == "" {
344
if env := cfgStr(cfg, "api_key_env", ""); env != "" {
345
apiKey = os.Getenv(env)
346
}
347
}
348
var provider shepherd.LLMProvider
349
if apiKey != "" {
350
llmCfg := llm.BackendConfig{
351
Backend: cfgStr(cfg, "backend", "openai"),
352
APIKey: apiKey,
353
BaseURL: cfgStr(cfg, "base_url", ""),
354
Model: cfgStr(cfg, "model", ""),
355
Region: cfgStr(cfg, "region", ""),
356
AWSKeyID: cfgStr(cfg, "aws_key_id", ""),
357
AWSSecretKey: cfgStr(cfg, "aws_secret_key", ""),
358
}
359
p, err := llm.New(llmCfg)
360
if err != nil {
361
return nil, fmt.Errorf("shepherd: build llm provider: %w", err)
362
}
363
provider = p
364
}
365
checkinSec := cfgInt(cfg, "checkin_interval_sec", 0)
366
return shepherd.New(shepherd.Config{
367
IRCAddr: m.ircAddr,
368
Nick: spec.Nick,
369
Password: pass,
370
Channels: channels,
371
ReportChannel: cfgStr(cfg, "report_channel", "#ops"),
372
CheckinInterval: time.Duration(checkinSec) * time.Second,
373
GoalSource: cfgStr(cfg, "goal_source", ""),
374
}, provider, m.log), nil
375
376
default:
377
return nil, fmt.Errorf("unknown bot ID %q", spec.ID)
378
}
379
}
380
381
// passwordsPath returns the path for the passwords file.
382
func (m *Manager) passwordsPath() string {
383
return filepath.Join(m.dataDir, "bot_passwords.json")
384
}
385
386
func (m *Manager) loadPasswords() error {
387
raw, err := os.ReadFile(m.passwordsPath())
388
if os.IsNotExist(err) {
389
return nil
390
}
391
if err != nil {
392
return err
393
}
394
return json.Unmarshal(raw, &m.passwords)
395
}
396
397
func (m *Manager) savePasswords() error {
398
raw, err := json.MarshalIndent(m.passwords, "", " ")
399
if err != nil {
400
return err
401
}
402
if err := os.MkdirAll(m.dataDir, 0755); err != nil {
403
return err
404
}
405
return os.WriteFile(m.passwordsPath(), raw, 0600)
406
}
407
408
func genPassword() (string, error) {
409
b := make([]byte, 16)
410
if _, err := rand.Read(b); err != nil {
411
return "", fmt.Errorf("manager: generate password: %w", err)
412
}
413
return hex.EncodeToString(b), nil
414
}
415
416
// splitCSV splits a comma-separated string into a slice, trimming spaces and
417
// filtering empty strings.
418
func splitCSV(s string) []string {
419
if s == "" {
420
return nil
421
}
422
parts := strings.Split(s, ",")
423
out := make([]string, 0, len(parts))
424
for _, p := range parts {
425
if p = strings.TrimSpace(p); p != "" {
426
out = append(out, p)
427
}
428
}
429
return out
430
}
431
432
// Config helper extractors.
433
434
func cfgStr(cfg map[string]any, key, def string) string {
435
if cfg == nil {
436
return def
437
}
438
v, ok := cfg[key]
439
if !ok {
440
return def
441
}
442
s, ok := v.(string)
443
if !ok {
444
return def
445
}
446
return s
447
}
448
449
func cfgInt(cfg map[string]any, key string, def int) int {
450
if cfg == nil {
451
return def
452
}
453
v, ok := cfg[key]
454
if !ok {
455
return def
456
}
457
switch n := v.(type) {
458
case int:
459
return n
460
case int64:
461
return int(n)
462
case float64:
463
return int(n)
464
}
465
return def
466
}
467
468
func cfgBool(cfg map[string]any, key string, def bool) bool {
469
if cfg == nil {
470
return def
471
}
472
v, ok := cfg[key]
473
if !ok {
474
return def
475
}
476
b, ok := v.(bool)
477
if !ok {
478
return def
479
}
480
return b
481
}
482
483
func cfgFloat(cfg map[string]any, key string, def float64) float64 {
484
if cfg == nil {
485
return def
486
}
487
v, ok := cfg[key]
488
if !ok {
489
return def
490
}
491
switch n := v.(type) {
492
case float64:
493
return n
494
case int:
495
return float64(n)
496
case int64:
497
return float64(n)
498
}
499
return def
500
}
501

Keyboard Shortcuts

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