|
| 1 | +# AGENTS.md — NotEnoughModPolling |
| 2 | + |
| 3 | +IRC bot that automatically keeps mods in Not Enough Mods up-to-date. |
| 4 | +Python 3.14+, managed with `uv`, linted/formatted with `ruff`, tested with `pytest`. |
| 5 | + |
| 6 | +## Build & Run |
| 7 | + |
| 8 | +```bash |
| 9 | +uv sync --dev # Install all dependencies (including dev) |
| 10 | +uv run python irc_bot.py # Start the bot (requires config.yml) |
| 11 | +``` |
| 12 | + |
| 13 | +## Lint |
| 14 | + |
| 15 | +```bash |
| 16 | +uv run ruff check . # Lint (pyflakes, pycodestyle, isort, pyupgrade, bugbear, simplify, ruff) |
| 17 | +uv run ruff check --fix . # Lint with auto-fix |
| 18 | +uv run ruff format . # Format code |
| 19 | +uv run ruff format --check . # Check formatting without modifying |
| 20 | +``` |
| 21 | + |
| 22 | +CI runs both `ruff check .` and `ruff format --check .` — both must pass. |
| 23 | + |
| 24 | +## Test |
| 25 | + |
| 26 | +```bash |
| 27 | +uv run pytest # Run all tests |
| 28 | +uv run pytest tests/test_ban_list.py # Run one test file |
| 29 | +uv run pytest tests/test_ban_list.py::TestBanListGroups # Run one test class |
| 30 | +uv run pytest tests/test_ban_list.py::TestBanListGroups::test_define_group # Run single test |
| 31 | +uv run pytest -x # Stop on first failure |
| 32 | +uv run pytest --tb=short -q # Short output (CI mode) |
| 33 | +``` |
| 34 | + |
| 35 | +All async tests run automatically via `pytest-asyncio` with `asyncio_mode = "auto"`. |
| 36 | + |
| 37 | +## Project Structure |
| 38 | + |
| 39 | +Flat layout (no `src/` directory). All primary modules live at the repo root: |
| 40 | + |
| 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()`. |
| 61 | + |
| 62 | +## Code Style |
| 63 | + |
| 64 | +### Formatting |
| 65 | + |
| 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 |
| 70 | + |
| 71 | +### Imports |
| 72 | + |
| 73 | +- **Absolute imports only** — never use relative imports (`from . import`) |
| 74 | +- **Ordering** (enforced by ruff/isort): stdlib → third-party → local, separated by blank lines |
| 75 | +- `plugins` and `irc_handlers` are configured as known first-party in isort |
| 76 | +- 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 | +``` |
| 89 | + |
| 90 | +### Naming Conventions |
| 91 | + |
| 92 | +| Element | Convention | Example | |
| 93 | +|----------------------|--------------------|--------------------------------------------| |
| 94 | +| Modules | `snake_case` | `irc_connection.py`, `bot_events.py` | |
| 95 | +| IRC handler modules | include numeric | `rpl_endofmotd_376.py`, `err_nicknameinuse_433.py` | |
| 96 | +| Classes | `PascalCase` | `IrcBot`, `ModPoller`, `CommandRouter` | |
| 97 | +| Functions/methods | `snake_case` | `fetch_page`, `check_mod`, `add_event` | |
| 98 | +| Private members | `_underscore` | `_parse_message`, `_handler_lock` | |
| 99 | +| Constants | `UPPER_SNAKE_CASE` | `PLUGIN_ID`, `MAX_POLL_FAILURES` | |
| 100 | +| Plugin IDs | `PLUGIN_ID = "x"` | Module-level constant in every plugin | |
| 101 | +| IRC handler IDs | `ID = "XXX"` | Module-level constant (`"PRIVMSG"`, `"376"`) | |
| 102 | +| New-style commands | `cmd_` prefix | `cmd_enable`, `cmd_disable`, `cmd_status` | |
| 103 | +| Command aliases | `alias_` prefix | `alias_start`, `alias_stop` | |
| 104 | +| Unused loop vars | `_prefix` | `_i`, `_k`, `_op` | |
| 105 | + |
| 106 | +### Type Annotations |
| 107 | + |
| 108 | +Type annotations are used **sparingly** — only on `NamedTuple` fields and occasional |
| 109 | +instance variable declarations. Function signatures do not carry type hints. |
| 110 | + |
| 111 | +- Use modern union syntax: `X | Y` (not `Optional[X]` or `Union[X, Y]`) |
| 112 | +- Annotate `NamedTuple` fields and complex instance variables where it aids clarity |
| 113 | + |
| 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 | + |
| 125 | +### String Formatting |
| 126 | + |
| 127 | +- **f-strings** for most string construction |
| 128 | +- **`.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 | +``` |
| 136 | + |
| 137 | +### Error Handling |
| 138 | + |
| 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` |
| 144 | +- Use `contextlib.suppress(ExcType)` instead of bare `try/except pass` |
| 145 | +- Validate inputs eagerly with `isinstance` checks, raising `TypeError`/`ValueError` |
| 146 | + |
| 147 | +### Async Patterns |
| 148 | + |
| 149 | +The codebase is fully async, built on `asyncio`: |
| 150 | + |
| 151 | +- Entry point: `asyncio.run(async_main())` in `irc_bot.py` |
| 152 | +- Background tasks via `asyncio.create_task()` with done-callback cleanup |
| 153 | +- `asyncio.Lock` for serialized handler execution and per-host rate limiting |
| 154 | +- `asyncio.Queue` for inter-task communication (with `queue.shutdown()` for cleanup) |
| 155 | +- `asyncio.gather`, `asyncio.as_completed`, `asyncio.wait_for` for concurrency |
| 156 | +- `async for` with async generators (`IrcConnection.read_lines()`) |
| 157 | +- aiohttp sessions: explicit `User-Agent` header, `aiohttp.ClientTimeout`, `async with` |
| 158 | + |
| 159 | +### Logging |
| 160 | + |
| 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 |
| 166 | + |
| 167 | +### Plugin Architecture |
| 168 | + |
| 169 | +Two plugin styles coexist: |
| 170 | + |
| 171 | +**Old-style** (function-based): module-level `COMMANDS` dict + underscore-prefixed async functions: |
| 172 | +```python |
| 173 | +PLUGIN_ID = "say" |
| 174 | +async def _say(router, name, params, channel, userdata, rank, is_channel): ... |
| 175 | +COMMANDS = {"say": {"execute": _say, "permission": Permission.HIDDEN}} |
| 176 | +``` |
| 177 | + |
| 178 | +**New-style** (class-based): `Plugin` class with `@command`/`@subcommand` decorators: |
| 179 | +```python |
| 180 | +PLUGIN_ID = "nemp" |
| 181 | +class Plugin: |
| 182 | + async def setup(self, router, startup): ... |
| 183 | + async def teardown(self, router): ... |
| 184 | + @command("nemp", permission=Permission.VOICED, allow_private=True) |
| 185 | + async def nemp(self, router, name, params, channel, userdata, rank, is_channel): ... |
| 186 | + @subcommand("nemp", "enable", permission=Permission.OP) |
| 187 | + async def cmd_enable(self, router, ...): ... |
| 188 | +``` |
| 189 | + |
| 190 | +Prefer the **new-style** class-based pattern for new plugins. |
| 191 | + |
| 192 | +## Testing Conventions |
| 193 | + |
| 194 | +- Test files: `tests/test_<module>.py` |
| 195 | +- 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 |
| 198 | +- Mocking: `MagicMock` (sync), `AsyncMock` (async), `patch`/`patch.object` |
| 199 | +- HTTP mocking: `aioresponses` library for `aiohttp` requests |
| 200 | +- Plain `assert` statements (pytest-style, no `unittest` assertions) |
| 201 | +- Shared fixtures in `tests/conftest.py` (e.g., `mod_poller`, `ban_list`, event fixtures) |
| 202 | + |
| 203 | +## Key Configuration |
| 204 | + |
| 205 | +- `ruff` config: `pyproject.toml` — rules: F, E, W, I, UP, B, SIM, RUF |
| 206 | +- `pytest` config: `pyproject.toml` — `asyncio_mode = "auto"`, `testpaths = ["tests"]` |
| 207 | +- `.editorconfig`: charset utf-8, trim whitespace, final newlines |
| 208 | +- `.gitignore`: `config.yml`, `*.db`, `__pycache__/`, `BotLogs/`, `mod_polling/htdocs/` |
| 209 | +- CI: `.github/workflows/tests.yml` — runs lint + tests on push/PR to `master` |
0 commit comments