"""Signal command and control bot — main entry point.""" from __future__ import annotations import logging from os import getenv import time from typing import Annotated import typer from python.common import configure_logger from python.orm.common import get_postgres_engine from python.signal_bot.commands.inventory import handle_inventory_update from python.signal_bot.device_registry import DeviceRegistry from python.signal_bot.llm_client import LLMClient from python.signal_bot.models import BotConfig, SignalMessage from python.signal_bot.signal_client import SignalClient logger = logging.getLogger(__name__) HELP_TEXT = ( "Available commands:\n" " !inventory — update van inventory from a text list\n" " !inventory (+ photo) — update van inventory from a receipt photo\n" " !status — show bot status\n" " !help — show this help message\n" "Send a receipt photo with the message '!inventory' to scan it.\n" ) def help_action( signal: SignalClient, message: SignalMessage, _llm: LLMClient, _registry: DeviceRegistry, _config: BotConfig, _cmd: str, ) -> None: """Return the help text for the bot.""" signal.reply(message, HELP_TEXT) def status_action( signal: SignalClient, message: SignalMessage, llm: LLMClient, registry: DeviceRegistry, _config: BotConfig, _cmd: str, ) -> None: """Return the status of the bot.""" models = llm.list_models() model_list = ", ".join(models[:10]) device_count = len(registry.list_devices()) signal.reply( message, f"Bot online.\nLLM: {llm.model}\nAvailable models: {model_list}\nKnown devices: {device_count}", ) def unknown_action( signal: SignalClient, message: SignalMessage, _llm: LLMClient, _registry: DeviceRegistry, _config: BotConfig, cmd: str, ) -> None: """Return an error message for an unknown command.""" signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}") def inventory_action( signal: SignalClient, message: SignalMessage, llm: LLMClient, _registry: DeviceRegistry, config: BotConfig, _cmd: str, ) -> None: """Process an inventory update.""" handle_inventory_update(message, signal, llm, config.inventory_api_url) def dispatch( message: SignalMessage, signal: SignalClient, llm: LLMClient, registry: DeviceRegistry, config: BotConfig, ) -> None: """Route an incoming message to the right command handler.""" source = message.source if not registry.is_verified(source) or not registry.has_safety_number(source): logger.info(f"Device {source} not verified, ignoring message") return text = message.message.strip() prefix = config.cmd_prefix if not text.startswith(prefix) and not message.attachments: return cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else "" commands = { "help": help_action, "status": status_action, "inventory": inventory_action, } action = commands.get(cmd, unknown_action) action(signal, message, llm, registry, config, cmd) def run_loop( config: BotConfig, signal: SignalClient, llm: LLMClient, registry: DeviceRegistry, ) -> None: """Listen for messages via WebSocket, reconnecting on failure.""" logger.info("Bot started — listening via WebSocket") retries = 0 delay = config.reconnect_delay while retries < config.max_retries: try: for message in signal.listen(): logger.info(f"Message from {message.source}: {message.message[:80]}") safety_number = signal.get_safety_number(message.source) registry.record_contact(message.source, safety_number) dispatch(message, signal, llm, registry, config) retries = 0 delay = config.reconnect_delay except Exception: retries += 1 logger.exception(f"WebSocket error ({retries}/{config.max_retries}), reconnecting in {delay}s") time.sleep(delay) delay = min(delay * 2, config.max_reconnect_delay) logger.critical("Max retries exceeded, shutting down") def main(log_level: Annotated[str, typer.Option()] = "INFO") -> None: """Run the Signal command and control bot.""" configure_logger(log_level) signal_api_url = getenv("SIGNAL_API_URL") phone_number = getenv("SIGNAL_PHONE_NUMBER") inventory_api_url = getenv("INVENTORY_API_URL") if signal_api_url is None: error = "SIGNAL_API_URL environment variable not set" raise ValueError(error) if phone_number is None: error = "SIGNAL_PHONE_NUMBER environment variable not set" raise ValueError(error) if inventory_api_url is None: error = "INVENTORY_API_URL environment variable not set" raise ValueError(error) config = BotConfig( signal_api_url=signal_api_url, phone_number=phone_number, inventory_api_url=inventory_api_url, ) engine = get_postgres_engine(name="RICHIE") llm_host = getenv("LLM_HOST") llm_model = getenv("LLM_MODEL", "qwen3-vl:32b") llm_port = int(getenv("LLM_PORT", "11434")) if llm_host is None: error = "LLM_HOST environment variable not set" raise ValueError(error) with ( SignalClient(config.signal_api_url, config.phone_number) as signal, LLMClient(model=llm_model, host=llm_host, port=llm_port) as llm, ): registry = DeviceRegistry(signal, engine) run_loop(config, signal, llm, registry) if __name__ == "__main__": typer.run(main)