ScuttleBot

scuttlebot / internal / bots / steward / steward.go
Blame History Raw 432 lines
1
// Package steward implements the steward bot — a moderation action bot that
2
// watches for sentinel incident reports and takes proportional IRC action.
3
//
4
// Steward reads structured reports from the mod channel posted by sentinel
5
// (or any other source using the same format) and responds based on configured
6
// severity thresholds:
7
//
8
// - low: warn the user via NOTICE
9
// - medium: warn + temporary mute (channel mode +q)
10
// - high: warn + kick (with reason)
11
//
12
// Every action steward takes is announced in the mod channel so the audit
13
// trail remains fully human-observable in IRC.
14
//
15
// Steward can also be commanded directly via DM by operators:
16
//
17
// warn <nick> <#channel> <reason>
18
// mute <nick> <#channel> [duration]
19
// kick <nick> <#channel> <reason>
20
// unmute <nick> <#channel>
21
package steward
22
23
import (
24
"context"
25
"fmt"
26
"log/slog"
27
"net"
28
"strconv"
29
"strings"
30
"sync"
31
"time"
32
33
"github.com/lrstanley/girc"
34
35
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
36
)
37
38
const defaultNick = "steward"
39
40
// Config controls steward's behaviour.
41
type Config struct {
42
// IRCAddr is host:port of the Ergo IRC server.
43
IRCAddr string
44
// Nick is the IRC nick. Default: "steward".
45
Nick string
46
// Password is the SASL PLAIN passphrase.
47
Password string
48
49
// ModChannel is the channel steward watches for sentinel reports and
50
// where it announces its own actions. Default: "#moderation".
51
ModChannel string
52
53
// OperatorNicks is the list of nicks allowed to issue direct commands.
54
OperatorNicks []string
55
56
// AutoAct enables automatic action on sentinel reports.
57
// When false, steward only acts on direct operator commands.
58
AutoAct bool
59
60
// MuteDuration is how long a medium-severity mute lasts. Default: 10m.
61
MuteDuration time.Duration
62
63
// WarnOnLow — send a warning notice for low-severity incidents.
64
// Default: true.
65
WarnOnLow bool
66
// DMOnAction, when true, sends a DM to all OperatorNicks when steward takes action.
67
DMOnAction bool
68
69
// CooldownPerNick is the minimum time between automated actions on the
70
// same nick. Default: 5 minutes.
71
CooldownPerNick time.Duration
72
73
// Channels is the list of channels to join on connect.
74
Channels []string
75
}
76
77
func (c *Config) setDefaults() {
78
if c.Nick == "" {
79
c.Nick = defaultNick
80
}
81
if c.ModChannel == "" {
82
c.ModChannel = "#moderation"
83
}
84
if c.MuteDuration == 0 {
85
c.MuteDuration = 10 * time.Minute
86
}
87
if c.CooldownPerNick == 0 {
88
c.CooldownPerNick = 5 * time.Minute
89
}
90
if !c.WarnOnLow {
91
c.WarnOnLow = true
92
}
93
}
94
95
// Bot is the steward bot.
96
type Bot struct {
97
cfg Config
98
log *slog.Logger
99
client *girc.Client
100
101
mu sync.Mutex
102
cooldown map[string]time.Time // nick → last action time
103
mutes map[string]time.Time // "channel:nick" → unmute at
104
}
105
106
// New creates a steward Bot.
107
func New(cfg Config, log *slog.Logger) *Bot {
108
cfg.setDefaults()
109
return &Bot{
110
cfg: cfg,
111
log: log,
112
cooldown: make(map[string]time.Time),
113
mutes: make(map[string]time.Time),
114
}
115
}
116
117
// Start connects to IRC and begins watching for sentinel reports.
118
// Blocks until ctx is done.
119
func (b *Bot) Start(ctx context.Context) error {
120
host, port, err := splitHostPort(b.cfg.IRCAddr)
121
if err != nil {
122
return fmt.Errorf("steward: %w", err)
123
}
124
125
c := girc.New(girc.Config{
126
Server: host,
127
Port: port,
128
Nick: b.cfg.Nick,
129
User: b.cfg.Nick,
130
Name: "scuttlebot steward",
131
SASL: &girc.SASLPlain{User: b.cfg.Nick, Pass: b.cfg.Password},
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
cl.Cmd.Mode(cl.GetNick(), "+B")
138
for _, ch := range b.cfg.Channels {
139
cl.Cmd.Join(ch)
140
}
141
cl.Cmd.Join(b.cfg.ModChannel)
142
if b.log != nil {
143
b.log.Info("steward connected", "channels", b.cfg.Channels)
144
}
145
})
146
147
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
148
if ch := e.Last(); strings.HasPrefix(ch, "#") {
149
cl.Cmd.Join(ch)
150
}
151
})
152
153
router := cmdparse.NewRouter(b.cfg.Nick)
154
router.Register(cmdparse.Command{
155
Name: "act",
156
Usage: "ACT <incident-id>",
157
Description: "manually trigger action on incident",
158
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
159
})
160
router.Register(cmdparse.Command{
161
Name: "override",
162
Usage: "OVERRIDE <incident-id>",
163
Description: "override pending action",
164
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
165
})
166
router.Register(cmdparse.Command{
167
Name: "status",
168
Usage: "STATUS",
169
Description: "show current pending actions",
170
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
171
})
172
173
c.Handlers.AddBg(girc.PRIVMSG, func(_ *girc.Client, e girc.Event) {
174
if len(e.Params) < 1 || e.Source == nil {
175
return
176
}
177
// Dispatch commands (DMs and channel messages).
178
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
179
c.Cmd.Message(reply.Target, reply.Text)
180
return
181
}
182
target := e.Params[0]
183
nick := e.Source.Name
184
text := strings.TrimSpace(e.Last())
185
186
if nick == b.cfg.Nick {
187
return
188
}
189
190
// Sentinel reports arrive as channel messages in the mod channel.
191
if target == b.cfg.ModChannel && b.cfg.AutoAct {
192
b.handleReport(c, text)
193
return
194
}
195
196
// Direct operator commands arrive as DMs.
197
if !strings.HasPrefix(target, "#") && b.isOperator(nick) {
198
b.handleCommand(c, nick, text)
199
}
200
})
201
202
b.client = c
203
204
// Background loop: unmute nicks whose mute duration has elapsed.
205
go b.unmuteLoop(ctx)
206
207
errCh := make(chan error, 1)
208
go func() {
209
if err := c.Connect(); err != nil && ctx.Err() == nil {
210
errCh <- err
211
}
212
}()
213
214
select {
215
case <-ctx.Done():
216
c.Close()
217
return nil
218
case err := <-errCh:
219
return fmt.Errorf("steward: irc: %w", err)
220
}
221
}
222
223
// JoinChannel joins an additional channel (needed to set channel modes).
224
func (b *Bot) JoinChannel(channel string) {
225
if b.client != nil {
226
b.client.Cmd.Join(channel)
227
}
228
}
229
230
// handleReport parses a sentinel incident report and takes action.
231
func (b *Bot) handleReport(c *girc.Client, text string) {
232
if !strings.HasPrefix(text, "[sentinel]") {
233
return
234
}
235
// [sentinel] incident in #channel | nick: X | severity: Y | reason: Z
236
channel, nick, severity, reason := parseSentinelReport(text)
237
if nick == "" || channel == "" {
238
return
239
}
240
241
// Cooldown check.
242
b.mu.Lock()
243
if last, ok := b.cooldown[nick]; ok && time.Since(last) < b.cfg.CooldownPerNick {
244
b.mu.Unlock()
245
return
246
}
247
b.cooldown[nick] = time.Now()
248
b.mu.Unlock()
249
250
switch severity {
251
case "high":
252
b.kick(c, nick, channel, reason)
253
case "medium":
254
b.warn(c, nick, channel, reason)
255
b.mute(c, nick, channel, b.cfg.MuteDuration)
256
case "low":
257
if b.cfg.WarnOnLow {
258
b.warn(c, nick, channel, reason)
259
}
260
}
261
}
262
263
// handleCommand processes direct operator commands.
264
func (b *Bot) handleCommand(c *girc.Client, op, text string) {
265
parts := strings.Fields(text)
266
if len(parts) < 3 {
267
c.Cmd.Notice(op, "steward: usage: warn|mute|kick|unmute <nick> <#channel> [reason/duration]")
268
return
269
}
270
cmd, nick, channel := parts[0], parts[1], parts[2]
271
rest := strings.Join(parts[3:], " ")
272
273
switch strings.ToLower(cmd) {
274
case "warn":
275
reason := rest
276
if reason == "" {
277
reason = "operator warning"
278
}
279
b.warn(c, nick, channel, reason)
280
case "mute":
281
d := b.cfg.MuteDuration
282
if rest != "" {
283
if parsed, err := time.ParseDuration(rest); err == nil {
284
d = parsed
285
}
286
}
287
b.mute(c, nick, channel, d)
288
case "kick":
289
reason := rest
290
if reason == "" {
291
reason = "removed by steward"
292
}
293
b.kick(c, nick, channel, reason)
294
case "unmute":
295
b.unmute(c, nick, channel)
296
default:
297
c.Cmd.Notice(op, fmt.Sprintf("steward: unknown command %q", cmd))
298
}
299
}
300
301
func (b *Bot) warn(c *girc.Client, nick, channel, reason string) {
302
c.Cmd.Notice(nick, fmt.Sprintf("[steward] warning in %s: %s", channel, reason))
303
b.announce(c, fmt.Sprintf("warned %s in %s — %s", nick, channel, reason))
304
if b.log != nil {
305
b.log.Info("steward warn", "nick", nick, "channel", channel, "reason", reason)
306
}
307
}
308
309
func (b *Bot) mute(c *girc.Client, nick, channel string, d time.Duration) {
310
// Extended ban m: to mute — agent stays in channel but cannot speak.
311
c.Cmd.Mode(channel, "+b", "m:"+nick+"!*@*")
312
key := channel + ":" + nick
313
b.mu.Lock()
314
b.mutes[key] = time.Now().Add(d)
315
b.mu.Unlock()
316
b.announce(c, fmt.Sprintf("muted %s in %s for %s", nick, channel, d.Round(time.Second)))
317
if b.log != nil {
318
b.log.Info("steward mute", "nick", nick, "channel", channel, "duration", d)
319
}
320
}
321
322
func (b *Bot) unmute(c *girc.Client, nick, channel string) {
323
c.Cmd.Mode(channel, "-b", "m:"+nick+"!*@*")
324
key := channel + ":" + nick
325
b.mu.Lock()
326
delete(b.mutes, key)
327
b.mu.Unlock()
328
b.announce(c, fmt.Sprintf("unmuted %s in %s", nick, channel))
329
if b.log != nil {
330
b.log.Info("steward unmute", "nick", nick, "channel", channel)
331
}
332
}
333
334
func (b *Bot) kick(c *girc.Client, nick, channel, reason string) {
335
c.Cmd.Kick(channel, nick, reason)
336
b.announce(c, fmt.Sprintf("kicked %s from %s — %s", nick, channel, reason))
337
if b.log != nil {
338
b.log.Info("steward kick", "nick", nick, "channel", channel, "reason", reason)
339
}
340
}
341
342
func (b *Bot) announce(c *girc.Client, msg string) {
343
full := "[steward] " + msg
344
c.Cmd.Message(b.cfg.ModChannel, full)
345
if b.cfg.DMOnAction {
346
for _, op := range b.cfg.OperatorNicks {
347
c.Cmd.Message(op, full)
348
}
349
}
350
}
351
352
// unmuteLoop lifts expired mutes.
353
func (b *Bot) unmuteLoop(ctx context.Context) {
354
ticker := time.NewTicker(30 * time.Second)
355
defer ticker.Stop()
356
for {
357
select {
358
case <-ctx.Done():
359
return
360
case <-ticker.C:
361
now := time.Now()
362
b.mu.Lock()
363
expired := make(map[string]time.Time)
364
for key, at := range b.mutes {
365
if now.After(at) {
366
expired[key] = at
367
delete(b.mutes, key)
368
}
369
}
370
b.mu.Unlock()
371
for key := range expired {
372
parts := strings.SplitN(key, ":", 2)
373
if len(parts) != 2 {
374
continue
375
}
376
channel, nick := parts[0], parts[1]
377
if b.client != nil {
378
b.unmute(b.client, nick, channel)
379
}
380
}
381
}
382
}
383
}
384
385
func (b *Bot) isOperator(nick string) bool {
386
for _, op := range b.cfg.OperatorNicks {
387
if strings.EqualFold(op, nick) {
388
return true
389
}
390
}
391
return false
392
}
393
394
// parseSentinelReport parses:
395
// [sentinel] incident in #channel | nick: X | severity: Y | reason: Z
396
func parseSentinelReport(text string) (channel, nick, severity, reason string) {
397
// Strip prefix up to "incident in"
398
idx := strings.Index(strings.ToLower(text), "incident in")
399
if idx == -1 {
400
return
401
}
402
rest := text[idx+len("incident in"):]
403
parts := strings.Split(rest, "|")
404
if len(parts) < 1 {
405
return
406
}
407
channel = strings.TrimSpace(parts[0])
408
for _, p := range parts[1:] {
409
p = strings.TrimSpace(p)
410
if kv, ok := strings.CutPrefix(p, "nick:"); ok {
411
nick = strings.TrimSpace(kv)
412
} else if kv, ok := strings.CutPrefix(p, "severity:"); ok {
413
severity = strings.ToLower(strings.TrimSpace(kv))
414
} else if kv, ok := strings.CutPrefix(p, "reason:"); ok {
415
reason = strings.TrimSpace(kv)
416
}
417
}
418
return
419
}
420
421
func splitHostPort(addr string) (string, int, error) {
422
host, portStr, err := net.SplitHostPort(addr)
423
if err != nil {
424
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
425
}
426
port, err := strconv.Atoi(portStr)
427
if err != nil {
428
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
429
}
430
return host, port, nil
431
}
432

Keyboard Shortcuts

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