ScuttleBot

scuttlebot / internal / bots / sentinel / sentinel.go
Blame History Raw 422 lines
1
// Package sentinel implements the sentinel bot — an LLM-powered channel
2
// observer that detects policy violations and posts structured incident
3
// reports to a moderation channel.
4
//
5
// Sentinel never takes enforcement action. It watches, judges, and reports.
6
// All reports are human-readable and posted to a configured mod channel
7
// (e.g. #moderation) so the full audit trail is IRC-native and observable.
8
//
9
// Reports have the form:
10
//
11
// [sentinel] incident in #channel | nick: <who> | severity: high | reason: <llm judgment>
12
package sentinel
13
14
import (
15
"context"
16
"fmt"
17
"log/slog"
18
"net"
19
"strconv"
20
"strings"
21
"sync"
22
"time"
23
24
"github.com/lrstanley/girc"
25
26
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
27
)
28
29
const defaultNick = "sentinel"
30
31
// LLMProvider calls a language model to evaluate channel content.
32
type LLMProvider interface {
33
Summarize(ctx context.Context, prompt string) (string, error)
34
}
35
36
// Config controls sentinel's behaviour.
37
type Config struct {
38
// IRCAddr is host:port of the Ergo IRC server.
39
IRCAddr string
40
// Nick is the IRC nick. Default: "sentinel".
41
Nick string
42
// Password is the SASL PLAIN passphrase.
43
Password string
44
45
// ModChannel is where incident reports are posted (e.g. "#moderation").
46
ModChannel string
47
// DMOperators, when true, also sends incident reports as DMs to AlertNicks.
48
DMOperators bool
49
// AlertNicks is the list of operator nicks to DM on incidents.
50
AlertNicks []string
51
52
// Policy is a plain-English description of what sentinel should flag.
53
// Example: "Flag harassment, hate speech, spam, and coordinated manipulation."
54
Policy string
55
56
// WindowSize is how many messages to buffer per channel before analysis.
57
// Default: 20.
58
WindowSize int
59
// WindowAge is the maximum age of buffered messages before a scan is forced.
60
// Default: 5 minutes.
61
WindowAge time.Duration
62
// CooldownPerNick is the minimum time between reports about the same nick.
63
// Default: 10 minutes.
64
CooldownPerNick time.Duration
65
// MinSeverity controls which severities trigger a report.
66
// "low", "medium", "high" — default: "medium".
67
MinSeverity string
68
69
// Channels is the list of channels to join on connect.
70
Channels []string
71
}
72
73
func (c *Config) setDefaults() {
74
if c.Nick == "" {
75
c.Nick = defaultNick
76
}
77
if c.WindowSize == 0 {
78
c.WindowSize = 20
79
}
80
if c.WindowAge == 0 {
81
c.WindowAge = 5 * time.Minute
82
}
83
if c.CooldownPerNick == 0 {
84
c.CooldownPerNick = 10 * time.Minute
85
}
86
if c.MinSeverity == "" {
87
c.MinSeverity = "medium"
88
}
89
if c.Policy == "" {
90
c.Policy = "Flag harassment, hate speech, spam, threats, and coordinated manipulation."
91
}
92
if c.ModChannel == "" {
93
c.ModChannel = "#moderation"
94
}
95
}
96
97
// msgEntry is a buffered channel message.
98
type msgEntry struct {
99
at time.Time
100
nick string
101
text string
102
}
103
104
// chanBuffer holds unanalysed messages for a channel.
105
type chanBuffer struct {
106
msgs []msgEntry
107
lastScan time.Time
108
}
109
110
// Bot is the sentinel bot.
111
type Bot struct {
112
cfg Config
113
llm LLMProvider
114
log *slog.Logger
115
client *girc.Client
116
117
mu sync.Mutex
118
buffers map[string]*chanBuffer // channel → buffer
119
cooldown map[string]time.Time // "channel:nick" → last report time
120
}
121
122
// New creates a sentinel Bot.
123
func New(cfg Config, llm LLMProvider, log *slog.Logger) *Bot {
124
cfg.setDefaults()
125
return &Bot{
126
cfg: cfg,
127
llm: llm,
128
log: log,
129
buffers: make(map[string]*chanBuffer),
130
cooldown: make(map[string]time.Time),
131
}
132
}
133
134
// Start connects to IRC and begins observation. Blocks until ctx is done.
135
func (b *Bot) Start(ctx context.Context) error {
136
host, port, err := splitHostPort(b.cfg.IRCAddr)
137
if err != nil {
138
return fmt.Errorf("sentinel: %w", err)
139
}
140
141
c := girc.New(girc.Config{
142
Server: host,
143
Port: port,
144
Nick: b.cfg.Nick,
145
User: b.cfg.Nick,
146
Name: "scuttlebot sentinel",
147
SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
148
PingDelay: 30 * time.Second,
149
PingTimeout: 30 * time.Second,
150
})
151
152
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
153
cl.Cmd.Mode(cl.GetNick(), "+B")
154
for _, ch := range b.cfg.Channels {
155
cl.Cmd.Join(ch)
156
}
157
cl.Cmd.Join(b.cfg.ModChannel)
158
if b.log != nil {
159
b.log.Info("sentinel connected", "channels", b.cfg.Channels)
160
}
161
})
162
163
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
164
if ch := e.Last(); strings.HasPrefix(ch, "#") {
165
cl.Cmd.Join(ch)
166
}
167
})
168
169
router := cmdparse.NewRouter(b.cfg.Nick)
170
router.Register(cmdparse.Command{
171
Name: "report",
172
Usage: "REPORT [#channel]",
173
Description: "on-demand policy review",
174
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
175
})
176
router.Register(cmdparse.Command{
177
Name: "status",
178
Usage: "STATUS",
179
Description: "show current incidents",
180
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
181
})
182
router.Register(cmdparse.Command{
183
Name: "dismiss",
184
Usage: "DISMISS <incident-id>",
185
Description: "dismiss a false positive",
186
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
187
})
188
189
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
190
if len(e.Params) < 1 || e.Source == nil {
191
return
192
}
193
// Dispatch commands (DMs and channel messages).
194
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
195
cl.Cmd.Message(reply.Target, reply.Text)
196
return
197
}
198
channel := e.Params[0]
199
if !strings.HasPrefix(channel, "#") {
200
return // non-command DMs ignored
201
}
202
if channel == b.cfg.ModChannel {
203
return // don't analyse the mod channel itself
204
}
205
nick := e.Source.Name
206
if nick == b.cfg.Nick {
207
return
208
}
209
b.buffer(ctx, channel, nick, e.Last())
210
})
211
212
b.client = c
213
214
// Background scanner — forces analysis on aged buffers.
215
go b.scanLoop(ctx)
216
217
errCh := make(chan error, 1)
218
go func() {
219
if err := c.Connect(); err != nil && ctx.Err() == nil {
220
errCh <- err
221
}
222
}()
223
224
select {
225
case <-ctx.Done():
226
c.Close()
227
return nil
228
case err := <-errCh:
229
return fmt.Errorf("sentinel: irc: %w", err)
230
}
231
}
232
233
// JoinChannel joins an additional channel.
234
func (b *Bot) JoinChannel(channel string) {
235
if b.client != nil {
236
b.client.Cmd.Join(channel)
237
}
238
}
239
240
// buffer appends a message to the channel buffer and triggers analysis
241
// when the window is full.
242
func (b *Bot) buffer(ctx context.Context, channel, nick, text string) {
243
b.mu.Lock()
244
buf := b.buffers[channel]
245
if buf == nil {
246
buf = &chanBuffer{lastScan: time.Now()}
247
b.buffers[channel] = buf
248
}
249
buf.msgs = append(buf.msgs, msgEntry{at: time.Now(), nick: nick, text: text})
250
ready := len(buf.msgs) >= b.cfg.WindowSize
251
if ready {
252
msgs := buf.msgs
253
buf.msgs = nil
254
buf.lastScan = time.Now()
255
b.mu.Unlock()
256
go b.analyse(ctx, channel, msgs)
257
} else {
258
b.mu.Unlock()
259
}
260
}
261
262
// scanLoop forces analysis of stale buffers periodically.
263
func (b *Bot) scanLoop(ctx context.Context) {
264
ticker := time.NewTicker(30 * time.Second)
265
defer ticker.Stop()
266
for {
267
select {
268
case <-ctx.Done():
269
return
270
case <-ticker.C:
271
b.flushStale(ctx)
272
}
273
}
274
}
275
276
func (b *Bot) flushStale(ctx context.Context) {
277
b.mu.Lock()
278
var work []struct {
279
channel string
280
msgs []msgEntry
281
}
282
for ch, buf := range b.buffers {
283
if len(buf.msgs) == 0 {
284
continue
285
}
286
if time.Since(buf.lastScan) >= b.cfg.WindowAge {
287
work = append(work, struct {
288
channel string
289
msgs []msgEntry
290
}{ch, buf.msgs})
291
buf.msgs = nil
292
buf.lastScan = time.Now()
293
}
294
}
295
b.mu.Unlock()
296
for _, w := range work {
297
go b.analyse(ctx, w.channel, w.msgs)
298
}
299
}
300
301
// analyse sends a window of messages to the LLM and reports any violations.
302
func (b *Bot) analyse(ctx context.Context, channel string, msgs []msgEntry) {
303
if b.llm == nil || len(msgs) == 0 {
304
return
305
}
306
307
prompt := b.buildPrompt(channel, msgs)
308
result, err := b.llm.Summarize(ctx, prompt)
309
if err != nil {
310
if b.log != nil {
311
b.log.Error("sentinel: llm error", "channel", channel, "err", err)
312
}
313
return
314
}
315
316
b.parseAndReport(channel, result)
317
}
318
319
// buildPrompt constructs the LLM prompt for a message window.
320
func (b *Bot) buildPrompt(channel string, msgs []msgEntry) string {
321
var sb strings.Builder
322
fmt.Fprintf(&sb, "You are a channel moderation assistant. Your policy:\n%s\n\n", b.cfg.Policy)
323
fmt.Fprintf(&sb, "Review the following IRC messages from %s and identify any policy violations.\n", channel)
324
fmt.Fprintf(&sb, "For each violation found, respond with one line in this exact format:\n")
325
fmt.Fprintf(&sb, "INCIDENT | nick: <nick> | severity: low|medium|high | reason: <brief reason>\n\n")
326
fmt.Fprintf(&sb, "If there are no violations, respond with: CLEAN\n\n")
327
fmt.Fprintf(&sb, "Messages (%d):\n", len(msgs))
328
for _, m := range msgs {
329
fmt.Fprintf(&sb, "[%s] %s: %s\n", m.at.Format("15:04:05"), m.nick, m.text)
330
}
331
return sb.String()
332
}
333
334
// parseAndReport parses LLM output and posts reports to the mod channel.
335
func (b *Bot) parseAndReport(channel, result string) {
336
if b.client == nil {
337
return
338
}
339
lines := strings.Split(strings.TrimSpace(result), "\n")
340
for _, line := range lines {
341
line = strings.TrimSpace(line)
342
if line == "" || strings.EqualFold(line, "CLEAN") {
343
continue
344
}
345
if !strings.HasPrefix(strings.ToUpper(line), "INCIDENT") {
346
continue
347
}
348
349
nick, severity, reason := parseIncidentLine(line)
350
if !b.severityMeetsMin(severity) {
351
continue
352
}
353
354
// Cooldown check.
355
coolKey := channel + ":" + nick
356
b.mu.Lock()
357
if last, ok := b.cooldown[coolKey]; ok && time.Since(last) < b.cfg.CooldownPerNick {
358
b.mu.Unlock()
359
continue
360
}
361
b.cooldown[coolKey] = time.Now()
362
b.mu.Unlock()
363
364
report := fmt.Sprintf("[sentinel] incident in %s | nick: %s | severity: %s | reason: %s",
365
channel, nick, severity, reason)
366
367
if b.log != nil {
368
b.log.Warn("sentinel incident", "channel", channel, "nick", nick, "severity", severity, "reason", reason)
369
}
370
b.client.Cmd.Message(b.cfg.ModChannel, report)
371
if b.cfg.DMOperators {
372
for _, nick := range b.cfg.AlertNicks {
373
b.client.Cmd.Message(nick, report)
374
}
375
}
376
}
377
}
378
379
func parseIncidentLine(line string) (nick, severity, reason string) {
380
// Format: INCIDENT | nick: X | severity: Y | reason: Z
381
parts := strings.Split(line, "|")
382
for _, p := range parts {
383
p = strings.TrimSpace(p)
384
if kv, ok := strings.CutPrefix(p, "nick:"); ok {
385
nick = strings.TrimSpace(kv)
386
} else if kv, ok := strings.CutPrefix(p, "severity:"); ok {
387
severity = strings.ToLower(strings.TrimSpace(kv))
388
} else if kv, ok := strings.CutPrefix(p, "reason:"); ok {
389
reason = strings.TrimSpace(kv)
390
}
391
}
392
if nick == "" {
393
nick = "unknown"
394
}
395
if severity == "" {
396
severity = "medium"
397
}
398
return
399
}
400
401
func (b *Bot) severityMeetsMin(severity string) bool {
402
order := map[string]int{"low": 0, "medium": 1, "high": 2}
403
min := order[b.cfg.MinSeverity]
404
got, ok := order[severity]
405
if !ok {
406
return true // unknown severity — report it
407
}
408
return got >= min
409
}
410
411
func splitHostPort(addr string) (string, int, error) {
412
host, portStr, err := net.SplitHostPort(addr)
413
if err != nil {
414
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
415
}
416
port, err := strconv.Atoi(portStr)
417
if err != nil {
418
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
419
}
420
return host, port, nil
421
}
422

Keyboard Shortcuts

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