mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 13:08:19 -04:00
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
This commit is contained in:
138
python/signal_bot/main.py
Normal file
138
python/signal_bot/main.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user