Files
dotfiles/python/signal_bot/main.py
Claude 51f6cd23ad add Signal command and control bot service
Python service for jeeves that communicates over Signal via signal-cli-rest-api.
Implements device verification via safety numbers (unverified devices cannot
run commands until verified over SSH), and a van inventory command that uses
an LLM on BOB (ollama) to parse receipt photos or text lists into structured
inventory data. The LLM backend is configurable to swap models easily.

https://claude.ai/code/session_01AKXQBuVBsW7J1YbukDiQ7A
2026-03-14 11:49:44 -04:00

139 lines
4.5 KiB
Python

"""Signal command and control bot — main entry point."""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Annotated
import typer
from python.common import configure_logger
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, LLMConfig, SignalMessage
from python.signal_bot.signal_client import SignalClient
logger = logging.getLogger(__name__)
# Command prefix — messages must start with this to be treated as commands.
CMD_PREFIX = "!"
HELP_TEXT = """\
Available commands:
!inventory <text list> — update van inventory from a text list
!inventory (+ photo) — update van inventory from a receipt photo
!status — show bot status
!help — show this help message
Send a receipt photo with the message "!inventory" to scan it.\
"""
def dispatch(
message: SignalMessage,
signal: SignalClient,
llm: LLMClient,
registry: DeviceRegistry,
inventory_path: Path,
) -> None:
"""Route an incoming message to the right command handler."""
source = message.source
if registry.is_blocked(source):
return
if not registry.is_verified(source):
signal.reply(
message,
"Your device is not verified. Ask the admin to verify your safety number over SSH.",
)
return
text = message.message.strip()
if not text.startswith(CMD_PREFIX) and not message.attachments:
return
cmd = text.lstrip(CMD_PREFIX).split()[0].lower() if text.startswith(CMD_PREFIX) else ""
if cmd == "help":
signal.reply(message, HELP_TEXT)
elif cmd == "status":
models = llm.list_models()
model_list = ", ".join(models[:10])
device_count = len(registry.list_devices())
signal.reply(
message,
f"Bot online.\nLLM: {llm.config.model}\nAvailable models: {model_list}\nKnown devices: {device_count}",
)
elif cmd == "inventory" or (message.attachments and not text.startswith(CMD_PREFIX)):
handle_inventory_update(message, signal, llm, inventory_path)
else:
signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}")
def run_loop(
config: BotConfig,
signal: SignalClient,
llm: LLMClient,
registry: DeviceRegistry,
) -> None:
"""Main polling loop."""
inventory_path = Path(config.inventory_file)
logger.info(f"Bot started — polling every {config.poll_interval}s")
while True:
try:
messages = signal.receive()
for message in messages:
logger.info(f"Message from {message.source}: {message.message[:80]}")
registry.record_contact(message.source, "")
dispatch(message, signal, llm, registry, inventory_path)
except Exception:
logger.exception("Error in message loop")
time.sleep(config.poll_interval)
def main(
signal_api_url: Annotated[str, typer.Option(envvar="SIGNAL_API_URL")],
phone_number: Annotated[str, typer.Option(envvar="SIGNAL_PHONE_NUMBER")],
llm_host: Annotated[str, typer.Option(envvar="LLM_HOST")],
llm_model: Annotated[str, typer.Option(envvar="LLM_MODEL")] = "qwen3-vl:32b",
llm_port: Annotated[int, typer.Option(envvar="LLM_PORT")] = 11434,
poll_interval: Annotated[int, typer.Option(help="Seconds between polls")] = 2,
inventory_file: Annotated[str, typer.Option(envvar="INVENTORY_FILE")] = "/var/lib/signal-bot/van_inventory.json",
registry_file: Annotated[str, typer.Option(envvar="REGISTRY_FILE")] = "/var/lib/signal-bot/device_registry.json",
log_level: Annotated[str, typer.Option()] = "INFO",
) -> None:
"""Run the Signal command and control bot."""
configure_logger(log_level)
llm_config = LLMConfig(model=llm_model, host=llm_host, port=llm_port)
config = BotConfig(
signal_api_url=signal_api_url,
phone_number=phone_number,
llm=llm_config,
poll_interval=poll_interval,
inventory_file=inventory_file,
)
signal = SignalClient(config.signal_api_url, config.phone_number)
llm = LLMClient(llm_config)
registry = DeviceRegistry(signal, Path(registry_file))
try:
run_loop(config, signal, llm, registry)
finally:
signal.close()
llm.close()
if __name__ == "__main__":
typer.run(main)