IRC bot that automatically keeps mods in Not Enough Mods up-to-date.
Python 3.14+, managed with uv, linted/formatted with ruff, tested with pytest.
uv sync --dev # Install all dependencies (including dev)
uv run python irc_bot.py # Start the bot (requires config.yml)uv run ruff check . # Lint (pyflakes, pycodestyle, isort, pyupgrade, bugbear, simplify, ruff)
uv run ruff check --fix . # Lint with auto-fix
uv run ruff format . # Format code
uv run ruff format --check . # Check formatting without modifyingCI runs both ruff check . and ruff format --check . — both must pass.
uv run pytest # Run all tests
uv run pytest tests/test_ban_list.py # Run one test file
uv run pytest tests/test_ban_list.py::TestBanListGroups # Run one test class
uv run pytest tests/test_ban_list.py::TestBanListGroups::test_define_group # Run single test
uv run pytest -x # Stop on first failure
uv run pytest --tb=short -q # Short output (CI mode)All async tests run automatically via pytest-asyncio with asyncio_mode = "auto".
Flat layout (no src/ directory). Primary modules live at the repo root:
irc_bot.py— Entry point,IrcBotclassirc_connection.py— TCP/IRC connection handlingcommand_router.py— Central command dispatch, plugin loadingbot_events.py— Event system (timer, chat, join events)ban_list.py— SQLite-backed ban systemconfig.py,irc_logging.py,help_system.py,user_auth.py,task_pool.pyplugins/— Bot command plugins (dynamically loaded viaimportlib)irc_handlers/— IRC protocol handlers (one per command/numeric)mod_polling/— Mod polling engine, parsers, data filestests/— All tests, withconftest.pyfor shared fixtures
- Line length: 120 characters (
pyproject.toml[tool.ruff]) - Indentation: 4 spaces for Python, 2 spaces for YAML (
.editorconfig) - Final newline: Always. Trailing whitespace: Always trim.
- Absolute imports only — never use relative imports (
from . import) - Ordering (enforced by ruff/isort): stdlib → third-party → local, separated by blank lines
pluginsandirc_handlersare configured as known first-party in isort- Use
import modulefor broad usage;from module import Namefor specific items
| Element | Convention | Example |
|---|---|---|
| Modules | snake_case |
irc_connection.py, bot_events.py |
| IRC handler modules | include numeric | rpl_endofmotd_376.py |
| Classes | PascalCase |
IrcBot, ModPoller, CommandRouter |
| Functions/methods | snake_case |
fetch_page, check_mod, add_event |
| Private members | _underscore |
_parse_message, _handler_lock |
| Constants | UPPER_SNAKE_CASE |
PLUGIN_ID, MAX_POLL_FAILURES |
| Plugin IDs | PLUGIN_ID = "x" |
Module-level constant in every plugin |
| IRC handler IDs | ID = "XXX" |
Module-level constant ("PRIVMSG", "376") |
| New-style commands | cmd_ prefix |
cmd_enable, cmd_disable, cmd_status |
| Command aliases | alias_ prefix |
alias_start, alias_stop |
| Unused loop vars | _prefix |
_i, _k, _op |
Used sparingly — only on NamedTuple fields and occasional instance variables.
Function signatures do not carry type hints.
- Use modern union syntax:
X | Y(notOptional[X]orUnion[X, Y]) - Annotate
NamedTuplefields and complex instance variables where it aids clarity
- f-strings for most string construction
.format()only for complex IRC messages with many color-code variables%-style only insideloggingcalls (lazy interpolation)
- Custom exceptions inherit from
Exceptionwith__init__and__str__ NEMPExceptionis the base for polling exceptions;InvalidVersioninherits from itConnectionDowninherits directly fromException(notNEMPException)- Top-level loops use broad
except Exceptionwithlogger.exception() - Specific catches where meaningful:
KeyError,TimeoutError,asyncio.CancelledError - Use
contextlib.suppress(ExcType)instead of baretry/except pass - Validate inputs eagerly with
isinstance, raisingTypeError/ValueError
Fully async on asyncio. Entry point: asyncio.run(async_main()) in irc_bot.py.
- Background tasks via
asyncio.create_task()with done-callback cleanup asyncio.Lockfor serialized handler execution and per-host rate limitingasyncio.Queuefor inter-task communication (withqueue.shutdown()for cleanup)asyncio.gather,asyncio.as_completed,asyncio.wait_forfor concurrency- aiohttp sessions: explicit
User-Agentheader,aiohttp.ClientTimeout,async with
- Module-level loggers:
logger = logging.getLogger("BanList") - Instance-level loggers:
self._logger = logging.getLogger("IRCConnection") - Hierarchical naming:
irc.ping,irc.rpl.353,cmd.say,cmd.pycalc - Always
%-style formatting in log calls. Use.exception()for tracebacks.
Two styles coexist. Prefer new-style (class-based) for new plugins.
Old-style (function-based): COMMANDS dict + underscore-prefixed async functions:
PLUGIN_ID = "say"
async def _say(router, name, params, channel, userdata, rank, is_channel): ...
COMMANDS = {"say": {"execute": _say, "permission": Permission.HIDDEN}}New-style (class-based): Plugin class with @command/@subcommand decorators:
PLUGIN_ID = "nemp"
class Plugin:
async def setup(self, router, startup): ...
async def teardown(self, router): ...
@command("nemp", permission=Permission.VOICED, allow_private=True)
async def nemp(self, router, name, params, channel, userdata, rank, is_channel): ...
@subcommand("nemp", "enable", permission=Permission.OP)
async def cmd_enable(self, router, ...): ...- Test files:
tests/test_<module>.py - Group tests in
class TestXxxwithtest_xxxmethods pytest.raises(ExcType, match="pattern")for exception testingtmp_pathfixture for file/database isolation- Mocking:
MagicMock(sync),AsyncMock(async),patch/patch.object - HTTP mocking:
aioresponseslibrary foraiohttprequests - Plain
assertstatements (pytest-style, nounittestassertions) - Shared fixtures in
tests/conftest.py(e.g.,mod_poller,ban_list)
ruffconfig:pyproject.toml— rules: F, E, W, I, UP, B, SIM, RUFpytestconfig:pyproject.toml—asyncio_mode = "auto",testpaths = ["tests"].editorconfig: charset utf-8, trim whitespace, final newlines.gitignore:config.yml,*.db,__pycache__/,BotLogs/,mod_polling/htdocs/- CI:
.github/workflows/tests.yml— runs lint + tests on push/PR tomaster