diff --git a/csp_bot/__init__.py b/csp_bot/__init__.py index 2f24c75..38ccde2 100644 --- a/csp_bot/__init__.py +++ b/csp_bot/__init__.py @@ -47,59 +47,49 @@ Channels = GatewayChannels __all__ = ( - # Version - "__version__", - # Chatom re-exports - "Channel", - "Message", - "User", - # Bot + "Backend", + "BaseCommand", + "BaseCommandModel", "Bot", - # Config + "BotCommand", "BotConfig", - "DiscordConfig", - "SlackConfig", - "SymphonyConfig", - "TelegramConfig", - # Commands — new framework + "BotInfo", + "BotMessage", + "Channel", + "Channels", "Command", "CommandContext", "CommandModel", - "BotInfo", - "LegacyCommandAdapter", - "command", - # Commands — legacy - "BaseCommand", - "BaseCommandModel", + "CommandVariant", + "CspBotGateway", + "DiscordConfig", "EchoCommand", + "FsspecStateStore", + "Gateway", + "GatewayChannels", + "GatewayModule", + "GatewaySettings", "HelpCommand", + "InMemoryStateStore", + "LegacyCommandAdapter", + "Message", "NoResponseCommand", "ReplyCommand", "ReplyToAllCommand", "ReplyToAuthorCommand", "ReplyToOtherCommand", "ScheduleCommand", - "StatusCommand", - # Gateway - "Channels", - "CspBotGateway", - "Gateway", - "GatewayChannels", - "GatewayModule", - "GatewaySettings", - # Persistence - "FsspecStateStore", - "InMemoryStateStore", "ScheduleStore", "ScheduledCommandRecord", + "SlackConfig", "StateStore", + "StatusCommand", "StoredRecord", - # Structs - "Backend", - "BotCommand", - "BotMessage", - "CommandVariant", - # Utils + "SymphonyConfig", + "TelegramConfig", + "User", + "__version__", + "command", "format_message", "get_backend_format", "is_valid_url", diff --git a/csp_bot/backends/discord.py b/csp_bot/backends/discord.py index f264346..904013e 100644 --- a/csp_bot/backends/discord.py +++ b/csp_bot/backends/discord.py @@ -4,8 +4,8 @@ """ __all__ = ( - "DiscordConfig", "DiscordAdapter", + "DiscordConfig", "DiscordMessage", "DiscordUser", ) @@ -24,8 +24,6 @@ class DiscordConfig(BaseModel): """Placeholder when chatom.discord is not available.""" - pass - DiscordAdapter = None DiscordMessage = ChatomMessage DiscordUser = ChatomUser diff --git a/csp_bot/backends/slack.py b/csp_bot/backends/slack.py index 3423b28..44ddb21 100644 --- a/csp_bot/backends/slack.py +++ b/csp_bot/backends/slack.py @@ -4,11 +4,11 @@ """ __all__ = ( - "SlackConfig", "SlackAdapter", + "SlackConfig", "SlackMessage", - "SlackUser", "SlackPresenceStatus", + "SlackUser", ) try: @@ -26,8 +26,6 @@ class SlackConfig(BaseModel): """Placeholder when chatom.slack is not available.""" - pass - SlackAdapter = None SlackMessage = ChatomMessage SlackUser = ChatomUser diff --git a/csp_bot/backends/symphony.py b/csp_bot/backends/symphony.py index deb9462..60cf1ae 100644 --- a/csp_bot/backends/symphony.py +++ b/csp_bot/backends/symphony.py @@ -4,11 +4,11 @@ """ __all__ = ( - "SymphonyConfig", "SymphonyAdapter", + "SymphonyConfig", "SymphonyMessage", - "SymphonyUser", "SymphonyPresenceStatus", + "SymphonyUser", ) try: @@ -26,8 +26,6 @@ class SymphonyConfig(BaseModel): """Placeholder when chatom.symphony is not available.""" - pass - SymphonyAdapter = None SymphonyMessage = ChatomMessage SymphonyUser = ChatomUser diff --git a/csp_bot/backends/telegram.py b/csp_bot/backends/telegram.py index a5dad62..3f1c9a0 100644 --- a/csp_bot/backends/telegram.py +++ b/csp_bot/backends/telegram.py @@ -4,8 +4,8 @@ """ __all__ = ( - "TelegramConfig", "TelegramAdapter", + "TelegramConfig", "TelegramMessage", "TelegramUser", ) @@ -24,8 +24,6 @@ class TelegramConfig(BaseModel): """Placeholder when chatom.telegram is not available.""" - pass - TelegramAdapter = None TelegramMessage = ChatomMessage TelegramUser = ChatomUser diff --git a/csp_bot/bot.py b/csp_bot/bot.py index a8cd0a6..80ec5e7 100644 --- a/csp_bot/bot.py +++ b/csp_bot/bot.py @@ -21,7 +21,7 @@ from io import StringIO from logging import getLogger from types import MappingProxyType -from typing import Any, Dict, List, Optional, Set, Tuple, Union +from typing import Any, ClassVar import csp from chatom import Channel, Message, User, mention_user_for_backend @@ -78,23 +78,23 @@ class Bot(GatewayModule): config: BotConfig - _command_models: List[Any] = PrivateAttr(default_factory=list) - _commands: Dict[str, Any] = PrivateAttr(default_factory=dict) - _configs: Dict[Backend, Any] = PrivateAttr(default_factory=dict) - _adapters: Dict[Backend, Any] = PrivateAttr(default_factory=dict) - _connected_backends: Dict[Backend, Tuple[Any, asyncio.AbstractEventLoop]] = PrivateAttr(default_factory=dict) + _command_models: list[Any] = PrivateAttr(default_factory=list) + _commands: dict[str, Any] = PrivateAttr(default_factory=dict) + _configs: dict[Backend, Any] = PrivateAttr(default_factory=dict) + _adapters: dict[Backend, Any] = PrivateAttr(default_factory=dict) + _connected_backends: dict[Backend, tuple[Any, asyncio.AbstractEventLoop]] = PrivateAttr(default_factory=dict) _schedule_store: ScheduleStore = PrivateAttr(default_factory=lambda: ScheduleStore(InMemoryStateStore())) - _authorized_users: Dict[Backend, Set[str]] = PrivateAttr(default_factory=dict) - _bot_user_ids: Dict[Backend, str] = PrivateAttr(default_factory=dict) - _bot_names: Dict[Backend, str] = PrivateAttr(default_factory=dict) + _authorized_users: dict[Backend, set[str]] = PrivateAttr(default_factory=dict) + _bot_user_ids: dict[Backend, str] = PrivateAttr(default_factory=dict) + _bot_names: dict[Backend, str] = PrivateAttr(default_factory=dict) _deps: Any = PrivateAttr(default=None) - _thread: Optional[threading.Thread] = PrivateAttr(None) + _thread: threading.Thread | None = PrivateAttr(None) _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) - _KNOWN_BACKENDS: Set[str] = {"discord", "slack", "symphony", "telegram"} + _KNOWN_BACKENDS: ClassVar[set[str]] = {"discord", "slack", "symphony", "telegram"} @staticmethod - def _datetime_for_now(value: Optional[datetime], now: datetime) -> Optional[datetime]: + def _datetime_for_now(value: datetime | None, now: datetime) -> datetime | None: # Persistence records use aware UTC datetimes. csp.now() is naive in # current runtime tests, so normalize only at the csp scheduling edge. if value is None: @@ -113,7 +113,7 @@ def set_schedule_store(self, schedule_store: ScheduleStore) -> None: """Inject a schedule store for delayed and recurring commands.""" self._schedule_store = schedule_store - def _restore_scheduled_commands(self, now: datetime) -> List[ScheduledCommandRecord]: + def _restore_scheduled_commands(self, now: datetime) -> list[ScheduledCommandRecord]: """Return future scheduled commands that should be re-armed.""" restored = [] for record in self._schedule_store.records(): @@ -166,7 +166,7 @@ def connect(self, channels: GatewayChannels) -> None: self._adapters["telegram"] = TelegramAdapter(self.config.telegram.config) # Fetch bot info for all backends at startup - for backend in self._adapters.keys(): + for backend in self._adapters: log.info(f"Fetching bot info for {backend}...") self._fetch_bot_info(backend) @@ -270,7 +270,7 @@ def _update_user_access(self, backend: str) -> None: if not adapter: return - users: Set[str] = set() + users: set[str] = set() for channel_name in config.user_access_channels: try: # Use chatom's backend to fetch channel members @@ -342,7 +342,7 @@ def _track_agent_session_response(self, response: Message, command: BotCommand) if response.id and response.id != orig_msg_id: AgentCommand._sessions.update_response_id(session_key, response.id) - def _ensure_backend_connected(self, backend: str) -> Optional[Tuple[Any, asyncio.AbstractEventLoop]]: + def _ensure_backend_connected(self, backend: str) -> tuple[Any, asyncio.AbstractEventLoop] | None: """Ensure a connected backend exists for the given platform. Lazily creates and connects a backend instance that can be reused @@ -385,7 +385,7 @@ async def _connect(): loop.close() return None - def _resolve_channel(self, channel_identifier: str, backend: str) -> Optional[Channel]: + def _resolve_channel(self, channel_identifier: str, backend: str) -> Channel | None: """Resolve a channel name or ID to a Channel object. Uses the shared connected backend for the platform. @@ -403,7 +403,7 @@ def _resolve_channel(self, channel_identifier: str, backend: str) -> Optional[Ch connected_backend, loop = result - async def _fetch() -> Optional[Channel]: + async def _fetch() -> Channel | None: log.info(f"Resolving channel '{channel_identifier}' for {backend}") # First try to fetch by name @@ -425,7 +425,7 @@ async def _fetch() -> Optional[Channel]: log.exception(f"Error resolving channel: {channel_identifier}") return None - def load_commands(self, command_models: List[Any]) -> None: + def load_commands(self, command_models: list[Any]) -> None: """Load command handlers from command models and decorator registry. Supports both legacy BaseCommandModel and the new CommandModel. @@ -436,9 +436,9 @@ def load_commands(self, command_models: List[Any]) -> None: for model in command_models: try: command = model.command() - except TypeError as e: + except TypeError: log.critical(f"Incomplete command type - implement all abstract methods: {model.command}") - raise e + raise if isinstance(command, BaseCommand): command_str = command.command() @@ -454,7 +454,7 @@ def load_commands(self, command_models: List[Any]) -> None: log.info(f"Registered command: /{command_str}") if command_str in self._commands: - raise Exception(f"Command already registered: {command_str}\n\t{command}\n\t{self._commands[command_str]}") + raise ValueError(f"Command already registered: {command_str}\n\t{command}\n\t{self._commands[command_str]}") self._commands[command_str] = runner self._command_models.append(model) @@ -502,9 +502,9 @@ def _load_entrypoint_commands(self) -> None: log.info("Loaded command entry point: %s", getattr(entry_point, "name", "")) - def _active_backends(self) -> Set[str]: + def _active_backends(self) -> set[str]: """Return configured backends for this bot instance.""" - active: Set[str] = set() + active: set[str] = set() if self.config.discord: active.add("discord") if self.config.slack: @@ -513,7 +513,7 @@ def _active_backends(self) -> Set[str]: active.add("symphony") return active - def _normalize_command_backends(self, command_name: str, backends: List[str]) -> List[str]: + def _normalize_command_backends(self, command_name: str, backends: list[str]) -> list[str]: """Normalize and validate declared command backends.""" normalized = [b.lower() for b in backends] unknown = sorted({b for b in normalized if b not in self._KNOWN_BACKENDS}) @@ -521,7 +521,7 @@ def _normalize_command_backends(self, command_name: str, backends: List[str]) -> raise ValueError(f"Command '{command_name}' declared unknown backends: {', '.join(unknown)}") return normalized - def _is_command_backend_compatible(self, command_name: str, command_runner: Any, active_backends: Set[str]) -> bool: + def _is_command_backend_compatible(self, command_name: str, command_runner: Any, active_backends: set[str]) -> bool: """Check registration-time backend compatibility for a command.""" declared_backends = self._command_backends(command_runner) if not declared_backends: @@ -544,7 +544,7 @@ def _is_command_backend_compatible(self, command_name: str, command_runner: Any, ) return False - def _command_backends(self, command_runner: Any) -> List[str]: + def _command_backends(self, command_runner: Any) -> list[str]: """Return supported backends for either legacy or new command types.""" if isinstance(command_runner, BaseCommand): return command_runner.backends() @@ -582,7 +582,7 @@ def _process_incoming_messages(self, msg: ts[Message]) -> Outputs(bot_commands=t if csp.ticked(msg): try: backend = msg.metadata.get("backend", "") - log.info(f"Processing incoming message from {backend}: content={repr(msg.content[:100] if msg.content else '')}") + log.info(f"Processing incoming message from {backend}: content={msg.content[:100] if msg.content else ''!r}") is_to_bot, channel_id, text, mentions = self._is_message_to_bot(msg, backend) log.info(f"is_to_bot={is_to_bot}, channel_id={channel_id}") @@ -619,9 +619,9 @@ def _handle_commands(self, cmd: ts[BotCommand]) -> Outputs(messages=ts[[Message] a_ratelimit: ts[bool] = csp.alarm(bool) with csp.state(): - s_buffer: List[Message] = [] - s_buffer_last: List[Message] = [] - s_to_process: List[BotCommand] = [] + s_buffer: list[Message] = [] + s_buffer_last: list[Message] = [] + s_to_process: list[BotCommand] = [] with csp.start(): csp.schedule_alarm(a_ratelimit, timedelta(seconds=self.config.ratelimit_seconds), True) @@ -632,21 +632,20 @@ def _handle_commands(self, cmd: ts[BotCommand]) -> Outputs(messages=ts[[Message] csp.schedule_alarm(a_scheduled, next_run_at, record.command) # Handle scheduled command triggers - if csp.ticked(a_scheduled): + if csp.ticked(a_scheduled) and self._schedule_store.get(a_scheduled.schedule_id) is not None: # Removed schedules may still have an outstanding CSP alarm; the # store is the source of truth and acts as the tombstone check. - if self._schedule_store.get(a_scheduled.schedule_id) is not None: - s_to_process.append(a_scheduled) - - # Reschedule recurring commands - if a_scheduled.schedule: - now = csp.now() - next_time = croniter(a_scheduled.schedule, now).get_next(datetime) - if next_time >= now: - self._store_scheduled_command(a_scheduled, next_time) - csp.schedule_alarm(a_scheduled, next_time, a_scheduled) - else: - self._remove_scheduled_command(a_scheduled.schedule_id) + s_to_process.append(a_scheduled) + + # Reschedule recurring commands + if a_scheduled.schedule: + now = csp.now() + next_time = croniter(a_scheduled.schedule, now).get_next(datetime) + if next_time >= now: + self._store_scheduled_command(a_scheduled, next_time) + csp.schedule_alarm(a_scheduled, next_time, a_scheduled) + else: + self._remove_scheduled_command(a_scheduled.schedule_id) # Handle new commands if csp.ticked(cmd): @@ -707,7 +706,7 @@ def _handle_commands(self, cmd: ts[BotCommand]) -> Outputs(messages=ts[[Message] csp.schedule_alarm(a_ratelimit, timedelta(seconds=self.config.ratelimit_seconds), True) - def _is_message_to_bot(self, msg: Message, backend: str) -> Tuple[bool, str, str, List[User]]: + def _is_message_to_bot(self, msg: Message, backend: str) -> tuple[bool, str, str, list[User]]: """Check if a message is directed at the bot. Uses chatom's mention parsing to detect bot mentions. @@ -856,10 +855,10 @@ async def _fetch(): try: loop.run_until_complete(_fetch()) - except Exception as e: - log.warning(f"Error fetching bot info for {backend}: {e}") + except Exception: + log.exception("Error fetching bot info for %s", backend) - def _get_bot_id(self, backend: str) -> Optional[str]: + def _get_bot_id(self, backend: str) -> str | None: """Get the bot's user ID for a backend.""" if backend in self._bot_user_ids: return self._bot_user_ids[backend] @@ -868,7 +867,7 @@ def _get_bot_id(self, backend: str) -> Optional[str]: self._fetch_bot_info(backend) return self._bot_user_ids.get(backend) - def _get_bot_name(self, backend: str) -> Optional[str]: + def _get_bot_name(self, backend: str) -> str | None: """Get the bot's username for a backend. First checks config for explicit bot_name, then checks cache, @@ -907,8 +906,8 @@ def _extract_commands( backend: str, channel_id: str, text: str, - mentions: List[User], - ) -> Optional[Union[BotCommand, List[BotCommand]]]: + mentions: list[User], + ) -> BotCommand | list[BotCommand] | None: """Extract bot commands from a message. Uses chatom's entity recognition to identify mentioned users. @@ -928,10 +927,10 @@ def _extract_commands( if bot_name and content.startswith(f"@{bot_name}"): content = content[len(f"@{bot_name}") :].strip() - log.info(f"Extracting command from: {repr(content)}") + log.info(f"Extracting command from: {content!r}") # Check for command syntax (supports both / and ! prefixes) - if not content.startswith("/") and not content.startswith("!"): + if not content.startswith(("/", "!")): # Check if this is a reply to an active agent session session_cmd = self._check_agent_session_reply(msg, backend, channel_id) if session_cmd: @@ -942,13 +941,13 @@ def _extract_commands( return self._create_help_command(msg, backend, channel_id) # Tokenize the command - tokens = list(reader(StringIO(content), delimiter=" ", quotechar='"', skipinitialspace=True))[0] + tokens = next(reader(StringIO(content), delimiter=" ", quotechar='"', skipinitialspace=True)) if not tokens: return None # Parse command and arguments (strip both / and ! prefixes) command_name = tokens[0].lstrip("/!").lower() - log.info(f"Parsed command_name: {repr(command_name)}, registered commands: {list(self._commands.keys())}") + log.info(f"Parsed command_name: {command_name!r}, registered commands: {list(self._commands.keys())}") if command_name not in self._commands: log.warning(f"Unknown command: {command_name}") return self._create_help_command(msg, backend, channel_id) @@ -1030,7 +1029,7 @@ def _extract_commands( log.exception("Error extracting command") return None - def _check_agent_session_reply(self, msg: Message, backend: str, channel_id: str) -> Optional[BotCommand]: + def _check_agent_session_reply(self, msg: Message, backend: str, channel_id: str) -> BotCommand | None: """Check if the message is a reply to a bot response with an active agent session. If so, constructs a BotCommand to continue the conversation. @@ -1105,10 +1104,10 @@ def _check_agent_session_reply(self, msg: Message, backend: str, channel_id: str def _parse_command_args( self, - tokens: List[str], - mentions: List[User], + tokens: list[str], + mentions: list[User], backend: str, - ) -> Tuple[List[str], List[User], str]: + ) -> tuple[list[str], list[User], str]: """Parse command arguments, extracting tagged users and channels.""" args = [] target_users = [] @@ -1146,7 +1145,7 @@ def _parse_command_args( j = i + 1 while j < len(tokens): next_token = tokens[j] - if next_token.startswith("@") or next_token.startswith("/") or next_token.startswith("!"): + if next_token.startswith(("@", "/", "!")): break skip_indices.add(j) j += 1 @@ -1166,7 +1165,7 @@ def _parse_command_args( return args, target_users, target_channel - def _create_help_command(self, msg: Message, backend: str, channel_id: str) -> BotCommand: + def _create_help_command(self, msg: Message, backend: str, channel_id: str) -> BotCommand | None: """Create a help command when no specific command is given.""" command_runner = self._commands.get("help") if not command_runner: @@ -1202,7 +1201,7 @@ def _create_help_command(self, msg: Message, backend: str, channel_id: str) -> B times_run=0, ) - def _execute_command(self, cmd: BotCommand) -> Optional[Union[Message, List[Message], BotCommand, List[BotCommand]]]: + def _execute_command(self, cmd: BotCommand) -> Message | list[Message] | BotCommand | list[BotCommand] | None: """Execute a bot command and return responses.""" command_runner = self._commands.get(cmd.command) if not command_runner: @@ -1273,7 +1272,7 @@ def _create_response_message( channel_id: str, backend: str, thread_id: str = "", - mentions: List[User] = None, + mentions: list[User] | None = None, ) -> Message: """Create a response message with chatom. diff --git a/csp_bot/bot_config.py b/csp_bot/bot_config.py index b1af701..943fa66 100644 --- a/csp_bot/bot_config.py +++ b/csp_bot/bot_config.py @@ -4,8 +4,6 @@ backend-specific configurations with bot-specific settings. """ -from typing import List, Optional, Set - from ccflow import BaseModel from pydantic import Field @@ -38,12 +36,12 @@ class BackendConfig(BaseModel): description="Name of the bot. Auto-detected from backend if empty.", ) - channels: Set[str] = Field( + channels: set[str] = Field( default_factory=set, description="Channels/rooms to subscribe to. Empty means all.", ) - user_access_channels: List[str] = Field( + user_access_channels: list[str] = Field( default_factory=list, description="If non-empty, only users from these channels can interact with the bot.", ) @@ -53,7 +51,7 @@ class BackendConfig(BaseModel): description="How frequently to query user access channels. 0 means only at startup.", ) - unauthorized_msg: Optional[str] = Field( + unauthorized_msg: str | None = Field( default="You are not authorized to interact with this bot.", description="Message to send when unauthorized user interacts. None means no message.", ) @@ -107,10 +105,10 @@ class BotConfig(BaseModel): their respective settings. """ - discord: Optional[DiscordConfig] = None - slack: Optional[SlackConfig] = None - symphony: Optional[SymphonyConfig] = None - telegram: Optional[TelegramConfig] = None + discord: DiscordConfig | None = None + slack: SlackConfig | None = None + symphony: SymphonyConfig | None = None + telegram: TelegramConfig | None = None ratelimit_seconds: float = Field( default=1.0, diff --git a/csp_bot/cli.py b/csp_bot/cli.py index eee8d21..b8bc7d9 100644 --- a/csp_bot/cli.py +++ b/csp_bot/cli.py @@ -12,8 +12,8 @@ __all__ = ( "load", - "run", "main", + "run", ) diff --git a/csp_bot/commands/__init__.py b/csp_bot/commands/__init__.py index 1d62674..615a9a5 100644 --- a/csp_bot/commands/__init__.py +++ b/csp_bot/commands/__init__.py @@ -38,35 +38,31 @@ pass __all__ = ( - # New framework + "AgentCommand", + "BaseCommand", + "BaseCommandModel", + "BotInfo", "Command", "CommandContext", "CommandEntry", "CommandModel", - "BotInfo", + "EchoCommand", + "EchoCommandModel", + "HelpCommand", + "HelpCommandModel", "LegacyCommandAdapter", - "command", - "clear_registry", - "get_registered_commands", - "execute_command_func", - # Legacy base classes - "AgentCommand", - "BaseCommand", - "BaseCommandModel", "NoResponseCommand", "ReplyCommand", "ReplyToAllCommand", "ReplyToAuthorCommand", "ReplyToOtherCommand", - # Built-in commands - "EchoCommand", - "EchoCommandModel", - "HelpCommand", - "HelpCommandModel", "ScheduleCommand", "ScheduleCommandModel", "StatusCommand", "StatusCommandModel", - # Utilities + "clear_registry", + "command", + "execute_command_func", + "get_registered_commands", "mention_user", ) diff --git a/csp_bot/commands/agent.py b/csp_bot/commands/agent.py index 6fe91c3..2af4ef9 100644 --- a/csp_bot/commands/agent.py +++ b/csp_bot/commands/agent.py @@ -18,10 +18,11 @@ import os import threading from abc import abstractmethod +from collections.abc import Sequence from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone -from typing import Any, ClassVar, Dict, List, Optional, Sequence, Union +from typing import Any, ClassVar from chatom import Channel, Message from chatom.backend import BackendBase @@ -60,9 +61,9 @@ class AgentSession: user_id: str channel_id: str command_name: str - message_history: List[ModelMessage] = field(default_factory=list) + message_history: list[ModelMessage] = field(default_factory=list) last_active: datetime = field(default_factory=_utc_now) - bot_response_id: Optional[str] = None # ID of last bot message (for reply matching) + bot_response_id: str | None = None # ID of last bot message (for reply matching) @property def store_key(self) -> str: @@ -75,7 +76,7 @@ def touch(self) -> None: def is_expired(self, ttl_seconds: float) -> bool: return (_utc_now() - self.last_active).total_seconds() > ttl_seconds - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-safe dict for durable storage. The pydantic-ai conversation history is serialized via @@ -92,7 +93,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "AgentSession": + def from_dict(cls, data: dict[str, Any]) -> AgentSession: """Reconstruct a session from :meth:`to_dict` output.""" version = data.get("schema_version") if version != cls.SCHEMA_VERSION: @@ -124,12 +125,12 @@ class SessionStore: namespace = "csp_bot.agent_sessions" response_namespace = "csp_bot.agent_sessions.responses" - def __init__(self, ttl_seconds: float = 900.0, store: Optional[StateStore] = None): + def __init__(self, ttl_seconds: float = 900.0, store: StateStore | None = None): self._ttl = ttl_seconds self.store: StateStore = store if store is not None else InMemoryStateStore() self._lock = threading.Lock() - def get(self, key: str) -> Optional[AgentSession]: + def get(self, key: str) -> AgentSession | None: with self._lock: session = self._load(key) if session and session.is_expired(self._ttl): @@ -137,7 +138,7 @@ def get(self, key: str) -> Optional[AgentSession]: return None return session - def get_by_response_id(self, response_id: str) -> Optional[AgentSession]: + def get_by_response_id(self, response_id: str) -> AgentSession | None: """Look up a session by the bot's response message ID (for replies).""" with self._lock: key = self.store.get(self.response_namespace, response_id) @@ -167,7 +168,7 @@ def update_response_id(self, key: str, response_id: str) -> None: self.store.put(self.namespace, key, session) self.store.put(self.response_namespace, response_id, key) - def _load(self, key: str) -> Optional[AgentSession]: + def _load(self, key: str) -> AgentSession | None: """Load a session, accepting both live objects and serialized dicts.""" value = self.store.get(self.namespace, key) if value is None or isinstance(value, AgentSession): @@ -176,7 +177,7 @@ def _load(self, key: str) -> Optional[AgentSession]: return AgentSession.from_dict(value) raise TypeError(f"Unexpected session value for {key!r}: {type(value)!r}") - def _remove_session(self, key: str, session: Optional[AgentSession] = None) -> None: + def _remove_session(self, key: str, session: AgentSession | None = None) -> None: """Remove a session and its reply-index entry (caller holds lock).""" if session is None: session = self._load(key) @@ -198,9 +199,9 @@ def cleanup_expired(self) -> int: def _run_agent( agent: Agent, - prompt: Union[str, Sequence[Any]], - loop: Optional[asyncio.AbstractEventLoop] = None, - message_history: Optional[Sequence[ModelMessage]] = None, + prompt: str | Sequence[Any], + loop: asyncio.AbstractEventLoop | None = None, + message_history: Sequence[ModelMessage] | None = None, ) -> Any: """Run an agent on an event loop (for use in thread pool). @@ -258,9 +259,9 @@ def build_prompt(self, command): return " ".join(command.args) """ - _backends: ClassVar[Dict[str, BackendBase]] = {} - _backend_loops: ClassVar[Dict[str, asyncio.AbstractEventLoop]] = {} - _futures: ClassVar[Dict[str, Future]] = {} + _backends: ClassVar[dict[str, BackendBase]] = {} + _backend_loops: ClassVar[dict[str, asyncio.AbstractEventLoop]] = {} + _futures: ClassVar[dict[str, Future]] = {} _sessions: ClassVar[SessionStore] = SessionStore(ttl_seconds=900.0) # Configurable delay between polling checks (seconds) @@ -272,7 +273,7 @@ def build_prompt(self, command): max_tool_calls: int = 25 # Optional per-tool call caps for a single run (e.g. limit expensive # history reads / searches). None applies no per-tool limit. - per_tool_limits: ClassVar[Optional[Dict[str, int]]] = None + per_tool_limits: ClassVar[dict[str, int] | None] = None # Session time-to-live (seconds). 0 disables sessions. session_ttl_seconds: float = 900.0 # Send a status message every N poll cycles (0 disables) @@ -291,7 +292,7 @@ def build_prompt(self, command): # can resolve references like "this channel" / "the current room". inject_channel: bool = True # Status messages shown to the user while processing - status_messages: ClassVar[List[str]] = [ + status_messages: ClassVar[list[str]] = [ "Thinking...", "Still working on it...", "Processing your request...", @@ -305,8 +306,8 @@ def __init__(self, *args, **kwargs): @classmethod def set_backends( cls, - backends: Dict[str, BackendBase], - loops: Optional[Dict[str, asyncio.AbstractEventLoop]] = None, + backends: dict[str, BackendBase], + loops: dict[str, asyncio.AbstractEventLoop] | None = None, ) -> None: """Inject backend instances. Called by Bot after adapter setup.""" cls._backends = backends @@ -318,7 +319,7 @@ def set_session_ttl(cls, ttl_seconds: float) -> None: cls._sessions = SessionStore(ttl_seconds=ttl_seconds, store=cls._sessions.store) @classmethod - def set_session_store(cls, store: StateStore, ttl_seconds: Optional[float] = None) -> None: + def set_session_store(cls, store: StateStore, ttl_seconds: float | None = None) -> None: """Back agent sessions with a (possibly durable) StateStore. Injecting an ``FsspecStateStore`` (or other durable backend) lets @@ -347,7 +348,7 @@ def build_root_prompt(self, command: BotCommand) -> str: """ return self.root_prompt - def build_toolset(self, command: BotCommand) -> Optional[BackendToolset]: + def build_toolset(self, command: BotCommand) -> BackendToolset | None: """Return a BackendToolset for the command's backend, or None. The toolset is configured with an AccessPolicy that enforces: @@ -373,7 +374,7 @@ def build_toolset(self, command: BotCommand) -> Optional[BackendToolset]: per_tool_limits=self.per_tool_limits, ) - def build_access_policy(self, command: BotCommand) -> "AccessPolicy": + def build_access_policy(self, command: BotCommand) -> AccessPolicy: """Build the access policy for this command invocation. Override in subclasses to customize access rules. The default @@ -418,7 +419,7 @@ def _session_key(self, command: BotCommand) -> str: """Key for session lookup: command:user:channel.""" return f"{self.command()}:{command.source.id}:{command.channel_id}" - def _get_session(self, command: BotCommand) -> Optional[AgentSession]: + def _get_session(self, command: BotCommand) -> AgentSession | None: """Find an existing session — by reply reference or by user+channel.""" # First: check if this is a reply to a bot message msg = command.message @@ -468,7 +469,7 @@ def _status_channel(self, command: BotCommand) -> Channel: return Channel(id=origin_id, name=origin_name) return command.channel - def _incoming_image_attachments(self, command: BotCommand) -> List[Any]: + def _incoming_image_attachments(self, command: BotCommand) -> list[Any]: """Return image attachments on the incoming message, if any.""" msg = command.message if not msg or not getattr(msg, "attachments", None): @@ -484,7 +485,7 @@ def _incoming_image_attachments(self, command: BotCommand) -> List[Any]: def _prompt_prefix(self, command: BotCommand) -> str: """Assemble the root prompt and channel-context note that precede the command's own prompt. Returns "" when neither applies.""" - parts: List[str] = [] + parts: list[str] = [] root = self.build_root_prompt(command) if root: parts.append(root) @@ -519,7 +520,7 @@ def _channel_context_note(self, command: BotCommand) -> str: "channel; pass this id to tools such as read_channel_history.]" ) - def _build_model_prompt(self, command: BotCommand, prompt: str) -> Union[str, List[Any]]: + def _build_model_prompt(self, command: BotCommand, prompt: str) -> str | list[Any]: """Assemble the prompt for the model, attaching incoming images. Downloads any image attachments on the incoming message (via the @@ -541,7 +542,7 @@ def _build_model_prompt(self, command: BotCommand, prompt: str) -> Union[str, Li from pydantic_ai import BinaryContent backend_loop = self._backend_loops.get(command.backend) - parts: List[Any] = [prompt] + parts: list[Any] = [prompt] for att in images: if getattr(att, "size", None) and att.size > self.max_incoming_image_bytes: log.warning("Skipping incoming image %r: %s bytes exceeds limit", getattr(att, "filename", ""), att.size) @@ -563,8 +564,8 @@ def _build_model_prompt(self, command: BotCommand, prompt: str) -> Union[str, Li def _download_on_loop( backend: BackendBase, attachment: Any, - message: Optional[Message], - loop: Optional[asyncio.AbstractEventLoop], + message: Message | None, + loop: asyncio.AbstractEventLoop | None, ) -> bytes: """Download an attachment on the backend's own event loop. @@ -624,7 +625,7 @@ def preexecute(self, command: BotCommand) -> BotCommand: command.delay = _utc_now() + timedelta(seconds=self.poll_interval) return command - def execute(self, command: BotCommand) -> Optional[Union[Message, List[Union[Message, "BaseCommand"]], "BaseCommand"]]: + def execute(self, command: BotCommand) -> Message | list[Message | BaseCommand] | BaseCommand | None: """Return result when ready; reschedule if still running.""" # Handle errors from preexecute if command.args and len(command.args) == 1 and str(command.args[0]).startswith("ERROR:"): @@ -660,7 +661,7 @@ def execute(self, command: BotCommand) -> Optional[Union[Message, List[Union[Mes command.delay = _utc_now() + timedelta(seconds=self.poll_interval) command.times_run += 1 - result: List[Any] = [command] + result: list[Any] = [command] if self.status_every_n_polls and self.status_messages: # Status messages go to the origin channel (where the user # typed the command), NOT the /room redirect destination. diff --git a/csp_bot/commands/base.py b/csp_bot/commands/base.py index c145b03..27bd744 100644 --- a/csp_bot/commands/base.py +++ b/csp_bot/commands/base.py @@ -5,7 +5,7 @@ """ from abc import ABC, abstractmethod -from typing import List, Type, Union +from typing import Union from ccflow import BaseModel from chatom import Message @@ -27,7 +27,7 @@ def kind() -> CommandVariant: ... @staticmethod - def backends() -> List[Backend]: + def backends() -> list[Backend]: """Return supported backends. Empty means all backends.""" return [] @@ -65,7 +65,7 @@ def preexecute(self, command: BotCommand) -> BotCommand: def execute( self, command: BotCommand, - ) -> Union[Message, BotMessage, List[Message], List[BotMessage], "BaseCommand", List["BaseCommand"], None]: + ) -> Union[Message, BotMessage, list[Message], list[BotMessage], "BaseCommand", list["BaseCommand"], None]: """Execute the command and return response(s). Commands can return: @@ -135,4 +135,4 @@ def num_recipients(self) -> int: class BaseCommandModel(BaseModel): """Model for registering commands via configuration.""" - command: Type[BaseCommand] + command: type[BaseCommand] diff --git a/csp_bot/commands/context.py b/csp_bot/commands/context.py index 13dd167..c48dc1c 100644 --- a/csp_bot/commands/context.py +++ b/csp_bot/commands/context.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any, Generic, List, Optional, TypeVar, Union +from typing import Any, Generic, TypeVar from chatom import Channel, Message, User from chatom.format import ( @@ -53,16 +53,16 @@ class CommandContext(Generic[Deps]): """ __slots__ = ( - "command_name", - "source", - "targets", - "channel", - "message", "args", "args_text", "backend", "bot", + "channel", + "command_name", "deps", + "message", + "source", + "targets", ) def __init__( @@ -70,10 +70,10 @@ def __init__( *, command_name: str, source: User, - targets: List[User], + targets: list[User], channel: Channel, message: Message, - args: List[str], + args: list[str], args_text: str, backend: str, bot: BotInfo, @@ -91,11 +91,11 @@ def __init__( self.deps = deps @property - def target(self) -> Optional[User]: + def target(self) -> User | None: """First mentioned user, or None.""" return self.targets[0] if self.targets else None - def mention(self, user: Optional[User]) -> UserMention: + def mention(self, user: User | None) -> UserMention: """Create a mention node for a user. Args: @@ -111,7 +111,7 @@ def mention(self, user: Optional[User]) -> UserMention: display_name=getattr(user, "display_name", "") or getattr(user, "name", "") or "", ) - def reply(self, *content: Union[TextNode, str, Table, FormattedImage, FormattedAttachment]) -> FormattedMessage: + def reply(self, *content: TextNode | str | Table | FormattedImage | FormattedAttachment) -> FormattedMessage: """Build a FormattedMessage from content nodes. Args: @@ -134,8 +134,8 @@ def reply(self, *content: Union[TextNode, str, Table, FormattedImage, FormattedA def table( self, data: Any, - headers: Optional[List[str]] = None, - alignment: Optional[Union[str, List[str]]] = None, + headers: list[str] | None = None, + alignment: str | list[str] | None = None, ) -> Table: """Build a Table node from data. @@ -171,7 +171,7 @@ def image( alt: str = "", title: str = "", *, - data: Optional[bytes] = None, + data: bytes | None = None, filename: str = "", content_type: str = "", ) -> FormattedImage: @@ -196,7 +196,7 @@ def attachment( filename: str = "", content_type: str = "", *, - data: Optional[bytes] = None, + data: bytes | None = None, ) -> FormattedAttachment: """Create an attachment node. diff --git a/csp_bot/commands/echo.py b/csp_bot/commands/echo.py index b4291e0..f7bb908 100644 --- a/csp_bot/commands/echo.py +++ b/csp_bot/commands/echo.py @@ -4,7 +4,6 @@ """ from logging import getLogger -from typing import Optional, Type from chatom import Message from chatom.format import FormattedMessage, Text, UserMention @@ -28,7 +27,7 @@ def name(self) -> str: def help(self) -> str: return "Echo a message. Syntax: /echo [/channel ]" - def execute(self, command: BotCommand) -> Optional[Message]: + def execute(self, command: BotCommand) -> Message | None: log.info(f"Echo command: {command.command}") text = " ".join(command.args) if command.args else "" @@ -58,4 +57,4 @@ def execute(self, command: BotCommand) -> Optional[Message]: class EchoCommandModel(BaseCommandModel): - command: Type[BaseCommand] = EchoCommand + command: type[BaseCommand] = EchoCommand diff --git a/csp_bot/commands/executor.py b/csp_bot/commands/executor.py index 486b012..4990de6 100644 --- a/csp_bot/commands/executor.py +++ b/csp_bot/commands/executor.py @@ -12,7 +12,7 @@ import inspect import logging import threading -from typing import Any, List, Optional, Union +from typing import Any from chatom import Message from chatom.base.attachment import Attachment, AttachmentType, Image as BaseImage @@ -25,8 +25,8 @@ # Module-level async event loop running in a background thread. # Lazily initialised on first use. -_loop: Optional[asyncio.AbstractEventLoop] = None -_loop_thread: Optional[threading.Thread] = None +_loop: asyncio.AbstractEventLoop | None = None +_loop_thread: threading.Thread | None = None _loop_lock = threading.Lock() @@ -95,7 +95,7 @@ def _extract_attachments(fm: FormattedMessage) -> list: return result -def _coerce_response(item: Any, backend: str) -> Optional[Union[Message, BotCommand]]: +def _coerce_response(item: Any, backend: str) -> Message | BotCommand | None: """Coerce a command return value into a chatom Message. Accepts: @@ -149,7 +149,7 @@ def execute_command_func( fn: Any, ctx: Any, timeout: float = 60.0, -) -> List[Optional[Union[Message, BotCommand]]]: +) -> list[Message | BotCommand | None]: """Execute a command callable and return a list of Messages. Detects the function signature and dispatches accordingly: @@ -178,7 +178,7 @@ def execute_command_func( return _run_sync_function(fn, ctx, backend) -def _run_sync_function(fn: Any, ctx: Any, backend: str) -> List[Optional[Union[Message, BotCommand]]]: +def _run_sync_function(fn: Any, ctx: Any, backend: str) -> list[Message | BotCommand | None]: """Execute a plain sync function.""" try: result = fn(ctx) @@ -193,7 +193,7 @@ def _run_async_function( ctx: Any, backend: str, timeout: float, -) -> List[Optional[Union[Message, BotCommand]]]: +) -> list[Message | BotCommand | None]: """Execute an async function in the background event loop.""" loop = _get_event_loop() try: @@ -213,9 +213,9 @@ def _run_sync_generator( ctx: Any, backend: str, timeout: float, -) -> List[Optional[Union[Message, BotCommand]]]: +) -> list[Message | BotCommand | None]: """Drain a sync generator until it yields None sentinel.""" - results: List[Optional[Union[Message, BotCommand]]] = [] + results: list[Message | BotCommand | None] = [] try: gen = fn(ctx) for item in gen: @@ -234,9 +234,9 @@ async def _drain_async_gen( fn: Any, ctx: Any, backend: str, -) -> List[Optional[Union[Message, BotCommand]]]: +) -> list[Message | BotCommand | None]: """Async helper to drain an async generator until None sentinel.""" - results: List[Optional[Union[Message, BotCommand]]] = [] + results: list[Message | BotCommand | None] = [] async for item in fn(ctx): if item is None: break @@ -249,7 +249,7 @@ def _run_async_generator( ctx: Any, backend: str, timeout: float, -) -> List[Optional[Union[Message, BotCommand]]]: +) -> list[Message | BotCommand | None]: """Drain an async generator in the background event loop.""" loop = _get_event_loop() try: diff --git a/csp_bot/commands/framework.py b/csp_bot/commands/framework.py index 4686d25..3f609c5 100644 --- a/csp_bot/commands/framework.py +++ b/csp_bot/commands/framework.py @@ -13,35 +13,32 @@ from __future__ import annotations import logging +from collections.abc import Callable from typing import ( Any, - Callable, - Dict, - List, - Optional, - Type, ) from ccflow import BaseModel +from pydantic import Field from csp_bot.commands.context import CommandContext log = logging.getLogger(__name__) -_COMMAND_REGISTRY: Dict[str, "CommandEntry"] = {} +_COMMAND_REGISTRY: dict[str, CommandEntry] = {} class CommandEntry: """Internal registry entry for a command.""" - __slots__ = ("name", "help", "backends", "handler", "is_class") + __slots__ = ("backends", "handler", "help", "is_class", "name") def __init__( self, name: str, help: str, handler: Any, - backends: Optional[List[str]] = None, + backends: list[str] | None = None, is_class: bool = False, ): self.name = name @@ -54,7 +51,7 @@ def __init__( def command( name: str, help: str = "", - backends: Optional[List[str]] = None, + backends: list[str] | None = None, ) -> Callable: """Decorator to register a function as a bot command. @@ -99,7 +96,7 @@ def decorator(fn: Callable) -> Callable: return decorator -def get_registered_commands() -> Dict[str, CommandEntry]: +def get_registered_commands() -> dict[str, CommandEntry]: """Return a copy of the global command registry.""" return dict(_COMMAND_REGISTRY) @@ -136,7 +133,7 @@ def execute(self, ctx: CommandContext) -> str: help: str = "" """Help text shown by the /help command.""" - backends: List[str] = [] + backends: list[str] = Field(default_factory=list) """Backends this command supports. Empty = all.""" def execute(self, ctx: CommandContext) -> Any: @@ -160,4 +157,4 @@ class CommandModel(BaseModel): api_url: "https://..." """ - command: Type[Command] = Command + command: type[Command] = Command diff --git a/csp_bot/commands/help.py b/csp_bot/commands/help.py index 660da2a..80b1769 100644 --- a/csp_bot/commands/help.py +++ b/csp_bot/commands/help.py @@ -1,7 +1,8 @@ """Help command for csp-bot.""" +from collections.abc import Mapping from logging import getLogger -from typing import Any, Mapping, Optional, Type +from typing import Any from chatom import Message from chatom.format import Bold, Code, FormattedMessage, Heading, LineBreak, ListItem, Span, Table, Text, UnorderedList @@ -85,8 +86,8 @@ def help(self) -> str: def execute( self, command: BotCommand, - commands: Mapping[str, Any] = None, - ) -> Optional[Message]: + commands: Mapping[str, Any] | None = None, + ) -> Message | None: log.info(f"Help command: {command.command}") # Collect help for each command @@ -120,4 +121,4 @@ def execute( class HelpCommandModel(BaseCommandModel): - command: Type[BaseCommand] = HelpCommand + command: type[BaseCommand] = HelpCommand diff --git a/csp_bot/commands/legacy.py b/csp_bot/commands/legacy.py index 6442a16..7b48454 100644 --- a/csp_bot/commands/legacy.py +++ b/csp_bot/commands/legacy.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging -from typing import Any, List +from typing import Any from csp_bot.commands.base import BaseCommand from csp_bot.commands.context import CommandContext @@ -47,7 +47,7 @@ def help(self) -> str: return self._command.help() @property - def backends(self) -> List[str]: + def backends(self) -> list[str]: return self._command.backends() def context_to_bot_command(self, ctx: CommandContext) -> BotCommand: diff --git a/csp_bot/commands/schedule.py b/csp_bot/commands/schedule.py index 551e07e..871fa0d 100644 --- a/csp_bot/commands/schedule.py +++ b/csp_bot/commands/schedule.py @@ -4,7 +4,7 @@ """ from logging import getLogger -from typing import TYPE_CHECKING, List, Optional, Type +from typing import TYPE_CHECKING from chatom import Message from chatom.format import Bold, FormattedMessage, Table, Text @@ -39,10 +39,10 @@ def preexecute( command: BotCommand, schedule: "ScheduleStore", bot_instance: "Bot", - ) -> Optional[BotCommand]: + ) -> BotCommand | None: log.info(f"Schedule command preexecute: {command.command}") - remove: List[int] = [] + remove: list[int] = [] if not command.args: command.args = ("list",) @@ -128,4 +128,4 @@ def execute(self, command: BotCommand, schedule: "ScheduleStore") -> Message: class ScheduleCommandModel(BaseCommandModel): - command: Type[BaseCommand] = ScheduleCommand + command: type[BaseCommand] = ScheduleCommand diff --git a/csp_bot/commands/status.py b/csp_bot/commands/status.py index d1ea983..5e870bb 100644 --- a/csp_bot/commands/status.py +++ b/csp_bot/commands/status.py @@ -3,12 +3,12 @@ Displays system and bot status information using FormattedMessage. """ -from datetime import datetime +from datetime import datetime, timezone from getpass import getuser from logging import getLogger from socket import gethostname from threading import active_count -from typing import TYPE_CHECKING, List, Optional, Type +from typing import TYPE_CHECKING, ClassVar import psutil from chatom import Message @@ -30,7 +30,7 @@ class StatusCommand(ReplyCommand): """Display bot and system status.""" - _adapters: List[str] = [] + _adapters: ClassVar[list[str]] = [] def command(self) -> str: return "status" @@ -45,14 +45,14 @@ def preexecute(self, command: BotCommand, bot_instance: "Bot") -> BotCommand: self._adapters = list(bot_instance._adapters.keys()) return command - def execute(self, command: BotCommand) -> Optional[Message]: + def execute(self, command: BotCommand) -> Message | None: log.info("Status command") mem = psutil.virtual_memory() proc = psutil.Process() rows = [ - {"Metric": "Now", "Value": str(datetime.utcnow())}, + {"Metric": "Now", "Value": str(datetime.now(timezone.utc))}, {"Metric": "Backends", "Value": ", ".join(self._adapters)}, {"Metric": "CPU", "Value": f"{psutil.cpu_percent()}%"}, {"Metric": "Memory", "Value": f"{mem.percent}%"}, @@ -75,4 +75,4 @@ def execute(self, command: BotCommand) -> Optional[Message]: class StatusCommandModel(BaseCommandModel): - command: Type[BaseCommand] = StatusCommand + command: type[BaseCommand] = StatusCommand diff --git a/csp_bot/config/__init__.py b/csp_bot/config/__init__.py index a4d7cfd..72dc062 100644 --- a/csp_bot/config/__init__.py +++ b/csp_bot/config/__init__.py @@ -1,6 +1,5 @@ from logging import getLogger from pathlib import Path -from typing import List, Optional from ccflow import RootModelRegistry, load_config as load_config_base @@ -14,7 +13,7 @@ def load_config( config_dir: str = "", config_name: str = "", - overrides: Optional[List[str]] = None, + overrides: list[str] | None = None, *, overwrite: bool = False, basepath: str = "", diff --git a/csp_bot/gateway/gateway.py b/csp_bot/gateway/gateway.py index e44ad2d..c235239 100644 --- a/csp_bot/gateway/gateway.py +++ b/csp_bot/gateway/gateway.py @@ -5,7 +5,7 @@ from functools import wraps from logging import getLogger -from typing import Any, List, Union +from typing import Any from chatom import Message from csp import ts @@ -25,12 +25,12 @@ log = getLogger(__name__) __all__ = ( + "Channels", + "CspBotGateway", + "Gateway", "GatewayChannels", "GatewayModule", "GatewaySettings", - "CspBotGateway", - "Channels", - "Gateway", "Module", "Settings", ) @@ -67,7 +67,7 @@ class CspBotGateway(BaseGateway): """CSP Bot Gateway with chatom integration.""" settings: GatewaySettings = Field(default_factory=GatewaySettings) - commands: List[Union[BaseCommandModel, CommandModel]] = [] + commands: list[BaseCommandModel | CommandModel] = Field(default_factory=list) deps: Any = None @model_validator(mode="before") @@ -84,20 +84,20 @@ def __hash__(self): def __init__( self, - modules: List[GatewayModule] = None, - channels: GatewayChannels = None, - commands: List[Union[BaseCommandModel, CommandModel]] = None, + modules: list[GatewayModule] | None = None, + channels: GatewayChannels | None = None, + commands: list[BaseCommandModel | CommandModel] | None = None, deps: Any = None, *args, **kwargs, ): channels = channels or GatewayChannels() super().__init__( + *args, modules=modules, channels=channels, commands=commands, deps=deps, - *args, **kwargs, ) diff --git a/csp_bot/persistence.py b/csp_bot/persistence.py index e0ce6bf..9915c29 100644 --- a/csp_bot/persistence.py +++ b/csp_bot/persistence.py @@ -8,10 +8,11 @@ import threading import uuid +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pickle import HIGHEST_PROTOCOL, dumps, loads -from typing import Any, Iterable, Optional, Protocol +from typing import Any, Protocol from urllib.parse import quote, unquote from csp_bot.structs import BotCommand @@ -30,7 +31,7 @@ def _utc_now() -> datetime: return datetime.now(timezone.utc) -def _to_utc(value: Optional[datetime]) -> Optional[datetime]: +def _to_utc(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: @@ -38,7 +39,7 @@ def _to_utc(value: Optional[datetime]) -> Optional[datetime]: return value.astimezone(timezone.utc) -def _sort_datetime(value: Optional[datetime]) -> datetime: +def _sort_datetime(value: datetime | None) -> datetime: return _to_utc(value) or datetime.max.replace(tzinfo=timezone.utc) @@ -51,9 +52,9 @@ class StoredRecord: value: Any created_at: datetime updated_at: datetime - expires_at: Optional[datetime] = None + expires_at: datetime | None = None - def is_expired(self, now: Optional[datetime] = None) -> bool: + def is_expired(self, now: datetime | None = None) -> bool: if self.expires_at is None: return False return (_to_utc(now) or _utc_now()) >= _to_utc(self.expires_at) @@ -66,7 +67,7 @@ def get(self, namespace: str, key: str, default: Any = None) -> Any: """Return a value, or default if missing or expired.""" ... - def put(self, namespace: str, key: str, value: Any, ttl_seconds: Optional[float] = None) -> StoredRecord: + def put(self, namespace: str, key: str, value: Any, ttl_seconds: float | None = None) -> StoredRecord: """Store a value with an optional TTL and return its record metadata. A TTL of ``None`` means no expiry. A TTL of ``0`` expires immediately. @@ -81,11 +82,11 @@ def records(self, namespace: str, prefix: str = "") -> Iterable[StoredRecord]: """Return unexpired records in a namespace, optionally filtered by key prefix.""" ... - def cleanup_expired(self, namespace: Optional[str] = None) -> int: + def cleanup_expired(self, namespace: str | None = None) -> int: """Remove expired records and return the number removed.""" ... - def clear(self, namespace: Optional[str] = None) -> int: + def clear(self, namespace: str | None = None) -> int: """Remove records, optionally limited to one namespace.""" ... @@ -112,7 +113,7 @@ def get(self, namespace: str, key: str, default: Any = None) -> Any: return default return record.value - def put(self, namespace: str, key: str, value: Any, ttl_seconds: Optional[float] = None) -> StoredRecord: + def put(self, namespace: str, key: str, value: Any, ttl_seconds: float | None = None) -> StoredRecord: now = _utc_now() record_key = (namespace, key) with self._lock: @@ -144,7 +145,7 @@ def records(self, namespace: str, prefix: str = "") -> list[StoredRecord]: if record_namespace == namespace and record_key.startswith(prefix) ] - def cleanup_expired(self, namespace: Optional[str] = None) -> int: + def cleanup_expired(self, namespace: str | None = None) -> int: now = _utc_now() with self._lock: expired_keys = [ @@ -156,7 +157,7 @@ def cleanup_expired(self, namespace: Optional[str] = None) -> int: self._records.pop(record_key, None) return len(expired_keys) - def clear(self, namespace: Optional[str] = None) -> int: + def clear(self, namespace: str | None = None) -> int: """Remove records, optionally limited to one namespace.""" with self._lock: if namespace is None: @@ -195,7 +196,7 @@ def get(self, namespace: str, key: str, default: Any = None) -> Any: return default return record.value - def put(self, namespace: str, key: str, value: Any, ttl_seconds: Optional[float] = None) -> StoredRecord: + def put(self, namespace: str, key: str, value: Any, ttl_seconds: float | None = None) -> StoredRecord: now = _utc_now() map_key = self._map_key(namespace, key) with self._lock: @@ -229,7 +230,7 @@ def records(self, namespace: str, prefix: str = "") -> list[StoredRecord]: records.append(record) return records - def cleanup_expired(self, namespace: Optional[str] = None) -> int: + def cleanup_expired(self, namespace: str | None = None) -> int: now = _utc_now() encoded_namespace = self._encode(namespace) if namespace is not None else None with self._lock: @@ -244,13 +245,13 @@ def cleanup_expired(self, namespace: Optional[str] = None) -> int: self._delete_map_key(map_key) return len(expired_keys) - def clear(self, namespace: Optional[str] = None) -> int: + def clear(self, namespace: str | None = None) -> int: with self._lock: if namespace is None: keys = list(self._mapper.keys()) else: encoded_namespace = self._encode(namespace) - keys = [map_key for map_key in self._mapper.keys() if map_key.startswith(f"{encoded_namespace}/")] + keys = [map_key for map_key in self._mapper if map_key.startswith(f"{encoded_namespace}/")] for map_key in keys: self._delete_map_key(map_key) return len(keys) @@ -267,7 +268,7 @@ def _decode(value: str) -> str: def _map_key(cls, namespace: str, key: str) -> str: return f"{cls._encode(namespace)}/{cls._encode(key)}" - def _load_record(self, map_key: str) -> Optional[StoredRecord]: + def _load_record(self, map_key: str) -> StoredRecord | None: try: data = self._mapper[map_key] except KeyError: @@ -291,7 +292,7 @@ class ScheduledCommandRecord: schedule_id: str command: BotCommand - next_run_at: Optional[datetime] + next_run_at: datetime | None created_at: datetime updated_at: datetime @@ -311,9 +312,9 @@ def __init__(self, store: StateStore) -> None: def put( self, command: BotCommand, - schedule_id: Optional[str] = None, - next_run_at: Optional[datetime] = None, - ttl_seconds: Optional[float] = None, + schedule_id: str | None = None, + next_run_at: datetime | None = None, + ttl_seconds: float | None = None, ) -> ScheduledCommandRecord: """Store a scheduled command. @@ -335,7 +336,7 @@ def put( self._store.put(self.namespace, resolved_schedule_id, record, ttl_seconds=ttl_seconds) return record - def get(self, schedule_id: str) -> Optional[ScheduledCommandRecord]: + def get(self, schedule_id: str) -> ScheduledCommandRecord | None: record = self._store.get(self.namespace, schedule_id) if isinstance(record, ScheduledCommandRecord): return record diff --git a/csp_bot/structs.py b/csp_bot/structs.py index fc1502f..706341d 100644 --- a/csp_bot/structs.py +++ b/csp_bot/structs.py @@ -6,16 +6,15 @@ from datetime import datetime from enum import Enum -from typing import Tuple from chatom import Channel, Message as ChatomMessage, User from csp_gateway.utils.struct import GatewayStruct __all__ = ( "Backend", - "CommandVariant", "BotCommand", "BotMessage", + "CommandVariant", ) @@ -64,7 +63,7 @@ class BotMessage(GatewayStruct): backend: str """Target backend platform.""" - mentions: Tuple[str] + mentions: tuple[str] """User IDs to mention in the message.""" formatted: object # FormattedMessage, but can't use pydantic in Struct @@ -121,13 +120,13 @@ class BotCommand(GatewayStruct): command: str """The command name (without leading /).""" - args: Tuple[str] + args: tuple[str] """Command arguments as parsed tokens.""" source: User """The user who issued the command.""" - targets: Tuple[User] + targets: tuple[User] """Users mentioned/tagged in the command.""" channel_id: str diff --git a/csp_bot/tests/test_bot_integration.py b/csp_bot/tests/test_bot_integration.py index 3d2cbe0..9f78a5f 100644 --- a/csp_bot/tests/test_bot_integration.py +++ b/csp_bot/tests/test_bot_integration.py @@ -8,12 +8,12 @@ """ import asyncio -from typing import Optional from unittest.mock import MagicMock, patch import pytest from chatom import Channel, ChannelType, Message, User from chatom.discord import DiscordChannel +from pydantic import Field from csp_bot import Bot, BotCommand, BotConfig, BotMessage from csp_bot.bot_config import SymphonyConfig @@ -117,7 +117,7 @@ def name(self): def help(self): return "Test command" - def execute(self, cmd: BotCommand) -> Optional[Message]: + def execute(self, cmd: BotCommand) -> Message | None: # Return message with backend field but NO metadata return Message( content="Hello", @@ -169,7 +169,7 @@ def name(self): def help(self): return "Metadata test" - def execute(self, cmd: BotCommand) -> Optional[Message]: + def execute(self, cmd: BotCommand) -> Message | None: return Message( content="Hello", channel=cmd.channel, @@ -214,7 +214,7 @@ def name(self): def help(self): return "Backend test" - def execute(self, cmd: BotCommand) -> Optional[Message]: + def execute(self, cmd: BotCommand) -> Message | None: # Return message with explicit backend different from command return Message( content="Hello", @@ -419,7 +419,7 @@ def test_model_command_skipped_when_backend_not_active(self, bot_with_symphony): class SlackOnlyCommand(Command): name: str = "model_slack_only" help: str = "Slack-only model command" - backends: list[str] = ["slack"] + backends: list[str] = Field(default_factory=lambda: ["slack"]) def execute(self, ctx): return "nope" @@ -493,7 +493,7 @@ def test_parse_room_directive(self, bot_with_symphony): tokens = ["@User", "/room", "TKP"] mentions = [User(id="user123", name="User")] - args, targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") + args, _targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") assert channel == "TKP" assert "/room" not in args @@ -504,7 +504,7 @@ def test_parse_channel_directive(self, bot_with_symphony): tokens = ["@User", "/channel", "general"] mentions = [User(id="user123", name="User")] - args, targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") + args, _targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") assert channel == "general" assert "/channel" not in args @@ -515,7 +515,7 @@ def test_parse_bang_room_directive(self, bot_with_symphony): tokens = ["@User", "!room", "TKP"] mentions = [User(id="user123", name="User")] - args, targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") + args, _targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") assert channel == "TKP" assert "!room" not in args @@ -525,7 +525,7 @@ def test_parse_bang_channel_directive(self, bot_with_symphony): tokens = ["@User", "!channel", "random"] mentions = [User(id="user123", name="User")] - args, targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") + args, _targets, channel = bot_with_symphony._parse_command_args(tokens, mentions, "slack") assert channel == "random" assert "!channel" not in args @@ -584,19 +584,20 @@ async def mock_fetch_channel(id=None, name=None): mock_backend_instance.fetch_channel = mock_fetch_channel mock_backend_class.return_value = mock_backend_instance - with patch.object(type(mock_adapter.backend), "__call__", mock_backend_class): - # We need to patch the type() call - with patch("csp_bot.bot.type") as mock_type: - mock_type.return_value = mock_backend_class + with ( + patch.object(type(mock_adapter.backend), "__call__", mock_backend_class), + patch("csp_bot.bot.type") as mock_type, + ): + mock_type.return_value = mock_backend_class - # Call the method - the key test is that it doesn't raise an exception - # and that the event loop handling works correctly - _channel = bot_with_symphony._resolve_channel("TKP", "symphony") + # Call the method - the key test is that it doesn't raise an exception + # and that the event loop handling works correctly + _channel = bot_with_symphony._resolve_channel("TKP", "symphony") - # Note: This may return None if the mock isn't set up quite right, - # but the key test is that it doesn't raise an exception - # and that the event loop handling works - assert _channel is None or hasattr(_channel, "id") + # Note: This may return None if the mock isn't set up quite right, + # but the key test is that it doesn't raise an exception + # and that the event loop handling works + assert _channel is None or hasattr(_channel, "id") class TestEnsureBackendConnected: diff --git a/csp_bot/tests/test_command_framework.py b/csp_bot/tests/test_command_framework.py index 6254bf4..34accdc 100644 --- a/csp_bot/tests/test_command_framework.py +++ b/csp_bot/tests/test_command_framework.py @@ -10,6 +10,7 @@ import pytest from chatom import Channel, Message, User from chatom.format import Bold, FormattedMessage, Text, UserMention +from pydantic import Field from csp_bot.commands.base import ReplyToOtherCommand from csp_bot.commands.context import BotInfo, CommandContext @@ -21,18 +22,18 @@ def _make_ctx(**overrides) -> CommandContext: """Build a CommandContext with sensible defaults.""" - defaults = dict( - command_name="test", - source=User(id="U1", name="alice"), - targets=[User(id="U2", name="bob")], - channel=Channel(id="C1", name="general"), - message=Message(content="/test hello", channel_id="C1"), - args=["hello"], - args_text="hello", - backend="slack", - bot=BotInfo(id="B1", name="testbot", version="0.0.1"), - deps=None, - ) + defaults = { + "command_name": "test", + "source": User(id="U1", name="alice"), + "targets": [User(id="U2", name="bob")], + "channel": Channel(id="C1", name="general"), + "message": Message(content="/test hello", channel_id="C1"), + "args": ["hello"], + "args_text": "hello", + "backend": "slack", + "bot": BotInfo(id="B1", name="testbot", version="0.0.1"), + "deps": None, + } defaults.update(overrides) return CommandContext(**defaults) @@ -745,7 +746,7 @@ def test_class_command_through_executor(self): class Thanks(Command): name: str = "thanks" help: str = "Thank someone" - gifts: list = ["cookie", "cake"] + gifts: list = Field(default_factory=lambda: ["cookie", "cake"]) def execute(self, ctx): return f"{ctx.mention(ctx.target)} gets a {self.gifts[0]}" diff --git a/csp_bot/tests/test_status.py b/csp_bot/tests/test_status.py new file mode 100644 index 0000000..1663f05 --- /dev/null +++ b/csp_bot/tests/test_status.py @@ -0,0 +1,36 @@ +from unittest.mock import MagicMock + +from chatom import Message, User + +from csp_bot.commands.status import StatusCommand +from csp_bot.structs import BotCommand + + +def _command() -> BotCommand: + return BotCommand( + backend="slack", + command="status", + args=(), + channel_id="C1", + channel_name="general", + source=User(id="U1"), + targets=(), + message=Message(content="/status"), + ) + + +def test_status_preexecute_records_instance_adapters(): + command = StatusCommand() + bot = MagicMock() + bot._adapters = {"slack": object(), "symphony": object()} + + bot_command = _command() + assert command.preexecute(bot_command, bot) is bot_command + assert command._adapters == ["slack", "symphony"] + + +def test_status_execute_uses_aware_utc_timestamp(): + result = StatusCommand().execute(_command()) + + assert result is not None + assert "+00:00" in result.content diff --git a/csp_bot/utils.py b/csp_bot/utils.py index 97a463b..6d0ea90 100644 --- a/csp_bot/utils.py +++ b/csp_bot/utils.py @@ -4,22 +4,22 @@ cross-platform capabilities for mentions, formatting, etc. """ -from typing import Literal, Optional +from typing import Literal from urllib.parse import urlparse from chatom import User, mention_user_for_backend from chatom.format import Format, FormattedMessage, get_format_for_backend __all__ = ( + "Backend", + "format_message", + "format_with_message_ml", + "get_backend_format", "is_valid_url", "mention_user", "mention_users", - "format_message", - "get_backend_format", - "format_with_message_ml", - "sanitize_message", "recursive_format_for_message_ml", - "Backend", + "sanitize_message", ) Backend = Literal["discord", "slack", "symphony", "telegram"] @@ -88,7 +88,7 @@ def mention_users( def format_message( content: str, backend: Backend, - formatted: Optional[FormattedMessage] = None, + formatted: FormattedMessage | None = None, ) -> str: """Format a message for a specific backend.