Skip to content

Commit 82c8446

Browse files
committed
rework command framework
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent 6ead355 commit 82c8446

10 files changed

Lines changed: 1610 additions & 21 deletions

File tree

csp_bot/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@
2121
from .commands import (
2222
BaseCommand,
2323
BaseCommandModel,
24+
BotInfo,
25+
Command,
26+
CommandContext,
27+
CommandModel,
2428
EchoCommand,
2529
HelpCommand,
30+
LegacyCommandAdapter,
2631
NoResponseCommand,
2732
ReplyCommand,
2833
ReplyToAllCommand,
2934
ReplyToAuthorCommand,
3035
ReplyToOtherCommand,
3136
ScheduleCommand,
3237
StatusCommand,
38+
command,
3339
mention_user,
3440
)
3541
from .gateway import CspBotGateway, Gateway, GatewayChannels, GatewayModule, GatewaySettings
@@ -53,7 +59,14 @@
5359
"DiscordConfig",
5460
"SlackConfig",
5561
"SymphonyConfig",
56-
# Commands
62+
# Commands — new framework
63+
"Command",
64+
"CommandContext",
65+
"CommandModel",
66+
"BotInfo",
67+
"LegacyCommandAdapter",
68+
"command",
69+
# Commands — legacy
5770
"BaseCommand",
5871
"BaseCommandModel",
5972
"EchoCommand",

csp_bot/bot.py

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,21 @@
3838
from .bot_config import BotConfig
3939
from .commands import (
4040
BaseCommand,
41-
BaseCommandModel,
41+
BotInfo,
42+
Command,
43+
CommandContext,
4244
HelpCommand,
4345
ScheduleCommand,
4446
StatusCommand,
47+
execute_command_func,
48+
get_registered_commands,
4549
)
4650
from .gateway import GatewayChannels, GatewayModule
4751
from .structs import (
4852
Backend,
4953
BotCommand,
5054
BotMessage,
55+
CommandVariant,
5156
)
5257

5358
log = getLogger(__name__)
@@ -70,18 +75,23 @@ class Bot(GatewayModule):
7075

7176
config: BotConfig
7277

73-
_command_models: List[BaseCommandModel] = PrivateAttr(default_factory=list)
74-
_commands: Dict[str, BaseCommand] = PrivateAttr(default_factory=dict)
78+
_command_models: List[Any] = PrivateAttr(default_factory=list)
79+
_commands: Dict[str, Any] = PrivateAttr(default_factory=dict)
7580
_configs: Dict[Backend, Any] = PrivateAttr(default_factory=dict)
7681
_adapters: Dict[Backend, Any] = PrivateAttr(default_factory=dict)
7782
_connected_backends: Dict[Backend, Tuple[Any, asyncio.AbstractEventLoop]] = PrivateAttr(default_factory=dict)
7883
_scheduled: Dict[str, BotCommand] = PrivateAttr(default_factory=dict)
7984
_authorized_users: Dict[Backend, Set[str]] = PrivateAttr(default_factory=dict)
8085
_bot_user_ids: Dict[Backend, str] = PrivateAttr(default_factory=dict)
8186
_bot_names: Dict[Backend, str] = PrivateAttr(default_factory=dict)
87+
_deps: Any = PrivateAttr(default=None)
8288
_thread: Optional[threading.Thread] = PrivateAttr(None)
8389
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
8490

91+
def set_deps(self, deps: Any) -> None:
92+
"""Set shared dependency object for new command framework contexts."""
93+
self._deps = deps
94+
8595
def connect(self, channels: GatewayChannels) -> None:
8696
"""Connect to configured backends and set up message processing.
8797
@@ -318,8 +328,11 @@ async def _fetch() -> Optional[Channel]:
318328
log.exception(f"Error resolving channel: {channel_identifier}")
319329
return None
320330

321-
def load_commands(self, command_models: List[BaseCommandModel]) -> None:
322-
"""Load command handlers from command models."""
331+
def load_commands(self, command_models: List[Any]) -> None:
332+
"""Load command handlers from command models and decorator registry.
333+
334+
Supports both legacy BaseCommandModel and the new CommandModel.
335+
"""
323336
log.info(f"Loading {len(command_models)} commands...")
324337
for model in command_models:
325338
try:
@@ -328,14 +341,59 @@ def load_commands(self, command_models: List[BaseCommandModel]) -> None:
328341
log.critical(f"Incomplete command type - implement all abstract methods: {model.command}")
329342
raise e
330343

331-
command_str = command.command()
344+
if isinstance(command, BaseCommand):
345+
command_str = command.command()
346+
runner: Any = command
347+
elif isinstance(command, Command):
348+
command_str = command.name
349+
runner = command
350+
else:
351+
raise TypeError(f"Unsupported command type from model {type(model).__name__}: {type(command).__name__}")
352+
332353
log.info(f"Registered command: /{command_str}")
333354
if command_str in self._commands:
334355
raise Exception(f"Command already registered: {command_str}\n\t{command}\n\t{self._commands[command_str]}")
335356

336-
self._commands[command_str] = command
357+
self._commands[command_str] = runner
337358
self._command_models.append(model)
338359

360+
# Decorator-registered commands are available globally and can be mixed
361+
# with model-based commands. Explicit model definitions win on conflicts.
362+
for command_name, entry in get_registered_commands().items():
363+
if command_name in self._commands:
364+
continue
365+
log.info(f"Registered decorated command: /{command_name}")
366+
self._commands[command_name] = entry
367+
368+
def _command_backends(self, command_runner: Any) -> List[str]:
369+
"""Return supported backends for either legacy or new command types."""
370+
if isinstance(command_runner, BaseCommand):
371+
return command_runner.backends()
372+
if isinstance(command_runner, Command):
373+
return command_runner.backends
374+
return list(getattr(command_runner, "backends", []) or [])
375+
376+
def _build_command_context(self, cmd: BotCommand) -> CommandContext:
377+
"""Build CommandContext from legacy BotCommand for new framework execution."""
378+
bot_info = BotInfo(
379+
id=self._get_bot_id(cmd.backend) or "",
380+
name=self._get_bot_name(cmd.backend) or "",
381+
version="",
382+
)
383+
channel = Channel(id=cmd.channel_id, name=cmd.channel_name)
384+
return CommandContext(
385+
command_name=cmd.command,
386+
source=cmd.source,
387+
targets=list(cmd.targets),
388+
channel=channel,
389+
message=cmd.message,
390+
args=list(cmd.args),
391+
args_text=" ".join(cmd.args),
392+
backend=cmd.backend,
393+
bot=bot_info,
394+
deps=self._deps,
395+
)
396+
339397
# =========================================================================
340398
# Message Processing Nodes
341399
# =========================================================================
@@ -733,7 +791,8 @@ def _extract_commands(
733791
command_runner = self._commands[command_name]
734792

735793
# Check backend support
736-
if command_runner.backends() and backend not in command_runner.backends():
794+
command_backends = self._command_backends(command_runner)
795+
if command_backends and backend not in command_backends:
737796
log.warning(f"Command {command_name} not supported on {backend}")
738797
return None
739798

@@ -764,7 +823,7 @@ def _extract_commands(
764823
channel_id=target_channel,
765824
channel_name=channel_name,
766825
backend=backend,
767-
variant=command_runner.kind(),
826+
variant=command_runner.kind() if isinstance(command_runner, BaseCommand) else CommandVariant.REPLY,
768827
message=msg,
769828
delay=None,
770829
schedule="",
@@ -874,7 +933,7 @@ def _create_help_command(self, msg: Message, backend: str, channel_id: str) -> B
874933
channel_id=channel_id,
875934
channel_name=channel_name,
876935
backend=backend,
877-
variant=command_runner.kind(),
936+
variant=command_runner.kind() if isinstance(command_runner, BaseCommand) else CommandVariant.REPLY,
878937
message=msg,
879938
delay=None,
880939
schedule="",
@@ -888,12 +947,22 @@ def _execute_command(self, cmd: BotCommand) -> Optional[Union[Message, List[Mess
888947
return None
889948

890949
try:
891-
if isinstance(command_runner, HelpCommand):
892-
responses = command_runner.execute(cmd, MappingProxyType(self._commands))
893-
elif isinstance(command_runner, ScheduleCommand):
894-
responses = command_runner.execute(cmd, self._scheduled)
950+
if isinstance(command_runner, BaseCommand):
951+
if isinstance(command_runner, HelpCommand):
952+
responses = command_runner.execute(cmd, MappingProxyType(self._commands))
953+
elif isinstance(command_runner, ScheduleCommand):
954+
responses = command_runner.execute(cmd, self._scheduled)
955+
else:
956+
responses = command_runner.execute(cmd)
957+
elif isinstance(command_runner, Command):
958+
ctx = self._build_command_context(cmd)
959+
responses = [r for r in execute_command_func(command_runner.execute, ctx) if r is not None]
960+
elif hasattr(command_runner, "handler"):
961+
ctx = self._build_command_context(cmd)
962+
responses = [r for r in execute_command_func(command_runner.handler, ctx) if r is not None]
895963
else:
896-
responses = command_runner.execute(cmd)
964+
log.error(f"Unsupported command runner type for {cmd.command}: {type(command_runner).__name__}")
965+
return None
897966
except Exception:
898967
log.exception(f"Error executing command: {cmd.command}")
899968
return None

csp_bot/commands/__init__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
33
Commands can leverage chatom's cross-platform features for
44
mentions, formatting, and entity recognition.
5+
6+
Two command APIs are available:
7+
8+
1. ``@command`` decorator for stateless function-based commands
9+
2. ``Command`` BaseModel subclass for stateful class-based commands
10+
11+
The legacy ``BaseCommand`` hierarchy is still supported via
12+
``LegacyCommandAdapter``.
513
"""
614

715
from csp_bot.utils import mention_user
@@ -15,13 +23,28 @@
1523
ReplyToAuthorCommand,
1624
ReplyToOtherCommand,
1725
)
26+
from .context import BotInfo, CommandContext
1827
from .echo import EchoCommand, EchoCommandModel
28+
from .executor import execute_command_func
29+
from .framework import Command, CommandEntry, CommandModel, clear_registry, command, get_registered_commands
1930
from .help import HelpCommand, HelpCommandModel
31+
from .legacy import LegacyCommandAdapter
2032
from .schedule import ScheduleCommand, ScheduleCommandModel
2133
from .status import StatusCommand, StatusCommandModel
2234

2335
__all__ = (
24-
# Base classes
36+
# New framework
37+
"Command",
38+
"CommandContext",
39+
"CommandEntry",
40+
"CommandModel",
41+
"BotInfo",
42+
"LegacyCommandAdapter",
43+
"command",
44+
"clear_registry",
45+
"get_registered_commands",
46+
"execute_command_func",
47+
# Legacy base classes
2548
"BaseCommand",
2649
"BaseCommandModel",
2750
"NoResponseCommand",

0 commit comments

Comments
 (0)