ScuttleBot

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

Keyboard Shortcuts

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