1212
1313import asyncio
1414import html
15+ import importlib .metadata as importlib_metadata
1516import re
1617import threading
1718import time
@@ -88,6 +89,8 @@ class Bot(GatewayModule):
8889 _thread : Optional [threading .Thread ] = PrivateAttr (None )
8990 _lock : threading .Lock = PrivateAttr (default_factory = threading .Lock )
9091
92+ _KNOWN_BACKENDS : Set [str ] = {"discord" , "slack" , "symphony" , "telegram" }
93+
9194 def set_deps (self , deps : Any ) -> None :
9295 """Set shared dependency object for new command framework contexts."""
9396 self ._deps = deps
@@ -334,6 +337,8 @@ def load_commands(self, command_models: List[Any]) -> None:
334337 Supports both legacy BaseCommandModel and the new CommandModel.
335338 """
336339 log .info (f"Loading { len (command_models )} commands..." )
340+ self ._load_entrypoint_commands ()
341+ active_backends = self ._active_backends ()
337342 for model in command_models :
338343 try :
339344 command = model .command ()
@@ -350,6 +355,9 @@ def load_commands(self, command_models: List[Any]) -> None:
350355 else :
351356 raise TypeError (f"Unsupported command type from model { type (model ).__name__ } : { type (command ).__name__ } " )
352357
358+ if not self ._is_command_backend_compatible (command_str , runner , active_backends ):
359+ continue
360+
353361 log .info (f"Registered command: /{ command_str } " )
354362 if command_str in self ._commands :
355363 raise Exception (f"Command already registered: { command_str } \n \t { command } \n \t { self ._commands [command_str ]} " )
@@ -362,9 +370,86 @@ def load_commands(self, command_models: List[Any]) -> None:
362370 for command_name , entry in get_registered_commands ().items ():
363371 if command_name in self ._commands :
364372 continue
373+ if not self ._is_command_backend_compatible (command_name , entry , active_backends ):
374+ continue
365375 log .info (f"Registered decorated command: /{ command_name } " )
366376 self ._commands [command_name ] = entry
367377
378+ def _load_entrypoint_commands (self ) -> None :
379+ """Load command plugins from Python entry points.
380+
381+ Entry points in the ``csp_bot.commands`` group are imported so they can
382+ register commands through decorators or module import side effects.
383+ If the loaded object is callable, it is invoked with no arguments.
384+ """
385+ try :
386+ try :
387+ entry_points = importlib_metadata .entry_points (group = "csp_bot.commands" )
388+ except TypeError :
389+ all_entry_points = importlib_metadata .entry_points ()
390+ entry_points = all_entry_points .get ("csp_bot.commands" , [])
391+ except Exception :
392+ log .exception ("Failed to discover csp_bot.commands entry points" )
393+ return
394+
395+ for entry_point in entry_points :
396+ try :
397+ loaded = entry_point .load ()
398+ except Exception :
399+ log .exception ("Failed to load command entry point: %s" , getattr (entry_point , "name" , "<unknown>" ))
400+ continue
401+
402+ if callable (loaded ):
403+ try :
404+ loaded ()
405+ except Exception :
406+ log .exception ("Failed to initialize command entry point: %s" , getattr (entry_point , "name" , "<unknown>" ))
407+ continue
408+
409+ log .info ("Loaded command entry point: %s" , getattr (entry_point , "name" , "<unknown>" ))
410+
411+ def _active_backends (self ) -> Set [str ]:
412+ """Return configured backends for this bot instance."""
413+ active : Set [str ] = set ()
414+ if self .config .discord :
415+ active .add ("discord" )
416+ if self .config .slack :
417+ active .add ("slack" )
418+ if self .config .symphony :
419+ active .add ("symphony" )
420+ return active
421+
422+ def _normalize_command_backends (self , command_name : str , backends : List [str ]) -> List [str ]:
423+ """Normalize and validate declared command backends."""
424+ normalized = [b .lower () for b in backends ]
425+ unknown = sorted ({b for b in normalized if b not in self ._KNOWN_BACKENDS })
426+ if unknown :
427+ raise ValueError (f"Command '{ command_name } ' declared unknown backends: { ', ' .join (unknown )} " )
428+ return normalized
429+
430+ def _is_command_backend_compatible (self , command_name : str , command_runner : Any , active_backends : Set [str ]) -> bool :
431+ """Check registration-time backend compatibility for a command."""
432+ declared_backends = self ._command_backends (command_runner )
433+ if not declared_backends :
434+ return True
435+
436+ normalized = self ._normalize_command_backends (command_name , declared_backends )
437+
438+ # If no backends are configured yet, keep command registration permissive.
439+ if not active_backends :
440+ return True
441+
442+ if active_backends .intersection (normalized ):
443+ return True
444+
445+ log .info (
446+ "Skipping command /%s: declared backends %s do not match active backends %s" ,
447+ command_name ,
448+ normalized ,
449+ sorted (active_backends ),
450+ )
451+ return False
452+
368453 def _command_backends (self , command_runner : Any ) -> List [str ]:
369454 """Return supported backends for either legacy or new command types."""
370455 if isinstance (command_runner , BaseCommand ):
@@ -394,10 +479,6 @@ def _build_command_context(self, cmd: BotCommand) -> CommandContext:
394479 deps = self ._deps ,
395480 )
396481
397- # =========================================================================
398- # Message Processing Nodes
399- # =========================================================================
400-
401482 @csp .node
402483 def _process_incoming_messages (self , msg : ts [Message ]) -> Outputs (bot_commands = ts [[BotCommand ]], unauthorized_message = ts [Message ]):
403484 """Process incoming messages to extract bot commands.
@@ -521,10 +602,6 @@ def _handle_commands(self, cmd: ts[BotCommand]) -> Outputs(messages=ts[[Message]
521602
522603 csp .schedule_alarm (a_ratelimit , timedelta (seconds = self .config .ratelimit_seconds ), True )
523604
524- # =========================================================================
525- # Message Analysis using chatom
526- # =========================================================================
527-
528605 def _is_message_to_bot (self , msg : Message , backend : str ) -> Tuple [bool , str , str , List [User ]]:
529606 """Check if a message is directed at the bot.
530607
@@ -713,10 +790,6 @@ def _is_authorized(self, msg: Message, backend: str) -> bool:
713790 authorized = self ._authorized_users .get (backend , set ())
714791 return author_id in authorized
715792
716- # =========================================================================
717- # Command Extraction and Execution
718- # =========================================================================
719-
720793 def _extract_commands (
721794 self ,
722795 msg : Message ,
0 commit comments