Skip to content

Commit f320900

Browse files
committed
Load commands via entrypoints, enforce backend compatibility
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent 9200826 commit f320900

5 files changed

Lines changed: 197 additions & 60 deletions

File tree

csp_bot/bot.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import asyncio
1414
import html
15+
import importlib.metadata as importlib_metadata
1516
import re
1617
import threading
1718
import 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,

csp_bot/commands/echo.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ def execute(self, command: BotCommand) -> Optional[Message]:
3636

3737
# Add mentions for any tagged users
3838
if command.targets:
39-
users = [t.to_chatom_user() if hasattr(t, "to_chatom_user") else t for t in command.targets]
40-
mentions = mention_users(users, command.backend)
39+
mentions = mention_users(list(command.targets), command.backend)
4140
if mentions:
4241
content = f"{content} {mentions}".strip()
4342

csp_bot/tests/test_bot_integration.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from csp_bot import Bot, BotCommand, BotConfig, BotMessage
1818
from csp_bot.bot_config import SymphonyConfig
1919
from csp_bot.commands import HelpCommand, ReplyToOtherCommand
20-
from csp_bot.commands.framework import Command, clear_registry, command
20+
from csp_bot.commands.framework import Command, CommandModel, clear_registry, command
2121
from csp_bot.structs import CommandVariant
2222

2323
# Test Fixtures
@@ -371,6 +371,116 @@ def execute(self, ctx):
371371
assert results[0].content == "token=abc123"
372372

373373

374+
class TestRegistrationTimeBackendPolicy:
375+
"""Tests for registration-time backend compatibility checks."""
376+
377+
def setup_method(self):
378+
clear_registry()
379+
380+
def teardown_method(self):
381+
clear_registry()
382+
383+
def test_decorated_command_skipped_when_backend_not_active(self, bot_with_symphony):
384+
"""Commands limited to inactive backends should not be registered."""
385+
386+
@command(name="slack_only", help="Slack only", backends=["slack"])
387+
def slack_only(ctx):
388+
return "nope"
389+
390+
bot_with_symphony.load_commands([])
391+
392+
assert "slack_only" not in bot_with_symphony._commands
393+
394+
def test_decorated_command_registered_when_backend_active(self, bot_with_symphony):
395+
"""Commands limited to active backends should be registered."""
396+
397+
@command(name="symphony_only", help="Symphony only", backends=["symphony"])
398+
def symphony_only(ctx):
399+
return "ok"
400+
401+
bot_with_symphony.load_commands([])
402+
403+
assert "symphony_only" in bot_with_symphony._commands
404+
405+
def test_invalid_backend_name_raises_on_registration(self, bot_with_symphony):
406+
"""Unknown backend names should fail fast during registration."""
407+
408+
@command(name="bad_backend", help="Bad backend", backends=["not-a-backend"])
409+
def bad_backend(ctx):
410+
return "nope"
411+
412+
with pytest.raises(ValueError, match="unknown backends"):
413+
bot_with_symphony.load_commands([])
414+
415+
def test_model_command_skipped_when_backend_not_active(self, bot_with_symphony):
416+
"""Model-loaded commands should obey registration-time backend filtering."""
417+
418+
class SlackOnlyCommand(Command):
419+
name: str = "model_slack_only"
420+
help: str = "Slack-only model command"
421+
backends: list[str] = ["slack"]
422+
423+
def execute(self, ctx):
424+
return "nope"
425+
426+
model = CommandModel(command=SlackOnlyCommand)
427+
bot_with_symphony.load_commands([model])
428+
429+
assert "model_slack_only" not in bot_with_symphony._commands
430+
431+
432+
class TestEntryPointCommandDiscovery:
433+
"""Tests for plugin command discovery through Python entry points."""
434+
435+
def setup_method(self):
436+
clear_registry()
437+
438+
def teardown_method(self):
439+
clear_registry()
440+
441+
def test_load_commands_discovers_entrypoint_registered_command(self, bot_with_symphony):
442+
"""Entry-point loader should import plugin and register its decorated command."""
443+
444+
def register_plugin_command():
445+
@command(name="from_ep", help="Registered from entry point")
446+
def from_ep(ctx):
447+
return "ok"
448+
449+
entry_point = MagicMock()
450+
entry_point.name = "plugin.from_ep"
451+
entry_point.load.return_value = register_plugin_command
452+
453+
with patch("csp_bot.bot.importlib_metadata.entry_points", return_value=[entry_point]):
454+
bot_with_symphony.load_commands([])
455+
456+
assert "from_ep" in bot_with_symphony._commands
457+
458+
def test_model_command_precedence_over_entrypoint_command(self, bot_with_symphony):
459+
"""Explicit model registration should win when entry point uses same command name."""
460+
461+
def register_plugin_command():
462+
@command(name="clash", help="Plugin command")
463+
def clash(ctx):
464+
return "plugin"
465+
466+
class ClashModelCommand(Command):
467+
name: str = "clash"
468+
help: str = "Model command"
469+
470+
def execute(self, ctx):
471+
return "model"
472+
473+
entry_point = MagicMock()
474+
entry_point.name = "plugin.clash"
475+
entry_point.load.return_value = register_plugin_command
476+
477+
with patch("csp_bot.bot.importlib_metadata.entry_points", return_value=[entry_point]):
478+
bot_with_symphony.load_commands([CommandModel(command=ClashModelCommand)])
479+
480+
assert "clash" in bot_with_symphony._commands
481+
assert isinstance(bot_with_symphony._commands["clash"], ClashModelCommand)
482+
483+
374484
# Command Argument Parsing Tests
375485

376486

csp_bot/tests/test_command_framework.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,6 @@ def _make_ctx(**overrides) -> CommandContext:
3737
return CommandContext(**defaults)
3838

3939

40-
# ===========================================================================
41-
# CommandContext tests
42-
# ===========================================================================
43-
44-
4540
class TestCommandContext:
4641
def test_basic_attributes(self):
4742
ctx = _make_ctx()
@@ -109,11 +104,6 @@ def test_deps_accessible(self):
109104
assert ctx.deps["api_key"] == "abc"
110105

111106

112-
# ===========================================================================
113-
# @command decorator tests
114-
# ===========================================================================
115-
116-
117107
class TestCommandDecorator:
118108
def setup_method(self):
119109
clear_registry()
@@ -163,11 +153,6 @@ def echo(ctx):
163153
assert echo(ctx) == "hello world"
164154

165155

166-
# ===========================================================================
167-
# Command class tests
168-
# ===========================================================================
169-
170-
171156
class TestCommandClass:
172157
def test_subclass_with_fields(self):
173158
class MyCmd(Command):
@@ -205,11 +190,6 @@ def test_base_command_raises_not_implemented(self):
205190
cmd.execute(ctx)
206191

207192

208-
# ===========================================================================
209-
# Executor tests — four signatures
210-
# ===========================================================================
211-
212-
213193
class TestExecutorSync:
214194
def test_sync_returns_str(self):
215195
def echo(ctx):
@@ -491,11 +471,6 @@ async def bad_agen(ctx):
491471
execute_command_func(bad_agen, ctx)
492472

493473

494-
# ===========================================================================
495-
# Executor — Command class integration
496-
# ===========================================================================
497-
498-
499474
class TestExecutorWithCommandClass:
500475
def test_sync_command_class(self):
501476
class Echo(Command):
@@ -552,11 +527,6 @@ async def execute(self, ctx):
552527
assert len(results) == 2
553528

554529

555-
# ===========================================================================
556-
# _coerce_response tests
557-
# ===========================================================================
558-
559-
560530
class TestCoerceResponse:
561531
def test_none(self):
562532
assert _coerce_response(None, "slack") is None
@@ -590,11 +560,6 @@ def test_unknown_type(self):
590560
assert result.content == "42"
591561

592562

593-
# ===========================================================================
594-
# Legacy adapter tests
595-
# ===========================================================================
596-
597-
598563
class _FakeEchoCommand(ReplyToOtherCommand):
599564
"""Minimal legacy command for testing."""
600565

@@ -644,11 +609,6 @@ def test_context_to_bot_command(self):
644609
assert bot_cmd.variant == CommandVariant.REPLY_TO_OTHER
645610

646611

647-
# ===========================================================================
648-
# End-to-end: decorated command through executor
649-
# ===========================================================================
650-
651-
652612
class TestEndToEnd:
653613
def setup_method(self):
654614
clear_registry()

csp_bot/utils.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,6 @@ def get_backend_format(backend: Backend) -> Format:
120120
return get_format_for_backend(backend)
121121

122122

123-
# ============================================================================
124-
# Symphony MessageML formatting utilities
125-
# ============================================================================
126-
127-
128123
def format_with_message_ml(text: str, to_message_ml: bool = True) -> str:
129124
"""Convert text to/from Symphony MessageML format.
130125

0 commit comments

Comments
 (0)