# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """What a command handler is allowed to touch, and how it answers. Split out of registry.py at the 250-line cap. The division is real rather than clerical: that file is "which word runs what", this one is "what the thing that runs may do" -- and the reply helpers are the only place in the codebase that decides *where* an answer goes. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from signal_bot.analytics.probe import timed_send from signal_bot.quoting import quote_of if TYPE_CHECKING: from signal_bot.config import Config from signal_bot.models import IncomingMessage from signal_bot.store import Store from signal_bot.transport_base import Transport @dataclass(frozen=True, slots=True) class CommandContext: """Everything a command handler is allowed to touch.""" message: IncomingMessage group_id: str transport: Transport store: Store config: Config command_name: str now: float day: str # Whatever followed the trigger, split on whitespace. Empty for every # command that does not opt into taking any -- see Command.takes_args. args: tuple[str, ...] = () async def reply(self, text: str) -> None: """Answer the message that triggered this command, in its group. There is no configurable reply target. A command answers where it was asked, which is the only routing that stays correct once the bot watches a test group and a production group at once -- and it quotes the asker, so a busy group can tell which "!cat" this is. """ async with timed_send(): await self.transport.send_text(text, self.group_id, quote_of(self.message)) async def reply_image(self, image_base64: str, caption: str = "") -> None: """Answer the triggering message with an image, quoting it.""" async with timed_send(): await self.transport.send_image( image_base64, self.group_id, quote_of(self.message), caption ) async def reply_media(self, payload_base64: str, media_type: str, filename: str) -> None: """Answer with an attachment that is not necessarily a photo. Separate from reply_image because Signal decides how to render an attachment from what it is told it is, and "a jpeg" is a lie that makes a video arrive as a file nobody can play. """ async with timed_send(): await self.transport.send_media( payload_base64, self.group_id, quote_of(self.message), media_type=media_type, filename=filename, )