1515from user_auth import AuthTracker
1616
1717
18+ def command (name , permission , allow_private = False ):
19+ """Decorator that marks a Plugin method as a bot command."""
20+
21+ def decorator (func ):
22+ func ._command_info = {
23+ "name" : name ,
24+ "permission" : permission ,
25+ "allow_private" : allow_private ,
26+ }
27+ return func
28+
29+ return decorator
30+
31+
32+ def subcommand (group , name , permission = None ):
33+ """Decorator that marks a Plugin method as a subcommand of a command group.
34+
35+ If permission is None, the group's permission is used.
36+ """
37+
38+ def decorator (func ):
39+ func ._subcommand_info = {
40+ "group" : group ,
41+ "name" : name ,
42+ "permission" : permission ,
43+ }
44+ return func
45+
46+ return decorator
47+
48+
1849class Permission (IntEnum ):
1950 """Minimum rank required to use a command.
2051
@@ -47,13 +78,15 @@ class PluginEntry(NamedTuple):
4778 command_names: Tuple of command names registered by this plugin.
4879 setup: ``async setup(router, startup)`` coroutine, or *None*.
4980 teardown: ``async teardown(router)`` coroutine, or *None*.
81+ instance: For new-style plugins, the ``Plugin`` class instance.
5082 """
5183
5284 module : object
5385 path : str
5486 command_names : tuple
5587 setup : Callable | None
5688 teardown : Callable | None
89+ instance : object | None = None
5790
5891
5992class CommandEntry (NamedTuple ):
@@ -106,7 +139,7 @@ def __init__(self, channels, cmdprefix, name, ident, adminlist, loglevel):
106139 self .name = name
107140 self .ident = ident
108141 self .protocol_handlers = self ._load_protocol_handlers ("irc_handlers" )
109- self .plugins , self .commands = self ._load_plugins ("commands " )
142+ self .plugins , self .commands = self ._load_plugins ("plugins " )
110143
111144 self .operators = adminlist
112145 self .auth_tracker = AuthTracker (adminlist )
@@ -385,15 +418,93 @@ def _load_source(name, path):
385418 spec .loader .exec_module (module )
386419 return module
387420
421+ def add_plugin (self , plugin_id , instance , module , path ):
422+ """Register a new-style Plugin instance. Discovers decorated methods."""
423+ command_names = []
424+ group_commands = {}
425+ subcommands = {}
426+
427+ for attr_name in dir (instance ):
428+ method = getattr (instance , attr_name , None )
429+ if method is None :
430+ continue
431+
432+ cmd_info = getattr (method , "_command_info" , None )
433+ if cmd_info is not None :
434+ group_commands [cmd_info ["name" ]] = (method , cmd_info )
435+
436+ sub_info = getattr (method , "_subcommand_info" , None )
437+ if sub_info is not None :
438+ group = sub_info ["group" ]
439+ subcommands .setdefault (group , {})[sub_info ["name" ]] = (
440+ method ,
441+ sub_info ["permission" ],
442+ )
443+
444+ for cmd_name , (method , cmd_info ) in group_commands .items ():
445+ subs = subcommands .get (cmd_name )
446+ execute = self ._make_group_dispatch (method , subs , cmd_info ["permission" ]) if subs else method
447+
448+ self .commands [cmd_name ] = CommandEntry (
449+ execute = execute ,
450+ permission = cmd_info ["permission" ],
451+ allow_private = cmd_info .get ("allow_private" , False ),
452+ plugin_id = plugin_id ,
453+ )
454+ command_names .append (cmd_name )
455+
456+ setup_fn = getattr (instance , "setup" , None )
457+ teardown_fn = getattr (instance , "teardown" , None )
458+
459+ self .plugins [plugin_id ] = PluginEntry (
460+ module = module ,
461+ path = path ,
462+ command_names = tuple (command_names ),
463+ setup = setup_fn ,
464+ teardown = teardown_fn ,
465+ instance = instance ,
466+ )
467+
468+ def _make_group_dispatch (self , fallback , subcommands , group_permission ):
469+ """Create an auto-dispatch function for a command group."""
470+
471+ async def dispatch (router , name , params , channel , userdata , rank , is_channel ):
472+ if params :
473+ sub_name = params [0 ].lower ()
474+ if sub_name in subcommands :
475+ method , sub_perm = subcommands [sub_name ]
476+ required = sub_perm if sub_perm is not None else group_permission
477+ if rank >= required :
478+ await method (router , name , params , channel , userdata , rank )
479+ else :
480+ await router .send_message (channel , "You're not authorized to use this command." )
481+ return
482+ await fallback (router , name , params , channel , userdata , rank , is_channel )
483+
484+ return dispatch
485+
486+ def remove_plugin (self , plugin_id ):
487+ """Unregister a plugin and all its commands."""
488+ plugin = self .plugins .get (plugin_id )
489+ if not plugin :
490+ return
491+ for name in plugin .command_names :
492+ self .commands .pop (name , None )
493+ del self .plugins [plugin_id ]
494+
388495 def _load_plugins (self , path ):
389496 """Load all plugin modules from *path*.
390497
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``).
498+ Each plugin module must define ``PLUGIN_ID`` (str).
499+
500+ **New-style** plugins define a ``Plugin`` class with ``@command``
501+ and ``@subcommand`` decorated methods. **Old-style** plugins
502+ define a ``COMMANDS`` dict mapping command names to dicts with
503+ keys ``execute``, ``permission``, and optionally ``allow_private``.
394504
395505 A module may optionally define ``async setup(router, startup)``
396- and/or ``async teardown(router)`` for lifecycle hooks.
506+ and/or ``async teardown(router)`` for lifecycle hooks (old-style),
507+ or the ``Plugin`` class may define those methods (new-style).
397508
398509 Returns:
399510 ``(plugins_dict, commands_dict)`` — *plugins_dict* maps plugin
@@ -403,46 +514,54 @@ def _load_plugins(self, path):
403514 file_list = self ._list_dir (path )
404515 self ._logger .info ("Loading plugins in path '%s'..." , path )
405516
406- plugins = {}
407- commands = {}
517+ # add_plugin() writes directly to self.plugins / self.commands,
518+ # so we initialize them here and return them at the end.
519+ self .plugins = {}
520+ self .commands = {}
408521
409522 for filename in file_list :
410523 filepath = path + "/" + filename
411524 self ._logger .debug ("Loading file %s in path '%s'" , filename , path )
412525 module = self ._load_source ("NEMP_" + filename [:- 3 ], filepath )
413526
414527 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 ,
528+ plugin_cls = getattr (module , "Plugin" , None )
529+
530+ if plugin_cls is not None :
531+ instance = plugin_cls ()
532+ self .add_plugin (plugin_id , instance , module , filepath )
533+ else :
534+ commands_dict = module .COMMANDS
535+
536+ setup_fn = getattr (module , "setup" , None )
537+ if setup_fn is not None and not callable (setup_fn ):
538+ setup_fn = None
539+
540+ teardown_fn = getattr (module , "teardown" , None )
541+ if teardown_fn is not None and not callable (teardown_fn ):
542+ teardown_fn = None
543+
544+ command_names = []
545+ for cmd_name , cmd_info in commands_dict .items ():
546+ entry = CommandEntry (
547+ execute = cmd_info ["execute" ],
548+ permission = cmd_info ["permission" ],
549+ allow_private = cmd_info .get ("allow_private" , False ),
550+ plugin_id = plugin_id ,
551+ )
552+ self .commands [cmd_name ] = entry
553+ command_names .append (cmd_name )
554+
555+ self .plugins [plugin_id ] = PluginEntry (
556+ module = module ,
557+ path = filepath ,
558+ command_names = tuple (command_names ),
559+ setup = setup_fn ,
560+ teardown = teardown_fn ,
432561 )
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- )
443562
444563 self ._logger .info ("Plugins in path '%s' loaded." , path )
445- return plugins , commands
564+ return self . plugins , self . commands
446565
447566 def _load_protocol_handlers (self , path ):
448567 """Load IRC protocol handler modules from *path*.
0 commit comments