22import importlib .util
33import logging
44import os
5+ from collections .abc import Callable
56from datetime import datetime
7+ from enum import IntEnum
8+ from typing import NamedTuple
69
710import task_pool
811from ban_list import BanList
1215from 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+
15100class 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
0 commit comments