|
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
|
|