Skip to content

Commit 2716896

Browse files
committed
refactor: plugin system for commands
Replace opaque tuple-based command/handler storage with named data structures (Permission IntEnum, PluginEntry, CommandEntry, HandlerEntry). Each command plugin now defines PLUGIN_ID and a COMMANDS dict; the loader builds typed namedtuples from these. Unify dispatch so is_channel is always passed to execute and allow_private gates PM support. Pass integer rank instead of string prefix to all command handlers. Merge related single-command files into multi-command plugins: ban_management, operator_management, channel_management, time_display, plugin_management, examples. Delete 20 absorbed files and dead plugin_loader.py.
1 parent 9ec80c9 commit 2716896

44 files changed

Lines changed: 1505 additions & 1371 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

command_router.py

Lines changed: 174 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import importlib.util
33
import logging
44
import os
5+
from collections.abc import Callable
56
from datetime import datetime
7+
from enum import IntEnum
8+
from typing import NamedTuple
69

710
import task_pool
811
from ban_list import BanList
@@ -12,6 +15,88 @@
1215
from user_auth import AuthTracker
1316

1417

18+
class Permission(IntEnum):
19+
"""Minimum rank required to use a command.
20+
21+
Each level corresponds to an IRC rank:
22+
23+
======== ===== ========================================
24+
Name Value IRC meaning
25+
======== ===== ========================================
26+
GUEST 0 Anyone
27+
VOICED 1 ``+`` and above
28+
OP 2 ``@`` and above
29+
ADMIN 3 ``@@`` — bot operator (admin list + registered)
30+
HIDDEN 4 Not shown in command list, restricted access
31+
======== ===== ========================================
32+
"""
33+
34+
GUEST = 0
35+
VOICED = 1
36+
OP = 2
37+
ADMIN = 3
38+
HIDDEN = 4
39+
40+
41+
class PluginEntry(NamedTuple):
42+
"""Lifecycle record for a loaded plugin module.
43+
44+
Attributes:
45+
module: The loaded Python module object.
46+
path: Filesystem path the module was loaded from.
47+
command_names: Tuple of command names registered by this plugin.
48+
setup: ``async setup(router, startup)`` coroutine, or *None*.
49+
teardown: ``async teardown(router)`` coroutine, or *None*.
50+
"""
51+
52+
module: object
53+
path: str
54+
command_names: tuple
55+
setup: Callable | None
56+
teardown: Callable | None
57+
58+
59+
class CommandEntry(NamedTuple):
60+
"""Dispatch record for a single command.
61+
62+
Attributes:
63+
execute: Async callable — signature is
64+
``(router, name, params, channel, userdata, rank, is_channel)``.
65+
permission: Minimum :class:`Permission` level required.
66+
allow_private: If *True* the command works in PMs; if *False*
67+
(the default) it is silently ignored outside channels.
68+
``is_channel`` is always passed to *execute* regardless.
69+
plugin_id: The ``PLUGIN_ID`` of the owning plugin.
70+
"""
71+
72+
execute: Callable
73+
permission: Permission
74+
allow_private: bool
75+
plugin_id: str
76+
77+
78+
class HandlerEntry(NamedTuple):
79+
"""Record for a loaded IRC protocol handler module.
80+
81+
Attributes:
82+
module: The loaded module (must expose ``ID`` and ``async execute``).
83+
path: Filesystem path the module was loaded from.
84+
"""
85+
86+
module: object
87+
path: str
88+
89+
90+
# Maps IRC user-mode prefixes to Permission values. "@@" in the channel
91+
# userlist means IRC-op but NOT bot-admin, so it maps to OP (2), not ADMIN.
92+
_RANK_FROM_PREFIX = {
93+
"@@": Permission.OP,
94+
"@": Permission.OP,
95+
"+": Permission.VOICED,
96+
"": Permission.GUEST,
97+
}
98+
99+
15100
class CommandRouter:
16101
def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
17102

@@ -20,8 +105,8 @@ def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
20105

21106
self.name = name
22107
self.ident = ident
23-
self.protocol_handlers = self._load_modules("irc_handlers")
24-
self.commands = self._load_modules("commands")
108+
self.protocol_handlers = self._load_protocol_handlers("irc_handlers")
109+
self.plugins, self.commands = self._load_plugins("commands")
25110

26111
self.operators = adminlist
27112
self.auth_tracker = AuthTracker(adminlist)
@@ -46,7 +131,6 @@ def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
46131

47132
self.server = None
48133
self.latency = None
49-
self.rank_values = {"@@": 3, "@": 2, "+": 1, "": 0}
50134
self.startup_time = datetime.now()
51135

52136
self.recent_messages = asyncio.Queue(maxsize=50)
@@ -63,10 +147,10 @@ def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
63147
self.auth = None
64148

65149
async def close(self):
66-
"""Call teardown() on all command modules that define one."""
67-
for cmd in self.commands:
68-
if self.commands[cmd][0].teardown:
69-
await self.commands[cmd][0].teardown(self)
150+
"""Call teardown() on all plugins that define one."""
151+
for _plugin_id, plugin in self.plugins.items():
152+
if plugin.teardown:
153+
await plugin.teardown(self)
70154

71155
async def handle(self, send, prefix, command, params, auth):
72156
self.send = send
@@ -82,7 +166,7 @@ async def handle(self, send, prefix, command, params, auth):
82166

83167
try:
84168
if command in self.protocol_handlers:
85-
await self.protocol_handlers[command][0].execute(self, send, prefix, command, params)
169+
await self.protocol_handlers[command].module.execute(self, send, prefix, command, params)
86170
else:
87171
# 0 is the lowest possible log level. Messages about unimplemented packets are
88172
# very common, so they will clutter up the file even if logging is set to DEBUG
@@ -127,14 +211,11 @@ def get_user_rank(self, channel, username):
127211

128212
def get_user_rank_num(self, channel, username):
129213
if username in self.operators and self.auth_tracker.is_registered(username):
130-
return 3
214+
return Permission.ADMIN
131215
else:
132216
for user in self.channel_data[channel]["Userlist"]:
133217
if user[0].lower() == username.lower():
134-
if user[1] == "@@":
135-
return 2
136-
else:
137-
return self.rank_values[user[1]]
218+
return _RANK_FROM_PREFIX[user[1]]
138219

139220
return -1 # No user found
140221

@@ -304,30 +385,83 @@ def _load_source(name, path):
304385
spec.loader.exec_module(module)
305386
return module
306387

307-
def _load_modules(self, path):
308-
ModuleList = self._list_dir(path)
309-
self._logger.info("Loading modules in path '%s'...", path)
310-
Packet = {}
311-
for i in ModuleList:
312-
self._logger.debug("Loading file %s in path '%s'", i, path)
313-
module = self._load_source("NEMP_" + i[0:-3], path + "/" + i)
314-
Packet[module.ID] = (module, path + "/" + i)
315-
316-
try:
317-
if not callable(module.setup):
318-
module.setup = False
319-
self._logger.log(0, "File %s does not use a setup function", i)
320-
except AttributeError:
321-
module.setup = False
322-
self._logger.log(0, "File %s does not use a setup function", i)
323-
324-
try:
325-
if not callable(module.teardown):
326-
module.teardown = False
327-
except AttributeError:
328-
module.teardown = False
329-
330-
Packet[module.ID] = (module, path + "/" + i)
331-
332-
self._logger.info("Modules in path '%s' loaded.", path)
333-
return Packet
388+
def _load_plugins(self, path):
389+
"""Load all plugin modules from *path*.
390+
391+
Each plugin module must define ``PLUGIN_ID`` (str) and ``COMMANDS``
392+
(dict mapping command names to dicts with keys ``execute``,
393+
``permission``, and optionally ``allow_private``).
394+
395+
A module may optionally define ``async setup(router, startup)``
396+
and/or ``async teardown(router)`` for lifecycle hooks.
397+
398+
Returns:
399+
``(plugins_dict, commands_dict)`` — *plugins_dict* maps plugin
400+
IDs to :class:`PluginEntry`; *commands_dict* maps command names
401+
to :class:`CommandEntry`.
402+
"""
403+
file_list = self._list_dir(path)
404+
self._logger.info("Loading plugins in path '%s'...", path)
405+
406+
plugins = {}
407+
commands = {}
408+
409+
for filename in file_list:
410+
filepath = path + "/" + filename
411+
self._logger.debug("Loading file %s in path '%s'", filename, path)
412+
module = self._load_source("NEMP_" + filename[:-3], filepath)
413+
414+
plugin_id = module.PLUGIN_ID
415+
commands_dict = module.COMMANDS
416+
417+
setup_fn = getattr(module, "setup", None)
418+
if setup_fn is not None and not callable(setup_fn):
419+
setup_fn = None
420+
421+
teardown_fn = getattr(module, "teardown", None)
422+
if teardown_fn is not None and not callable(teardown_fn):
423+
teardown_fn = None
424+
425+
command_names = []
426+
for cmd_name, cmd_info in commands_dict.items():
427+
entry = CommandEntry(
428+
execute=cmd_info["execute"],
429+
permission=cmd_info["permission"],
430+
allow_private=cmd_info.get("allow_private", False),
431+
plugin_id=plugin_id,
432+
)
433+
commands[cmd_name] = entry
434+
command_names.append(cmd_name)
435+
436+
plugins[plugin_id] = PluginEntry(
437+
module=module,
438+
path=filepath,
439+
command_names=tuple(command_names),
440+
setup=setup_fn,
441+
teardown=teardown_fn,
442+
)
443+
444+
self._logger.info("Plugins in path '%s' loaded.", path)
445+
return plugins, commands
446+
447+
def _load_protocol_handlers(self, path):
448+
"""Load IRC protocol handler modules from *path*.
449+
450+
Each handler module must define ``ID`` (the IRC command/numeric it
451+
handles) and ``async execute(router, send, prefix, command, params)``.
452+
453+
Returns:
454+
A dict mapping IRC commands to :class:`HandlerEntry`.
455+
"""
456+
file_list = self._list_dir(path)
457+
self._logger.info("Loading protocol handlers in path '%s'...", path)
458+
459+
handlers = {}
460+
for filename in file_list:
461+
filepath = path + "/" + filename
462+
self._logger.debug("Loading file %s in path '%s'", filename, path)
463+
module = self._load_source("NEMP_" + filename[:-3], filepath)
464+
handlers[module.ID] = HandlerEntry(module=module, path=filepath)
465+
466+
self._logger.info("Protocol handlers in path '%s' loaded.", path)
467+
return handlers

commands/addop.py

Lines changed: 0 additions & 21 deletions
This file was deleted.

commands/ban.py

Lines changed: 0 additions & 99 deletions
This file was deleted.

0 commit comments

Comments
 (0)