Commands package: !tz, -h usage, and abuse throttling #2

Merged
drawblank merged 1 commit from feat/commands-package into main 2026-08-20 09:39:18 +00:00
Owner

Builds on #1. Grows commands.py from a 170-line module into a package, then adds three features on top.

Structure

commands/main.py holds the registry and the generic plumbing (reply routing, truncation, error handling, dispatch). Every other module implements one command and registers itself on import, so a new command is a new file plus one line in COMMAND_MODULES.

Handlers now raise CommandError rather than each open-coding a try/except -> privmsg_user dance. !time is 12 lines; !tz is 62.

An unexpected exception in a handler is caught and logged instead of escaping into the IRC event loop, where it would take the bot down.

Bug fix: replies vanished in private messages

irc/client.py:367 sets event.target to the raw PRIVMSG recipient — the bot's own nick for a direct message. !ping, !help, !time and !dice all replied there, so the bot messaged itself and the user saw nothing. Only their error paths worked, since those already used event.source.nick.

Features

  • MOTD announced on join and on demand via !motd. Announcing happens in on_join, not after connection.join() — sending before the server confirms the join can be bounced on a +n channel.
  • !tz ZONE [ZONE] reports a zone's time, or the gap between two. Both zones are read from one shared instant so the delta cannot straddle a second, and the gap follows DST rather than a fixed table.
  • !time is now a fixed alias for !tz UTC and ignores its argument. (Behaviour change: !time Europe/Amsterdam returns UTC. !tz is the command that takes a zone.)
  • !<command> -h prints a usage line built from the handler's docstring, intercepted in the registry so it covers every command present and future, and never runs the handler.
  • Throttling of repeat offenders, keyed on nick, host, and user@host, so changing any one of them does not shake off a mute. Three bad commands or eight valid ones in a burst earns an escalating, silent mute — answering is the expensive part and the reaction a troll is after. Muted identities also skip URL title fetching, the costliest thing the bot does.

Verification

304 tests via uv run pytest, none touching the network or the clock. Verified from a clean checkout of this commit in an isolated worktree.

Timezone assertions are derived from zoneinfo at runtime rather than hard-coded, so a tzdata update changes what is tested rather than whether it passes.

Beyond a green suite, 20 deliberate defects were introduced and confirmed caught:

Mutation Tests failed
Revert the PM reply fix 7
Drop minutes from a tz delta 9
Invert delta direction 47
Read the clock per zone instead of sharing an instant 76
Remove the on_join nick filter 1
Remove the mute check 4
Identity keyed on nick only 8
Linked histories never merge 1
Flood limit never fires 5

Known limits

  • A troll changing nick and host simultaneously gets a fresh start; there is a test pinning that as documented behaviour rather than an oversight.
  • Unknown commands (!nope) are deliberately free and unpunished — they already cost a dict miss and send nothing.
  • One commit rather than several: the features interlock through main.py and __init__.py, so splitting them into individually-green commits would mean writing intermediate versions of those files that never existed.
Builds on #1. Grows `commands.py` from a 170-line module into a package, then adds three features on top. ## Structure `commands/main.py` holds the registry and the generic plumbing (reply routing, truncation, error handling, dispatch). Every other module implements one command and registers itself on import, so a new command is a new file plus one line in `COMMAND_MODULES`. Handlers now raise `CommandError` rather than each open-coding a `try/except -> privmsg_user` dance. `!time` is 12 lines; `!tz` is 62. An unexpected exception in a handler is caught and logged instead of escaping into the IRC event loop, where it would take the bot down. ## Bug fix: replies vanished in private messages `irc/client.py:367` sets `event.target` to the raw PRIVMSG recipient — the **bot's own nick** for a direct message. `!ping`, `!help`, `!time` and `!dice` all replied there, so the bot messaged itself and the user saw nothing. Only their error paths worked, since those already used `event.source.nick`. ## Features - **MOTD** announced on join and on demand via `!motd`. Announcing happens in `on_join`, not after `connection.join()` — sending before the server confirms the join can be bounced on a `+n` channel. - **`!tz ZONE [ZONE]`** reports a zone's time, or the gap between two. Both zones are read from one shared instant so the delta cannot straddle a second, and the gap follows DST rather than a fixed table. - **`!time`** is now a fixed alias for `!tz UTC` and ignores its argument. *(Behaviour change: `!time Europe/Amsterdam` returns UTC. `!tz` is the command that takes a zone.)* - **`!<command> -h`** prints a usage line built from the handler's docstring, intercepted in the registry so it covers every command present and future, and never runs the handler. - **Throttling** of repeat offenders, keyed on nick, host, *and* user@host, so changing any one of them does not shake off a mute. Three bad commands or eight valid ones in a burst earns an escalating, silent mute — answering is the expensive part and the reaction a troll is after. Muted identities also skip URL title fetching, the costliest thing the bot does. ## Verification 304 tests via `uv run pytest`, none touching the network or the clock. Verified from a clean checkout of this commit in an isolated worktree. Timezone assertions are **derived from zoneinfo at runtime** rather than hard-coded, so a tzdata update changes what is tested rather than whether it passes. Beyond a green suite, 20 deliberate defects were introduced and confirmed caught: | Mutation | Tests failed | |---|---| | Revert the PM reply fix | 7 | | Drop minutes from a tz delta | 9 | | Invert delta direction | 47 | | Read the clock per zone instead of sharing an instant | 76 | | Remove the `on_join` nick filter | 1 | | Remove the mute check | 4 | | Identity keyed on nick only | 8 | | Linked histories never merge | 1 | | Flood limit never fires | 5 | ## Known limits - A troll changing nick **and** host simultaneously gets a fresh start; there is a test pinning that as documented behaviour rather than an oversight. - Unknown commands (`!nope`) are deliberately free and unpunished — they already cost a dict miss and send nothing. - One commit rather than several: the features interlock through `main.py` and `__init__.py`, so splitting them into individually-green commits would mean writing intermediate versions of those files that never existed.
Grows commands.py from a 170-line module into a package, and builds three
features on top of it.

Structure
- commands/main.py holds the registry and the generic plumbing: reply
  routing, line truncation, error handling, and the dispatch loop. Every
  other module implements one command and registers itself on import.
- Handlers now raise CommandError instead of each open-coding a
  try/except -> privmsg_user dance, so !time is 12 lines and !tz is 62.
- An unexpected exception in a handler is caught and logged rather than
  escaping into the IRC event loop, where it would take down the bot.

Fixes
- Replies in a private message went to event.target, which the irc library
  sets to the bot's own nick for a PRIVMSG, so the bot messaged itself and
  the user saw nothing. !ping, !help, !time and !dice were all affected;
  only their error paths worked, since those already used event.source.nick.

Features
- MOTD announced on join (from config.toml) and on demand via !motd.
  Announcing happens in on_join rather than after connection.join(), since
  sending before the server confirms the join can be bounced on a +n channel.
- !tz ZONE [ZONE] reports a zone's time, or the gap between two. Both zones
  are read from one shared instant so the delta cannot straddle a second,
  and the gap follows DST rather than being a fixed table.
- !time is now a fixed alias for !tz UTC and ignores its argument.
- !<command> -h prints a usage line built from the handler's docstring,
  intercepted in the registry so it works for every command, present and
  future, and never runs the handler.
- Repeat offenders are throttled per identity (nick, host, and user@host,
  so changing any one of them does not shake off a mute). Three bad
  commands or a burst of eight valid ones earns an escalating, silent mute;
  answering is the expensive part and the reaction a troll wants. Muted
  identities also skip URL title fetching, the costliest thing the bot does.

Tests
- 304 tests under uv run pytest, none touching the network or the clock.
- Timezone assertions are derived from zoneinfo at runtime rather than
  hard-coded, so a tzdata update changes what is tested, not whether it
  passes.
- Verified by mutation: 20 deliberate defects were introduced across the
  reply routing, delta formatting, join filter, and throttle, and every one
  was caught by the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slopnode/bot!2
No description provided.