Skip to content

Commit 464ed34

Browse files
committed
docs: rewrite README, add plugin guide and architecture overview
Fix major README inaccuracies: commands/ -> plugins/, remove nonexistent plugin_loader.py, correct plugin API examples to use PLUGIN_ID and Permission enum, fix nemp command table. Add docs/plugins.md covering both old-style and new-style plugin patterns, event system, help registration, and task pool API. Add docs/architecture.md covering connection lifecycle, message dispatch, handler system, event system, and concurrency model. Trim AGENTS.md from 209 to 164 lines, fix ConnectionDown inheritance.
1 parent 298cbee commit 464ed34

4 files changed

Lines changed: 670 additions & 115 deletions

File tree

AGENTS.md

Lines changed: 36 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ Python 3.14+, managed with `uv`, linted/formatted with `ruff`, tested with `pyte
66
## Build & Run
77

88
```bash
9-
uv sync --dev # Install all dependencies (including dev)
10-
uv run python irc_bot.py # Start the bot (requires config.yml)
9+
uv sync --dev # Install all dependencies (including dev)
10+
uv run python irc_bot.py # Start the bot (requires config.yml)
1111
```
1212

1313
## Lint
@@ -36,63 +36,40 @@ All async tests run automatically via `pytest-asyncio` with `asyncio_mode = "aut
3636

3737
## Project Structure
3838

39-
Flat layout (no `src/` directory). All primary modules live at the repo root:
39+
Flat layout (no `src/` directory). Primary modules live at the repo root:
4040

41-
```
42-
irc_bot.py # Entry point, IrcBot class
43-
irc_connection.py # TCP/IRC connection handling
44-
irc_logging.py # Logging configuration (file + console)
45-
config.py # YAML config loading
46-
command_router.py # Central command dispatch, plugin loading
47-
bot_events.py # Event system (timer, chat, join events)
48-
help_system.py # Help database
49-
user_auth.py # Auth/registration tracking
50-
ban_list.py # SQLite-backed ban system
51-
task_pool.py # Async task management
52-
plugins/ # Bot command plugins (dynamically loaded)
53-
irc_handlers/ # IRC protocol handlers (one per command/numeric)
54-
mod_polling/ # Mod polling engine, parsers, data files
55-
config/ # Supplementary YAML config files
56-
tests/ # All tests, with conftest.py for shared fixtures
57-
```
58-
59-
Plugins and IRC handlers are discovered at runtime via `os.listdir()` and loaded
60-
with `importlib.util.spec_from_file_location()`.
41+
- `irc_bot.py` — Entry point, `IrcBot` class
42+
- `irc_connection.py` — TCP/IRC connection handling
43+
- `command_router.py` — Central command dispatch, plugin loading
44+
- `bot_events.py` — Event system (timer, chat, join events)
45+
- `ban_list.py` — SQLite-backed ban system
46+
- `config.py`, `irc_logging.py`, `help_system.py`, `user_auth.py`, `task_pool.py`
47+
- `plugins/` — Bot command plugins (dynamically loaded via `importlib`)
48+
- `irc_handlers/` — IRC protocol handlers (one per command/numeric)
49+
- `mod_polling/` — Mod polling engine, parsers, data files
50+
- `tests/` — All tests, with `conftest.py` for shared fixtures
6151

6252
## Code Style
6353

6454
### Formatting
6555

66-
- **Line length**: 120 characters (configured in `pyproject.toml` under `[tool.ruff]`)
67-
- **Indentation**: 4 spaces for Python, 2 spaces for YAML (see `.editorconfig`)
68-
- **Final newline**: Always insert
69-
- **Trailing whitespace**: Always trim
56+
- **Line length**: 120 characters (`pyproject.toml` `[tool.ruff]`)
57+
- **Indentation**: 4 spaces for Python, 2 spaces for YAML (`.editorconfig`)
58+
- **Final newline**: Always. **Trailing whitespace**: Always trim.
7059

7160
### Imports
7261

7362
- **Absolute imports only** — never use relative imports (`from . import`)
7463
- **Ordering** (enforced by ruff/isort): stdlib → third-party → local, separated by blank lines
7564
- `plugins` and `irc_handlers` are configured as known first-party in isort
7665
- Use `import module` for broad usage; `from module import Name` for specific items
77-
- Standard library modules are typically imported whole (`import asyncio`, `import logging`)
78-
79-
```python
80-
import asyncio
81-
import logging
82-
83-
import aiohttp
84-
import yaml
85-
86-
from bot_events import MsgEvent, StandardEvent
87-
from command_router import Permission, command
88-
```
8966

9067
### Naming Conventions
9168

9269
| Element | Convention | Example |
9370
|----------------------|--------------------|--------------------------------------------|
9471
| Modules | `snake_case` | `irc_connection.py`, `bot_events.py` |
95-
| IRC handler modules | include numeric | `rpl_endofmotd_376.py`, `err_nicknameinuse_433.py` |
72+
| IRC handler modules | include numeric | `rpl_endofmotd_376.py` |
9673
| Classes | `PascalCase` | `IrcBot`, `ModPoller`, `CommandRouter` |
9774
| Functions/methods | `snake_case` | `fetch_page`, `check_mod`, `add_event` |
9875
| Private members | `_underscore` | `_parse_message`, `_handler_lock` |
@@ -105,70 +82,50 @@ from command_router import Permission, command
10582

10683
### Type Annotations
10784

108-
Type annotations are used **sparingly** — only on `NamedTuple` fields and occasional
109-
instance variable declarations. Function signatures do not carry type hints.
85+
Used **sparingly** — only on `NamedTuple` fields and occasional instance variables.
86+
Function signatures do **not** carry type hints.
11087

11188
- Use modern union syntax: `X | Y` (not `Optional[X]` or `Union[X, Y]`)
11289
- Annotate `NamedTuple` fields and complex instance variables where it aids clarity
11390

114-
```python
115-
class PluginEntry(NamedTuple):
116-
module: object
117-
path: str
118-
setup: Callable | None
119-
teardown: Callable | None
120-
instance: object | None = None
121-
122-
self._host_locks: dict[str, asyncio.Lock] = {}
123-
```
124-
12591
### String Formatting
12692

12793
- **f-strings** for most string construction
12894
- **`.format()`** only for complex IRC messages with many color-code variables
129-
- **`%`-style** only inside `logging` calls (standard practice for lazy interpolation)
130-
131-
```python
132-
f"PRIVMSG {channel} :{msg}" # general use
133-
logger.info("Connected to %s", self.host) # logging only
134-
"{purple}{name}{end}".format(name=n, purple=P, end=E) # complex IRC messages
135-
```
95+
- **`%`-style** only inside `logging` calls (lazy interpolation)
13696

13797
### Error Handling
13898

139-
- **Custom exceptions** inherit from `Exception`, define `__init__` and `__str__`:
140-
`ConnectionDown`, `NEMPException`, `InvalidVersion`, `EventAlreadyExists`, etc.
141-
- `NEMPException` is a base class; `InvalidVersion` inherits from it
142-
- **Top-level loops** use broad `except Exception` with `logger.exception()`
143-
- **Specific catches** where meaningful: `KeyError`, `TimeoutError`, `asyncio.CancelledError`
99+
- Custom exceptions inherit from `Exception` with `__init__` and `__str__`
100+
- `NEMPException` is the base for polling exceptions; `InvalidVersion` inherits from it
101+
- `ConnectionDown` inherits directly from `Exception` (not `NEMPException`)
102+
- Top-level loops use broad `except Exception` with `logger.exception()`
103+
- Specific catches where meaningful: `KeyError`, `TimeoutError`, `asyncio.CancelledError`
144104
- Use `contextlib.suppress(ExcType)` instead of bare `try/except pass`
145-
- Validate inputs eagerly with `isinstance` checks, raising `TypeError`/`ValueError`
105+
- Validate inputs eagerly with `isinstance`, raising `TypeError`/`ValueError`
146106

147107
### Async Patterns
148108

149-
The codebase is fully async, built on `asyncio`:
109+
Fully async on `asyncio`. Entry point: `asyncio.run(async_main())` in `irc_bot.py`.
150110

151-
- Entry point: `asyncio.run(async_main())` in `irc_bot.py`
152111
- Background tasks via `asyncio.create_task()` with done-callback cleanup
153112
- `asyncio.Lock` for serialized handler execution and per-host rate limiting
154113
- `asyncio.Queue` for inter-task communication (with `queue.shutdown()` for cleanup)
155114
- `asyncio.gather`, `asyncio.as_completed`, `asyncio.wait_for` for concurrency
156-
- `async for` with async generators (`IrcConnection.read_lines()`)
157115
- aiohttp sessions: explicit `User-Agent` header, `aiohttp.ClientTimeout`, `async with`
158116

159117
### Logging
160118

161-
- **Module-level** loggers: `logger = logging.getLogger("BanList")`
162-
- **Instance-level** loggers: `self._logger = logging.getLogger("IRCConnection")`
163-
- Hierarchical naming: `irc.ping`, `irc.rpl.353`, `irc.err.433`, `cmd.say`, `cmd.pycalc`
164-
- Always use `%`-style formatting in log calls (lazy interpolation)
165-
- Use `.exception()` for logging with tracebacks
119+
- Module-level loggers: `logger = logging.getLogger("BanList")`
120+
- Instance-level loggers: `self._logger = logging.getLogger("IRCConnection")`
121+
- Hierarchical naming: `irc.ping`, `irc.rpl.353`, `cmd.say`, `cmd.pycalc`
122+
- Always `%`-style formatting in log calls. Use `.exception()` for tracebacks.
166123

167124
### Plugin Architecture
168125

169-
Two plugin styles coexist:
126+
Two styles coexist. Prefer **new-style** (class-based) for new plugins.
170127

171-
**Old-style** (function-based): module-level `COMMANDS` dict + underscore-prefixed async functions:
128+
**Old-style** (function-based): `COMMANDS` dict + underscore-prefixed async functions:
172129
```python
173130
PLUGIN_ID = "say"
174131
async def _say(router, name, params, channel, userdata, rank, is_channel): ...
@@ -187,18 +144,16 @@ class Plugin:
187144
async def cmd_enable(self, router, ...): ...
188145
```
189146

190-
Prefer the **new-style** class-based pattern for new plugins.
191-
192147
## Testing Conventions
193148

194149
- Test files: `tests/test_<module>.py`
195150
- Group tests in `class TestXxx` with `test_xxx` methods
196-
- Use `pytest.raises(ExcType, match="pattern")` for exception testing
197-
- Use `tmp_path` fixture for file/database isolation
151+
- `pytest.raises(ExcType, match="pattern")` for exception testing
152+
- `tmp_path` fixture for file/database isolation
198153
- Mocking: `MagicMock` (sync), `AsyncMock` (async), `patch`/`patch.object`
199154
- HTTP mocking: `aioresponses` library for `aiohttp` requests
200155
- Plain `assert` statements (pytest-style, no `unittest` assertions)
201-
- Shared fixtures in `tests/conftest.py` (e.g., `mod_poller`, `ban_list`, event fixtures)
156+
- Shared fixtures in `tests/conftest.py` (e.g., `mod_poller`, `ban_list`)
202157

203158
## Key Configuration
204159

README.md

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -35,81 +35,134 @@ uv sync
3535
| administration | `operators` | Users with admin privileges |
3636
| administration | `command_prefix` | Command trigger (default: `=`) |
3737
| administration | `logging_level` | `DEBUG`, `INFO`, `WARNING`, etc. |
38+
| networking | `force_ipv6`, `bind_address` | Network options |
3839

3940
2. **NEMP polling config** — only needed if you want mod-polling features:
4041

4142
```bash
4243
cp mod_polling/config.yml.example mod_polling/config.yml
4344
```
4445

45-
Add your GitHub API credentials (optional, increases rate limits) and set the staff IRC channel.
46+
Settings include GitHub API credentials (increases rate limits), polling interval (default 1800s), auto-start behavior, and the staff IRC channel.
47+
48+
3. **NEM relay config** (optional) — forwards mod update announcements to Discord:
49+
50+
```bash
51+
cp config/nem_relay.yml.example config/nem_relay.yml
52+
```
53+
54+
Set the Discord webhook URL and the IRC channel/nick to listen on.
4655

4756
## Running
4857

4958
```bash
5059
uv run irc_bot.py
5160
```
5261

53-
Logs are written to `BotLogs/` and the last crash traceback is saved to `exception.txt`.
62+
The bot automatically reconnects on disconnection with exponential backoff (5s to 300s). Logs are written to `BotLogs/` and the last crash traceback is saved to `exception.txt`.
5463

5564
## Usage
5665

5766
All commands use the configured prefix (default `=`). Examples:
5867

5968
| Command | Permission | Description |
6069
|---------|-----------|-------------|
61-
| `=help` | everyone | List available commands |
62-
| `=help <cmd>` | everyone | Show help for a command |
63-
| `=nemp running true` | op | Start polling for mod updates |
64-
| `=nemp list` | everyone | List tracked mods |
65-
| `=nemp status <mod>` | everyone | Check a specific mod's status |
66-
| `=reload <cmd>` | admin | Reload a command module |
67-
| `=join #channel` | admin | Join a channel |
68-
| `=part #channel` | admin | Leave a channel |
69-
70-
Permission levels: 0 = everyone, 1 = voiced+, 2 = channel op, 3 = bot admin.
70+
| `=help` | GUEST | List available commands |
71+
| `=help <cmd>` | GUEST | Show help for a command |
72+
| `=nemp enable` | OP | Start polling for mod updates |
73+
| `=nemp list` | OP | List tracked mods |
74+
| `=nemp status` | VOICED | Check if polling is running |
75+
| `=reload <plugin>` | ADMIN | Reload a plugin module |
76+
| `=join #channel` | ADMIN | Join a channel |
77+
| `=part #channel` | ADMIN | Leave a channel |
78+
79+
Permission levels (from `command_router.Permission`):
80+
81+
| Level | Name | Description |
82+
|-------|------|-------------|
83+
| 0 | GUEST | Anyone |
84+
| 1 | VOICED | `+` and above |
85+
| 2 | OP | `@` and above |
86+
| 3 | ADMIN | Bot operator (admin list + registered) |
87+
| 4 | HIDDEN | Not shown in command list |
7188

7289
## Project structure
7390

7491
```
7592
irc_bot.py Main entry point — async IRC event loop
76-
command_router.py Command dispatch and dynamic plugin loading
7793
irc_connection.py Low-level async IRC read/write with rate limiting
94+
command_router.py Command dispatch, plugin/handler loading, messaging helpers
7895
config.py YAML configuration loader
7996
bot_events.py Timer, message, and channel event system
97+
ban_list.py SQLite-backed ban system
98+
user_auth.py Auth/registration tracking
99+
help_system.py Self-documenting help system
80100
task_pool.py Background async task manager
81-
plugin_loader.py Plugin interface definition
101+
irc_logging.py Logging with daily file rotation
82102
83-
irc_handlers/ IRC protocol handlers (PRIVMSG, JOIN, PING, etc.)
84-
commands/ Command plugins loaded dynamically at startup
103+
plugins/ Bot command plugins (dynamically loaded at startup)
104+
irc_handlers/ IRC protocol handlers (PRIVMSG, JOIN, PING, numerics, etc.)
85105
mod_polling/ Mod-polling subsystem
86-
poller.py Polling logic for all supported sources
87-
mods.json Registry of tracked mods, their checkers, and regexes
88-
config.yml GitHub API credentials and IRC settings
89-
scripts/ Maintenance and testing utilities
90-
test_regexes.py Test mod regexes against live API data
106+
poller.py Polling engine for all supported sources
107+
mods.json Registry of tracked mods and their parser configs
108+
config.yml.example GitHub API, polling interval, staff channel settings
109+
config/ Supplementary config files (e.g., nem_relay.yml)
110+
scripts/ Maintenance utilities (regex testing, release cadence analysis)
111+
docs/ Additional documentation
112+
tests/ Test suite
91113
```
92114

93-
## Writing commands
115+
## Writing plugins
116+
117+
Plugins are Python files in `plugins/` that are loaded automatically at startup. There are two styles; the **new-style** (class-based) is preferred for new plugins.
94118

95-
Commands are Python files in `commands/` that are loaded automatically. Minimal example:
119+
### New-style (class-based)
96120

97121
```python
98-
ID = "greet"
99-
permission = 0
100-
privmsgEnabled = True
122+
from command_router import Permission, command, subcommand
123+
124+
PLUGIN_ID = "greet"
125+
126+
class Plugin:
127+
async def setup(self, router, startup):
128+
"""Called on load (startup=True) or reload (startup=False)."""
129+
pass
130+
131+
async def teardown(self, router):
132+
"""Called on shutdown or before reload."""
133+
pass
134+
135+
@command("greet", permission=Permission.GUEST, allow_private=True)
136+
async def greet(self, router, name, params, channel, userdata, rank, is_channel):
137+
await router.send_message(channel, f"Hello, {name}!")
138+
139+
@subcommand("greet", "loud", permission=Permission.VOICED)
140+
async def cmd_loud(self, router, name, params, channel, userdata, rank):
141+
await router.send_message(channel, f"HELLO, {name.upper()}!")
142+
```
143+
144+
- The `@command` method is the fallback when no subcommand matches.
145+
- Subcommands inherit the group's permission when `permission=None`.
146+
- `setup()` and `teardown()` are optional lifecycle hooks.
147+
148+
### Old-style (function-based)
149+
150+
```python
151+
from command_router import Permission
152+
153+
PLUGIN_ID = "say"
154+
155+
async def _say(router, name, params, channel, userdata, rank, is_channel):
156+
await router.send_chat_message(router.send, channel, " ".join(params))
101157

102-
async def execute(self, name, params, channel, userdata, rank, chan):
103-
await self.send_message(channel, f"Hello, {name}!")
158+
COMMANDS = {
159+
"say": {"execute": _say, "permission": Permission.HIDDEN},
160+
}
104161
```
105162

106-
- `ID` — the command name users type after the prefix
107-
- `permission` — minimum rank required (0–3)
108-
- `execute()` — called when the command is invoked; `self` is the `CommandRouter`
109-
- `setup(self, startup)` — optional, called on load/reload
110-
- `teardown(self)` — optional, called on unload
163+
See `plugins/examples.py` for patterns using timer events, chat events, and background tasks.
111164

112-
See `commands/example_*.py` for more patterns (timer events, background tasks, etc.).
165+
For the full plugin development guide, see [docs/plugins.md](docs/plugins.md).
113166

114167
## Contributing
115168

0 commit comments

Comments
 (0)