ScuttleBot

scuttlebot / internal / bots / warden / warden.go
Blame History Raw 478 lines
1
// Package warden implements the warden bot — channel moderation and rate limiting.
2
//
3
// warden monitors channels for misbehaving agents:
4
// - Malformed message envelopes → NOTICE to sender
5
// - Excessive message rates → warn (NOTICE), then mute (+q), then kick
6
//
7
// Actions escalate: first violation warns, second mutes, third kicks.
8
// Escalation state resets after a configurable cool-down period.
9
package warden
10
11
import (
12
"context"
13
"fmt"
14
"log/slog"
15
"net"
16
"strconv"
17
"strings"
18
"sync"
19
"time"
20
21
"github.com/lrstanley/girc"
22
23
"github.com/conflicthq/scuttlebot/internal/bots/cmdparse"
24
"github.com/conflicthq/scuttlebot/pkg/protocol"
25
)
26
27
const botNick = "warden"
28
29
// Action is an enforcement action taken against a nick.
30
type Action string
31
32
const (
33
ActionWarn Action = "warn"
34
ActionMute Action = "mute"
35
ActionKick Action = "kick"
36
)
37
38
// ChannelConfig configures warden's limits for a single channel.
39
type ChannelConfig struct {
40
// MessagesPerSecond is the max sustained rate. Default: 5.
41
MessagesPerSecond float64
42
43
// Burst is the max burst above the rate. Default: 10.
44
Burst int
45
46
// CoolDown is how long before escalation state resets. Default: 60s.
47
CoolDown time.Duration
48
}
49
50
func (c *ChannelConfig) defaults() {
51
if c.MessagesPerSecond <= 0 {
52
c.MessagesPerSecond = 5
53
}
54
if c.Burst <= 0 {
55
c.Burst = 10
56
}
57
if c.CoolDown <= 0 {
58
c.CoolDown = 60 * time.Second
59
}
60
}
61
62
// nickState tracks per-nick rate limiting and escalation within a channel.
63
type nickState struct {
64
tokens float64
65
lastRefill time.Time
66
violations int
67
lastAction time.Time
68
// Loop detection: track recent messages for repetition.
69
recentMsgs []string
70
}
71
72
// channelMsg is a recent message for ping-pong detection.
73
type channelMsg struct {
74
nick string
75
text string
76
}
77
78
// channelState holds per-channel warden state.
79
type channelState struct {
80
mu sync.Mutex
81
cfg ChannelConfig
82
nicks map[string]*nickState
83
recentMsgs []channelMsg // channel-wide message history for ping-pong detection
84
}
85
86
func newChannelState(cfg ChannelConfig) *channelState {
87
cfg.defaults()
88
return &channelState{cfg: cfg, nicks: make(map[string]*nickState)}
89
}
90
91
// consume attempts to consume one token for nick. Returns true if allowed;
92
// false if rate-limited.
93
func (cs *channelState) consume(nick string) bool {
94
cs.mu.Lock()
95
defer cs.mu.Unlock()
96
97
ns, ok := cs.nicks[nick]
98
if !ok {
99
ns = &nickState{
100
tokens: float64(cs.cfg.Burst),
101
lastRefill: time.Now(),
102
}
103
cs.nicks[nick] = ns
104
}
105
106
// Refill tokens based on elapsed time.
107
now := time.Now()
108
elapsed := now.Sub(ns.lastRefill).Seconds()
109
ns.lastRefill = now
110
ns.tokens = minF(float64(cs.cfg.Burst), ns.tokens+elapsed*cs.cfg.MessagesPerSecond)
111
112
if ns.tokens >= 1 {
113
ns.tokens--
114
return true
115
}
116
return false
117
}
118
119
// recordMessage tracks a message for loop detection. Returns true if a loop
120
// is detected (same message repeated 3+ times in recent history).
121
func (cs *channelState) recordMessage(nick, text string) bool {
122
cs.mu.Lock()
123
defer cs.mu.Unlock()
124
125
ns, ok := cs.nicks[nick]
126
if !ok {
127
ns = &nickState{tokens: float64(cs.cfg.Burst), lastRefill: time.Now()}
128
cs.nicks[nick] = ns
129
}
130
131
ns.recentMsgs = append(ns.recentMsgs, text)
132
// Keep last 10 messages.
133
if len(ns.recentMsgs) > 10 {
134
ns.recentMsgs = ns.recentMsgs[len(ns.recentMsgs)-10:]
135
}
136
137
// Check for repetition: same message 3+ times in last 10.
138
count := 0
139
for _, m := range ns.recentMsgs {
140
if m == text {
141
count++
142
}
143
}
144
return count >= 3
145
}
146
147
// recordChannelMessage tracks messages at channel level for ping-pong detection.
148
// Returns the offending nick if a ping-pong loop is detected (two nicks
149
// alternating back and forth 4+ times with no other participants).
150
func (cs *channelState) recordChannelMessage(nick, text string) string {
151
cs.mu.Lock()
152
defer cs.mu.Unlock()
153
154
cs.recentMsgs = append(cs.recentMsgs, channelMsg{nick: nick, text: text})
155
if len(cs.recentMsgs) > 20 {
156
cs.recentMsgs = cs.recentMsgs[len(cs.recentMsgs)-20:]
157
}
158
159
// Check last 8 messages for A-B-A-B pattern.
160
msgs := cs.recentMsgs
161
if len(msgs) < 8 {
162
return ""
163
}
164
tail := msgs[len(msgs)-8:]
165
nickA := tail[0].nick
166
nickB := tail[1].nick
167
if nickA == nickB {
168
return ""
169
}
170
for i, m := range tail {
171
expected := nickA
172
if i%2 == 1 {
173
expected = nickB
174
}
175
if m.nick != expected {
176
return ""
177
}
178
}
179
// A-B-A-B-A-B-A-B pattern detected — return the most recent speaker.
180
return nick
181
}
182
183
// violation records an enforcement action and returns the appropriate Action.
184
// Escalates: warn → mute → kick. Resets after CoolDown.
185
func (cs *channelState) violation(nick string) Action {
186
cs.mu.Lock()
187
defer cs.mu.Unlock()
188
189
ns, ok := cs.nicks[nick]
190
if !ok {
191
ns = &nickState{tokens: float64(cs.cfg.Burst), lastRefill: time.Now()}
192
cs.nicks[nick] = ns
193
}
194
195
// Reset escalation after cool-down.
196
if !ns.lastAction.IsZero() && time.Since(ns.lastAction) > cs.cfg.CoolDown {
197
ns.violations = 0
198
}
199
200
ns.violations++
201
ns.lastAction = time.Now()
202
203
switch ns.violations {
204
case 1:
205
return ActionWarn
206
case 2:
207
return ActionMute
208
default:
209
return ActionKick
210
}
211
}
212
213
// Bot is the warden.
214
type Bot struct {
215
ircAddr string
216
password string
217
initChannels []string // channels to join on connect
218
channelConfigs map[string]ChannelConfig // keyed by channel name
219
defaultConfig ChannelConfig
220
mu sync.RWMutex
221
channels map[string]*channelState
222
log *slog.Logger
223
client *girc.Client
224
}
225
226
// ActionRecord is written when warden takes action. Used in tests.
227
type ActionRecord struct {
228
At time.Time
229
Channel string
230
Nick string
231
Action Action
232
Reason string
233
}
234
235
// ActionSink receives action records. Optional — if nil, actions are logged only.
236
type ActionSink interface {
237
Record(ActionRecord)
238
}
239
240
// New creates a warden bot. channelConfigs overrides per-channel limits;
241
// defaultConfig is used for channels not in the map.
242
func New(ircAddr, password string, channels []string, channelConfigs map[string]ChannelConfig, defaultConfig ChannelConfig, log *slog.Logger) *Bot {
243
defaultConfig.defaults()
244
return &Bot{
245
ircAddr: ircAddr,
246
password: password,
247
initChannels: channels,
248
channelConfigs: channelConfigs,
249
defaultConfig: defaultConfig,
250
channels: make(map[string]*channelState),
251
log: log,
252
}
253
}
254
255
// Name returns the bot's IRC nick.
256
func (b *Bot) Name() string { return botNick }
257
258
// Start connects to IRC and begins moderation. Blocks until ctx is cancelled.
259
func (b *Bot) Start(ctx context.Context) error {
260
host, port, err := splitHostPort(b.ircAddr)
261
if err != nil {
262
return fmt.Errorf("warden: parse irc addr: %w", err)
263
}
264
265
c := girc.New(girc.Config{
266
Server: host,
267
Port: port,
268
Nick: botNick,
269
User: botNick,
270
Name: "scuttlebot warden",
271
SASL: &girc.SASLPlain{User: botNick, Pass: b.password},
272
PingDelay: 30 * time.Second,
273
PingTimeout: 30 * time.Second,
274
SSL: false,
275
})
276
277
c.Handlers.AddBg(girc.CONNECTED, func(cl *girc.Client, _ girc.Event) {
278
cl.Cmd.Mode(cl.GetNick(), "+B")
279
for _, ch := range b.initChannels {
280
cl.Cmd.Join(ch)
281
}
282
for ch := range b.channelConfigs {
283
cl.Cmd.Join(ch)
284
}
285
if b.log != nil {
286
b.log.Info("warden connected", "channels", b.initChannels)
287
}
288
})
289
290
c.Handlers.AddBg(girc.INVITE, func(cl *girc.Client, e girc.Event) {
291
if ch := e.Last(); strings.HasPrefix(ch, "#") {
292
cl.Cmd.Join(ch)
293
}
294
})
295
296
router := cmdparse.NewRouter(botNick)
297
router.Register(cmdparse.Command{
298
Name: "warn",
299
Usage: "WARN <nick> [reason]",
300
Description: "issue a warning to a user",
301
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
302
})
303
router.Register(cmdparse.Command{
304
Name: "mute",
305
Usage: "MUTE <nick> [duration]",
306
Description: "mute a user",
307
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
308
})
309
router.Register(cmdparse.Command{
310
Name: "kick",
311
Usage: "KICK <nick> [reason]",
312
Description: "kick a user from channel",
313
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
314
})
315
router.Register(cmdparse.Command{
316
Name: "status",
317
Usage: "STATUS",
318
Description: "show current warnings and mutes",
319
Handler: func(_ *cmdparse.Context, _ string) string { return "not implemented yet" },
320
})
321
322
c.Handlers.AddBg(girc.PRIVMSG, func(cl *girc.Client, e girc.Event) {
323
if len(e.Params) < 1 || e.Source == nil {
324
return
325
}
326
// Dispatch commands (DMs and channel messages).
327
if reply := router.Dispatch(e.Source.Name, e.Params[0], e.Last()); reply != nil {
328
cl.Cmd.Message(reply.Target, reply.Text)
329
return
330
}
331
channel := e.Params[0]
332
if !strings.HasPrefix(channel, "#") {
333
return // non-command DMs ignored
334
}
335
nick := e.Source.Name
336
text := e.Last()
337
338
cs := b.channelStateFor(channel)
339
340
// Check for malformed envelope.
341
if _, err := protocol.Unmarshal([]byte(text)); err != nil {
342
// Non-JSON is human chat — not an error. Only warn if it looks like
343
// a broken JSON attempt (starts with '{').
344
if strings.HasPrefix(strings.TrimSpace(text), "{") {
345
cl.Cmd.Notice(nick, "warden: malformed envelope ignored (invalid JSON)")
346
}
347
return
348
}
349
350
// Skip enforcement for channel ops (+o and above).
351
if isChannelOp(cl, channel, nick) {
352
return
353
}
354
355
// Loop detection: same message repeated 3+ times → mute.
356
if cs.recordMessage(nick, text) {
357
b.enforce(cl, channel, nick, ActionMute, "repetitive message loop detected")
358
return
359
}
360
361
// Ping-pong detection: two agents alternating back and forth → mute the latest.
362
if loopNick := cs.recordChannelMessage(nick, text); loopNick != "" {
363
b.enforce(cl, channel, loopNick, ActionMute, "agent ping-pong loop detected")
364
return
365
}
366
367
// Rate limit check.
368
if !cs.consume(nick) {
369
action := cs.violation(nick)
370
b.enforce(cl, channel, nick, action, "rate limit exceeded")
371
}
372
})
373
374
b.client = c
375
376
errCh := make(chan error, 1)
377
go func() {
378
if err := c.Connect(); err != nil && ctx.Err() == nil {
379
errCh <- err
380
}
381
}()
382
383
select {
384
case <-ctx.Done():
385
c.Close()
386
return nil
387
case err := <-errCh:
388
return fmt.Errorf("warden: irc connection: %w", err)
389
}
390
}
391
392
// Stop disconnects the bot.
393
func (b *Bot) Stop() {
394
if b.client != nil {
395
b.client.Close()
396
}
397
}
398
399
func (b *Bot) channelStateFor(channel string) *channelState {
400
b.mu.RLock()
401
cs, ok := b.channels[channel]
402
b.mu.RUnlock()
403
if ok {
404
return cs
405
}
406
407
cfg, ok := b.channelConfigs[channel]
408
if !ok {
409
cfg = b.defaultConfig
410
}
411
412
b.mu.Lock()
413
defer b.mu.Unlock()
414
// Double-check after lock upgrade.
415
if cs, ok = b.channels[channel]; ok {
416
return cs
417
}
418
cs = newChannelState(cfg)
419
b.channels[channel] = cs
420
return cs
421
}
422
423
func (b *Bot) enforce(cl *girc.Client, channel, nick string, action Action, reason string) {
424
if b.log != nil {
425
b.log.Warn("warden: enforcing", "channel", channel, "nick", nick, "action", action, "reason", reason)
426
}
427
switch action {
428
case ActionWarn:
429
cl.Cmd.Notice(nick, fmt.Sprintf("warden: warning — %s in %s", reason, channel))
430
case ActionMute:
431
cl.Cmd.Notice(nick, fmt.Sprintf("warden: muted in %s — %s", channel, reason))
432
// Use extended ban m: to mute — agent stays in channel but cannot speak.
433
mask := "m:" + nick + "!*@*"
434
cl.Cmd.Mode(channel, "+b", mask)
435
// Remove mute after cooldown so the agent can recover.
436
cs := b.channelStateFor(channel)
437
go func() {
438
time.Sleep(cs.cfg.CoolDown)
439
cl.Cmd.Mode(channel, "-b", mask)
440
}()
441
case ActionKick:
442
cl.Cmd.Kick(channel, nick, "warden: "+reason)
443
}
444
}
445
446
// isChannelOp returns true if nick has +o or higher in the given channel.
447
// Returns false if the user or channel cannot be looked up (e.g. not tracked).
448
func isChannelOp(cl *girc.Client, channel, nick string) bool {
449
user := cl.LookupUser(nick)
450
if user == nil || user.Perms == nil {
451
return false
452
}
453
perms, ok := user.Perms.Lookup(channel)
454
if !ok {
455
return false
456
}
457
return perms.IsAdmin()
458
}
459
460
func splitHostPort(addr string) (string, int, error) {
461
host, portStr, err := net.SplitHostPort(addr)
462
if err != nil {
463
return "", 0, fmt.Errorf("invalid address %q: %w", addr, err)
464
}
465
port, err := strconv.Atoi(portStr)
466
if err != nil {
467
return "", 0, fmt.Errorf("invalid port in %q: %w", addr, err)
468
}
469
return host, port, nil
470
}
471
472
func minF(a, b float64) float64 {
473
if a < b {
474
return a
475
}
476
return b
477
}
478

Keyboard Shortcuts

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