diff --git a/pyproject.toml b/pyproject.toml index e64b39d..4d8c947 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,9 +84,6 @@ lint.ignore = [ "python/alembic/**" = [ "INP001", # (perm) this creates LSP issues for alembic ] -"python/signal_bot/**" = [ - "D107", # (perm) class docstrings cover __init__ -] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/python/alembic/signal_bot/versions/2026_03_17-seprating_signal_bot_database_6eaf696e07a5.py b/python/alembic/signal_bot/versions/2026_03_17-seprating_signal_bot_database_6eaf696e07a5.py deleted file mode 100644 index d51cb3e..0000000 --- a/python/alembic/signal_bot/versions/2026_03_17-seprating_signal_bot_database_6eaf696e07a5.py +++ /dev/null @@ -1,100 +0,0 @@ -"""seprating signal_bot database. - -Revision ID: 6eaf696e07a5 -Revises: -Create Date: 2026-03-17 21:35:37.612672 - -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -from python.orm import SignalBotBase - -if TYPE_CHECKING: - from collections.abc import Sequence - -# revision identifiers, used by Alembic. -revision: str = "6eaf696e07a5" -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -schema = SignalBotBase.schema_name - - -def upgrade() -> None: - """Upgrade.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "dead_letter_message", - sa.Column("source", sa.String(), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), - sa.Column( - "status", postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema), nullable=False - ), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_dead_letter_message")), - schema=schema, - ) - op.create_table( - "role", - sa.Column("name", sa.String(length=50), nullable=False), - sa.Column("id", sa.SmallInteger(), nullable=False), - sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), - sa.UniqueConstraint("name", name=op.f("uq_role_name")), - schema=schema, - ) - op.create_table( - "signal_device", - sa.Column("phone_number", sa.String(length=50), nullable=False), - sa.Column("safety_number", sa.String(), nullable=True), - sa.Column( - "trust_level", - postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema), - nullable=False, - ), - sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_signal_device")), - sa.UniqueConstraint("phone_number", name=op.f("uq_signal_device_phone_number")), - schema=schema, - ) - op.create_table( - "device_role", - sa.Column("device_id", sa.Integer(), nullable=False), - sa.Column("role_id", sa.SmallInteger(), nullable=False), - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint( - ["device_id"], [f"{schema}.signal_device.id"], name=op.f("fk_device_role_device_id_signal_device") - ), - sa.ForeignKeyConstraint(["role_id"], [f"{schema}.role.id"], name=op.f("fk_device_role_role_id_role")), - sa.PrimaryKeyConstraint("id", name=op.f("pk_device_role")), - sa.UniqueConstraint("device_id", "role_id", name="uq_device_role_device_role"), - schema=schema, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("device_role", schema=schema) - op.drop_table("signal_device", schema=schema) - op.drop_table("role", schema=schema) - op.drop_table("dead_letter_message", schema=schema) - # ### end Alembic commands ### diff --git a/python/alembic/signal_bot/versions/2026_03_18-test_66bdd532bcab.py b/python/alembic/signal_bot/versions/2026_03_18-test_66bdd532bcab.py deleted file mode 100644 index c97cb71..0000000 --- a/python/alembic/signal_bot/versions/2026_03_18-test_66bdd532bcab.py +++ /dev/null @@ -1,72 +0,0 @@ -"""test. - -Revision ID: 66bdd532bcab -Revises: 6eaf696e07a5 -Create Date: 2026-03-18 19:21:14.561568 - -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -from python.orm import SignalBotBase - -if TYPE_CHECKING: - from collections.abc import Sequence - -# revision identifiers, used by Alembic. -revision: str = "66bdd532bcab" -down_revision: str | None = "6eaf696e07a5" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -schema = SignalBotBase.schema_name - - -def upgrade() -> None: - """Upgrade.""" - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "dead_letter_message", - "status", - existing_type=postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema), - type_=sa.Enum("UNPROCESSED", "PROCESSED", name="message_status", native_enum=False), - existing_nullable=False, - schema=schema, - ) - op.alter_column( - "signal_device", - "trust_level", - existing_type=postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema), - type_=sa.Enum("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", native_enum=False), - existing_nullable=False, - schema=schema, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade.""" - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "signal_device", - "trust_level", - existing_type=sa.Enum("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", native_enum=False), - type_=postgresql.ENUM("VERIFIED", "UNVERIFIED", "BLOCKED", name="trust_level", schema=schema), - existing_nullable=False, - schema=schema, - ) - op.alter_column( - "dead_letter_message", - "status", - existing_type=sa.Enum("UNPROCESSED", "PROCESSED", name="message_status", native_enum=False), - type_=postgresql.ENUM("UNPROCESSED", "PROCESSED", name="message_status", schema=schema), - existing_nullable=False, - schema=schema, - ) - # ### end Alembic commands ### diff --git a/python/database_cli.py b/python/database_cli.py index 600cb1d..bcd411a 100644 --- a/python/database_cli.py +++ b/python/database_cli.py @@ -83,13 +83,6 @@ DATABASES: dict[str, DatabaseConfig] = { base_class_name="VanInventoryBase", models_module="python.orm.van_inventory.models", ), - "signal_bot": DatabaseConfig( - env_prefix="SIGNALBOT", - version_location="python/alembic/signal_bot/versions", - base_module="python.orm.signal_bot.base", - base_class_name="SignalBotBase", - models_module="python.orm.signal_bot.models", - ), "data_science_dev": DatabaseConfig( env_prefix="DATA_SCIENCE_DEV", version_location="python/alembic/data_science_dev/versions", diff --git a/python/orm/__init__.py b/python/orm/__init__.py index 9b4466f..1f5f8aa 100644 --- a/python/orm/__init__.py +++ b/python/orm/__init__.py @@ -2,12 +2,10 @@ from python.orm.data_science_dev.base import DataScienceDevBase from python.orm.richie.base import RichieBase -from python.orm.signal_bot.base import SignalBotBase from python.orm.van_inventory.base import VanInventoryBase __all__ = [ "DataScienceDevBase", "RichieBase", - "SignalBotBase", "VanInventoryBase", ] diff --git a/python/orm/signal_bot/__init__.py b/python/orm/signal_bot/__init__.py deleted file mode 100644 index 1c92a4a..0000000 --- a/python/orm/signal_bot/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Signal bot database ORM exports.""" - -from __future__ import annotations - -from python.orm.signal_bot.base import SignalBotBase, SignalBotTableBase, SignalBotTableBaseSmall -from python.orm.signal_bot.models import DeadLetterMessage, DeviceRole, RoleRecord, SignalDevice - -__all__ = [ - "DeadLetterMessage", - "DeviceRole", - "RoleRecord", - "SignalBotBase", - "SignalBotTableBase", - "SignalBotTableBaseSmall", - "SignalDevice", -] diff --git a/python/orm/signal_bot/base.py b/python/orm/signal_bot/base.py deleted file mode 100644 index 12d8a34..0000000 --- a/python/orm/signal_bot/base.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Signal bot database ORM base.""" - -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy import DateTime, MetaData, SmallInteger, func -from sqlalchemy.ext.declarative import AbstractConcreteBase -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - -from python.orm.common import NAMING_CONVENTION - - -class SignalBotBase(DeclarativeBase): - """Base class for signal_bot database ORM models.""" - - schema_name = "main" - - metadata = MetaData( - schema=schema_name, - naming_convention=NAMING_CONVENTION, - ) - - -class _TableMixin: - """Shared timestamp columns for all table bases.""" - - created: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - ) - updated: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) - - -class SignalBotTableBaseSmall(_TableMixin, AbstractConcreteBase, SignalBotBase): - """Table with SmallInteger primary key.""" - - __abstract__ = True - - id: Mapped[int] = mapped_column(SmallInteger, primary_key=True) - - -class SignalBotTableBase(_TableMixin, AbstractConcreteBase, SignalBotBase): - """Table with Integer primary key.""" - - __abstract__ = True - - id: Mapped[int] = mapped_column(primary_key=True) diff --git a/python/orm/signal_bot/models.py b/python/orm/signal_bot/models.py deleted file mode 100644 index 126fee5..0000000 --- a/python/orm/signal_bot/models.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Signal bot device, role, and dead letter ORM models.""" - -from __future__ import annotations - -from datetime import datetime - -from sqlalchemy import DateTime, Enum, ForeignKey, SmallInteger, String, Text, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from python.orm.signal_bot.base import SignalBotTableBase, SignalBotTableBaseSmall -from python.signal_bot.models import MessageStatus, TrustLevel - - -class RoleRecord(SignalBotTableBaseSmall): - """Lookup table for RBAC roles, keyed by smallint.""" - - __tablename__ = "role" - - name: Mapped[str] = mapped_column(String(50), unique=True) - - -class DeviceRole(SignalBotTableBase): - """Association between a device and a role.""" - - __tablename__ = "device_role" - __table_args__ = ( - UniqueConstraint("device_id", "role_id", name="uq_device_role_device_role"), - {"schema": "main"}, - ) - - device_id: Mapped[int] = mapped_column(ForeignKey("main.signal_device.id")) - role_id: Mapped[int] = mapped_column(SmallInteger, ForeignKey("main.role.id")) - - -class SignalDevice(SignalBotTableBase): - """A Signal device tracked by phone number and safety number.""" - - __tablename__ = "signal_device" - - phone_number: Mapped[str] = mapped_column(String(50), unique=True) - safety_number: Mapped[str | None] - trust_level: Mapped[TrustLevel] = mapped_column( - Enum(TrustLevel, name="trust_level", create_constraint=False, native_enum=False), - default=TrustLevel.UNVERIFIED, - ) - last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - - roles: Mapped[list[RoleRecord]] = relationship(secondary=DeviceRole.__table__) - - -class DeadLetterMessage(SignalBotTableBase): - """A Signal message that failed processing and was sent to the dead letter queue.""" - - __tablename__ = "dead_letter_message" - - source: Mapped[str] - message: Mapped[str] = mapped_column(Text) - received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - status: Mapped[MessageStatus] = mapped_column( - Enum(MessageStatus, name="message_status", create_constraint=False, native_enum=False), - default=MessageStatus.UNPROCESSED, - ) diff --git a/python/signal_bot/__init__.py b/python/signal_bot/__init__.py deleted file mode 100644 index 78fa05f..0000000 --- a/python/signal_bot/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Signal command and control bot.""" diff --git a/python/signal_bot/commands/__init__.py b/python/signal_bot/commands/__init__.py deleted file mode 100644 index 71ffd0c..0000000 --- a/python/signal_bot/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Signal bot commands.""" diff --git a/python/signal_bot/commands/inventory.py b/python/signal_bot/commands/inventory.py deleted file mode 100644 index a1fa105..0000000 --- a/python/signal_bot/commands/inventory.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Van inventory command — parse receipts and item lists via LLM, push to API.""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING, Any - -import httpx - -from python.signal_bot.models import InventoryItem, InventoryUpdate - -if TYPE_CHECKING: - from python.signal_bot.llm_client import LLMClient - from python.signal_bot.models import SignalMessage - from python.signal_bot.signal_client import SignalClient - -logger = logging.getLogger(__name__) - -SYSTEM_PROMPT = """\ -You are an inventory assistant. Extract items from the input and return ONLY -a JSON array. Each element must have these fields: - - "name": item name (string) - - "quantity": numeric count or amount (default 1) - - "unit": unit of measure (e.g. "each", "lb", "oz", "gallon", "bag", "box") - - "category": category like "food", "tools", "supplies", etc. - - "notes": any extra detail (empty string if none) - -Example output: -[{"name": "water bottles", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": "1 gallon each"}] - -Return ONLY the JSON array, no other text.\ -""" - -IMAGE_PROMPT = "Extract all items from this receipt or inventory photo." -TEXT_PROMPT = "Extract all items from this inventory list." - - -def parse_llm_response(raw: str) -> list[InventoryItem]: - """Parse the LLM JSON response into InventoryItem list.""" - text = raw.strip() - # Strip markdown code fences if present - if text.startswith("```"): - lines = text.split("\n") - lines = [line for line in lines if not line.startswith("```")] - text = "\n".join(lines) - - items_data: list[dict[str, Any]] = json.loads(text) - return [InventoryItem.model_validate(item) for item in items_data] - - -def _upsert_item(api_url: str, item: InventoryItem) -> None: - """Create or update an item via the van_inventory API. - - Fetches existing items, and if one with the same name exists, - patches its quantity (summing). Otherwise creates a new item. - """ - base = api_url.rstrip("/") - response = httpx.get(f"{base}/api/items", timeout=10) - response.raise_for_status() - existing: list[dict[str, Any]] = response.json() - - match = next((e for e in existing if e["name"].lower() == item.name.lower()), None) - - if match: - new_qty = match["quantity"] + item.quantity - patch = {"quantity": new_qty} - if item.category: - patch["category"] = item.category - response = httpx.patch(f"{base}/api/items/{match['id']}", json=patch, timeout=10) - response.raise_for_status() - return - payload = { - "name": item.name, - "quantity": item.quantity, - "unit": item.unit, - "category": item.category or None, - } - response = httpx.post(f"{base}/api/items", json=payload, timeout=10) - response.raise_for_status() - - -def handle_inventory_update( - message: SignalMessage, - signal: SignalClient, - llm: LLMClient, - api_url: str, -) -> InventoryUpdate: - """Process an inventory update from a Signal message. - - Accepts either an image (receipt photo) or text list. - Uses the LLM to extract structured items, then pushes to the van_inventory API. - """ - try: - logger.info(f"Processing inventory update from {message.source}") - if message.attachments: - image_data = signal.get_attachment(message.attachments[0]) - raw_response = llm.chat( - IMAGE_PROMPT, - image_data=image_data, - system=SYSTEM_PROMPT, - ) - source_type = "receipt_photo" - elif message.message.strip(): - raw_response = llm.chat( - f"{TEXT_PROMPT}\n\n{message.message}", - system=SYSTEM_PROMPT, - ) - source_type = "text_list" - else: - signal.reply(message, "Send a photo of a receipt or a text list of items to update inventory.") - return InventoryUpdate() - - logger.info(f"{raw_response=}") - - new_items = parse_llm_response(raw_response) - - logger.info(f"{new_items=}") - - for item in new_items: - _upsert_item(api_url, item) - - summary = _format_summary(new_items) - signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}") - - return InventoryUpdate(items=new_items, raw_response=raw_response, source_type=source_type) - - except Exception: - logger.exception("Failed to process inventory update") - signal.reply(message, "Failed to process inventory update. Check logs for details.") - return InventoryUpdate() - - -def _format_summary(items: list[InventoryItem]) -> str: - """Format items into a readable summary.""" - lines = [f" - {item.name} x{item.quantity} {item.unit} [{item.category}]" for item in items] - return "\n".join(lines) diff --git a/python/signal_bot/commands/location.py b/python/signal_bot/commands/location.py deleted file mode 100644 index bb79c96..0000000 --- a/python/signal_bot/commands/location.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Location command for the Signal bot.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -import httpx - -if TYPE_CHECKING: - from python.signal_bot.models import SignalMessage - from python.signal_bot.signal_client import SignalClient - -logger = logging.getLogger(__name__) - - -def _get_entity_state(ha_url: str, ha_token: str, entity_id: str) -> dict[str, Any]: - """Fetch an entity's state from Home Assistant.""" - entity_url = f"{ha_url}/api/states/{entity_id}" - logger.debug(f"Fetching {entity_url=}") - response = httpx.get( - entity_url, - headers={"Authorization": f"Bearer {ha_token}"}, - timeout=30, - ) - response.raise_for_status() - return response.json() - - -def _format_location(latitude: str, longitude: str) -> str: - """Render a friendly location response.""" - return f"Van location: {latitude}, {longitude}\nhttps://maps.google.com/?q={latitude},{longitude}" - - -def handle_location_request( - message: SignalMessage, - signal: SignalClient, - ha_url: str | None, - ha_token: str | None, -) -> None: - """Reply with van location from Home Assistant.""" - if ha_url is None or ha_token is None: - signal.reply(message, "Location command is not configured (missing HA_URL or HA_TOKEN).") - return - - lat_payload = None - lon_payload = None - try: - lat_payload = _get_entity_state(ha_url, ha_token, "sensor.van_last_known_latitude") - lon_payload = _get_entity_state(ha_url, ha_token, "sensor.van_last_known_longitude") - except httpx.HTTPError: - logger.exception("Couldn't fetch van location from Home Assistant right now.") - logger.debug(f"{ha_url=} {lat_payload=} {lon_payload=}") - signal.reply(message, "Couldn't fetch van location from Home Assistant right now.") - return - - latitude = lat_payload.get("state", "") - longitude = lon_payload.get("state", "") - - if not latitude or not longitude or latitude == "unavailable" or longitude == "unavailable": - signal.reply(message, "Van location is unavailable in Home Assistant right now.") - return - - signal.reply(message, _format_location(latitude, longitude)) diff --git a/python/signal_bot/device_registry.py b/python/signal_bot/device_registry.py deleted file mode 100644 index 8221063..0000000 --- a/python/signal_bot/device_registry.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Device registry — tracks verified/unverified devices by safety number.""" - -from __future__ import annotations - -import logging -from datetime import datetime, timedelta -from typing import TYPE_CHECKING, NamedTuple - -from sqlalchemy import delete, select -from sqlalchemy.orm import Session - -from python.common import utcnow -from python.orm.signal_bot.models import RoleRecord, SignalDevice -from python.signal_bot.models import Role, TrustLevel - -if TYPE_CHECKING: - from sqlalchemy.engine import Engine - - from python.signal_bot.signal_client import SignalClient - -logger = logging.getLogger(__name__) - -_BLOCKED_TTL = timedelta(minutes=60) -_DEFAULT_TTL = timedelta(minutes=5) - - -class _CacheEntry(NamedTuple): - expires: datetime - trust_level: TrustLevel - has_safety_number: bool - safety_number: str | None - roles: list[Role] - - -class DeviceRegistry: - """Manage device trust based on Signal safety numbers. - - Devices start as UNVERIFIED. An admin verifies them over SSH by calling - ``verify(phone_number)`` which marks the device VERIFIED and also tells - signal-cli to trust the identity. - - Only VERIFIED devices may execute commands. - """ - - def __init__(self, signal_client: SignalClient, engine: Engine) -> None: - self.signal_client = signal_client - self.engine = engine - self._contact_cache: dict[str, _CacheEntry] = {} - - def is_verified(self, phone_number: str) -> bool: - """Check if a phone number is verified.""" - if entry := self._cached(phone_number): - return entry.trust_level == TrustLevel.VERIFIED - device = self._load_device(phone_number) - return device is not None and device.trust_level == TrustLevel.VERIFIED - - def record_contact(self, phone_number: str, safety_number: str | None = None) -> None: - """Record seeing a device. Creates entry if new, updates last_seen.""" - now = utcnow() - - entry = self._cached(phone_number) - if entry and entry.safety_number == safety_number: - return - - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if device: - if device.safety_number != safety_number and device.trust_level != TrustLevel.BLOCKED: - logger.warning(f"Safety number changed for {phone_number}, resetting to UNVERIFIED") - device.safety_number = safety_number - device.trust_level = TrustLevel.UNVERIFIED - device.last_seen = now - else: - device = SignalDevice( - phone_number=phone_number, - safety_number=safety_number, - trust_level=TrustLevel.UNVERIFIED, - last_seen=now, - ) - session.add(device) - logger.info(f"New device registered: {phone_number}") - - session.commit() - self._update_cache(phone_number, device) - - def has_safety_number(self, phone_number: str) -> bool: - """Check if a device has a safety number on file.""" - if entry := self._cached(phone_number): - return entry.has_safety_number - device = self._load_device(phone_number) - return device is not None and device.safety_number is not None - - def verify(self, phone_number: str) -> bool: - """Mark a device as verified. Called by admin over SSH. - - Returns True if the device was found and verified. - """ - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if not device: - logger.warning(f"Cannot verify unknown device: {phone_number}") - return False - - device.trust_level = TrustLevel.VERIFIED - self.signal_client.trust_identity(phone_number, trust_all_known_keys=True) - session.commit() - self._update_cache(phone_number, device) - logger.info(f"Device verified: {phone_number}") - return True - - def block(self, phone_number: str) -> bool: - """Block a device.""" - return self._set_trust(phone_number, TrustLevel.BLOCKED, "Device blocked") - - def unverify(self, phone_number: str) -> bool: - """Reset a device to unverified.""" - return self._set_trust(phone_number, TrustLevel.UNVERIFIED) - - # -- role management ------------------------------------------------------ - - def get_roles(self, phone_number: str) -> list[Role]: - """Return the roles for a device, defaulting to empty.""" - if entry := self._cached(phone_number): - return entry.roles - device = self._load_device(phone_number) - return _extract_roles(device) if device else [] - - def has_role(self, phone_number: str, role: Role) -> bool: - """Check if a device has a specific role or is admin.""" - roles = self.get_roles(phone_number) - return Role.ADMIN in roles or role in roles - - def grant_role(self, phone_number: str, role: Role) -> bool: - """Add a role to a device. Called by admin over SSH.""" - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if not device: - logger.warning(f"Cannot grant role for unknown device: {phone_number}") - return False - - if any(record.name == role for record in device.roles): - return True - - role_record = session.scalars(select(RoleRecord).where(RoleRecord.name == role)).one_or_none() - - if not role_record: - logger.warning(f"Unknown role: {role}") - return False - - device.roles.append(role_record) - session.commit() - self._update_cache(phone_number, device) - logger.info(f"Device {phone_number} granted role {role}") - return True - - def revoke_role(self, phone_number: str, role: Role) -> bool: - """Remove a role from a device. Called by admin over SSH.""" - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if not device: - logger.warning(f"Cannot revoke role for unknown device: {phone_number}") - return False - - device.roles = [record for record in device.roles if record.name != role] - session.commit() - self._update_cache(phone_number, device) - logger.info(f"Device {phone_number} revoked role {role}") - return True - - def set_roles(self, phone_number: str, roles: list[Role]) -> bool: - """Replace all roles for a device. Called by admin over SSH.""" - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if not device: - logger.warning(f"Cannot set roles for unknown device: {phone_number}") - return False - - role_names = [str(role) for role in roles] - records = session.scalars(select(RoleRecord).where(RoleRecord.name.in_(role_names))).all() - device.roles = records - session.commit() - self._update_cache(phone_number, device) - logger.info(f"Device {phone_number} roles set to {role_names}") - return True - - # -- queries -------------------------------------------------------------- - - def list_devices(self) -> list[SignalDevice]: - """Return all known devices.""" - with Session(self.engine) as session: - return list(session.scalars(select(SignalDevice)).all()) - - def sync_identities(self) -> None: - """Pull identity list from signal-cli and record any new ones.""" - identities = self.signal_client.get_identities() - for identity in identities: - number = identity.get("number", "") - safety = identity.get("safety_number", identity.get("fingerprint", "")) - if number: - self.record_contact(number, safety) - - # -- internals ------------------------------------------------------------ - - def _cached(self, phone_number: str) -> _CacheEntry | None: - """Return the cache entry if it exists and hasn't expired.""" - entry = self._contact_cache.get(phone_number) - if entry and utcnow() < entry.expires: - return entry - return None - - def _load_device(self, phone_number: str) -> SignalDevice | None: - """Fetch a device by phone number (with joined roles).""" - with Session(self.engine) as session: - return session.scalars(select(SignalDevice).where(SignalDevice.phone_number == phone_number)).one_or_none() - - def _update_cache(self, phone_number: str, device: SignalDevice) -> None: - """Refresh the cache entry for a device.""" - ttl = _BLOCKED_TTL if device.trust_level == TrustLevel.BLOCKED else _DEFAULT_TTL - self._contact_cache[phone_number] = _CacheEntry( - expires=utcnow() + ttl, - trust_level=device.trust_level, - has_safety_number=device.safety_number is not None, - safety_number=device.safety_number, - roles=_extract_roles(device), - ) - - def _set_trust(self, phone_number: str, level: str, log_msg: str | None = None) -> bool: - """Update the trust level for a device.""" - with Session(self.engine) as session: - device = session.scalars( - select(SignalDevice).where(SignalDevice.phone_number == phone_number) - ).one_or_none() - - if not device: - return False - - device.trust_level = level - session.commit() - self._update_cache(phone_number, device) - if log_msg: - logger.info(f"{log_msg}: {phone_number}") - return True - - -def _extract_roles(device: SignalDevice) -> list[Role]: - """Convert a device's RoleRecord objects to a list of Role enums.""" - return [Role(record.name) for record in device.roles] - - -def sync_roles(engine: Engine) -> None: - """Sync the Role enum to the role table, adding new and removing stale entries.""" - expected = {role.value for role in Role} - - with Session(engine) as session: - existing = set(session.scalars(select(RoleRecord.name)).all()) - - to_add = expected - existing - to_remove = existing - expected - - for name in to_add: - session.add(RoleRecord(name=name)) - logger.info(f"Role added: {name}") - - if to_remove: - session.execute(delete(RoleRecord).where(RoleRecord.name.in_(to_remove))) - for name in to_remove: - logger.info(f"Role removed: {name}") - - session.commit() diff --git a/python/signal_bot/llm_client.py b/python/signal_bot/llm_client.py deleted file mode 100644 index a6c9a5f..0000000 --- a/python/signal_bot/llm_client.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Flexible LLM client for ollama backends.""" - -from __future__ import annotations - -import base64 -import logging -from typing import Any, Self - -import httpx - -logger = logging.getLogger(__name__) - - -class LLMClient: - """Talk to an ollama instance. - - Args: - model: Ollama model name. - host: Ollama host. - port: Ollama port. - temperature: Sampling temperature. - """ - - def __init__( - self, - *, - model: str, - host: str, - port: int = 11434, - temperature: float = 0.1, - timeout: int = 300, - ) -> None: - self.model = model - self.temperature = temperature - self._client = httpx.Client(base_url=f"http://{host}:{port}", timeout=timeout) - - def chat(self, prompt: str, image_data: bytes | None = None, system: str | None = None) -> str: - """Send a text prompt and return the response.""" - messages: list[dict[str, Any]] = [] - if system: - messages.append({"role": "system", "content": system}) - - user_msg = {"role": "user", "content": prompt} - if image_data: - user_msg["images"] = [base64.b64encode(image_data).decode()] - - messages.append(user_msg) - return self._generate(messages) - - def _generate(self, messages: list[dict[str, Any]]) -> str: - """Call the ollama chat API.""" - payload = { - "model": self.model, - "messages": messages, - "stream": False, - "options": {"temperature": self.temperature}, - } - logger.info(f"LLM request to {self.model}") - response = self._client.post("/api/chat", json=payload) - response.raise_for_status() - data = response.json() - return data["message"]["content"] - - def list_models(self) -> list[str]: - """List available models on the ollama instance.""" - response = self._client.get("/api/tags") - response.raise_for_status() - return [m["name"] for m in response.json().get("models", [])] - - def __enter__(self) -> Self: - """Enter the context manager.""" - return self - - def __exit__(self, *args: object) -> None: - """Close the HTTP client on exit.""" - self.close() - - def close(self) -> None: - """Close the HTTP client.""" - self._client.close() diff --git a/python/signal_bot/main.py b/python/signal_bot/main.py deleted file mode 100644 index bc847d2..0000000 --- a/python/signal_bot/main.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Signal command and control bot — main entry point.""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from os import getenv -from typing import TYPE_CHECKING, Annotated - -if TYPE_CHECKING: - from collections.abc import Callable - -import typer -from alembic.command import upgrade -from sqlalchemy.orm import Session -from tenacity import before_sleep_log, retry, stop_after_attempt, wait_exponential - -from python.common import configure_logger, utcnow -from python.database_cli import DATABASES -from python.orm.common import get_postgres_engine -from python.orm.signal_bot.models import DeadLetterMessage -from python.signal_bot.commands.inventory import handle_inventory_update -from python.signal_bot.commands.location import handle_location_request -from python.signal_bot.device_registry import DeviceRegistry, sync_roles -from python.signal_bot.llm_client import LLMClient -from python.signal_bot.models import BotConfig, MessageStatus, Role, SignalMessage -from python.signal_bot.signal_client import SignalClient - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class Command: - """A registered bot command.""" - - action: Callable[[SignalMessage, str], None] - help_text: str - role: Role | None # None = no role required (always allowed) - - -class Bot: - """Holds shared resources and dispatches incoming messages to command handlers.""" - - def __init__( - self, - signal: SignalClient, - llm: LLMClient, - registry: DeviceRegistry, - config: BotConfig, - ) -> None: - self.signal = signal - self.llm = llm - self.registry = registry - self.config = config - self.commands: dict[str, Command] = { - "help": Command(action=self._help, help_text="show this help message", role=None), - "status": Command(action=self._status, help_text="show bot status", role=Role.STATUS), - "inventory": Command( - action=self._inventory, - help_text="update van inventory from a text list or receipt photo", - role=Role.INVENTORY, - ), - "location": Command( - action=self._location, - help_text="get current van location", - role=Role.LOCATION, - ), - } - - # -- actions -------------------------------------------------------------- - - def _help(self, message: SignalMessage, _cmd: str) -> None: - """Return help text filtered to the sender's roles.""" - self.signal.reply(message, self._build_help(self.registry.get_roles(message.source))) - - def _status(self, message: SignalMessage, _cmd: str) -> None: - """Return the status of the bot.""" - models = self.llm.list_models() - model_list = ", ".join(models[:10]) - device_count = len(self.registry.list_devices()) - self.signal.reply( - message, - f"Bot online.\nLLM: {self.llm.model}\nAvailable models: {model_list}\nKnown devices: {device_count}", - ) - - def _inventory(self, message: SignalMessage, _cmd: str) -> None: - """Process an inventory update.""" - handle_inventory_update(message, self.signal, self.llm, self.config.inventory_api_url) - - def _location(self, message: SignalMessage, _cmd: str) -> None: - """Reply with current van location.""" - handle_location_request(message, self.signal, self.config.ha_url, self.config.ha_token) - - # -- dispatch ------------------------------------------------------------- - - def _build_help(self, roles: list[Role]) -> str: - """Build help text showing only the commands the user can access.""" - is_admin = Role.ADMIN in roles - lines = ["Available commands:"] - for name, cmd in self.commands.items(): - if cmd.role is None or is_admin or cmd.role in roles: - lines.append(f" {name:20s} — {cmd.help_text}") - return "\n".join(lines) - - def dispatch(self, message: SignalMessage) -> None: - """Route an incoming message to the right command handler.""" - source = message.source - - if not self.registry.is_verified(source): - logger.info(f"Device {source} not verified, ignoring message") - return - - if not self.registry.has_safety_number(source) and self.registry.has_role(source, Role.ADMIN): - logger.warning(f"Admin device {source} missing safety number, ignoring message") - return - - text = message.message.strip() - parts = text.split() - - if not parts and not message.attachments: - return - - cmd = parts[0].lower() if parts else "" - - logger.info(f"f{source=} running {cmd=} with {message=}") - - command = self.commands.get(cmd) - if command is None: - if message.attachments: - command = self.commands["inventory"] - cmd = "inventory" - else: - return - - if command.role is not None and not self.registry.has_role(source, command.role): - logger.warning(f"Device {source} denied access to {cmd!r}") - self.signal.reply(message, f"Permission denied: you do not have the '{command.role}' role.") - return - - command.action(message, cmd) - - def process_message(self, message: SignalMessage) -> None: - """Process a single message, sending it to the dead letter queue after repeated failures.""" - max_attempts = self.config.max_message_attempts - for attempt in range(1, max_attempts + 1): - try: - safety_number = self.signal.get_safety_number(message.source) - self.registry.record_contact(message.source, safety_number) - self.dispatch(message) - except Exception: - logger.exception(f"Failed to process message (attempt {attempt}/{max_attempts})") - else: - return - - logger.error(f"Message from {message.source} failed {max_attempts} times, sending to dead letter queue") - with Session(self.config.engine) as session: - session.add( - DeadLetterMessage( - source=message.source, - message=message.message, - received_at=utcnow(), - status=MessageStatus.UNPROCESSED, - ) - ) - session.commit() - - def run(self) -> None: - """Listen for messages via WebSocket, reconnecting on failure.""" - logger.info("Bot started — listening via WebSocket") - - @retry( - stop=stop_after_attempt(self.config.max_retries), - wait=wait_exponential(multiplier=self.config.reconnect_delay, max=self.config.max_reconnect_delay), - before_sleep=before_sleep_log(logger, logging.WARNING), - reraise=True, - ) - def _listen() -> None: - for message in self.signal.listen(): - logger.info(f"Message from {message.source}: {message.message[:80]}") - self.process_message(message) - - try: - _listen() - except Exception: - logger.critical("Max retries exceeded, shutting down") - raise - - -def main( - log_level: Annotated[str, typer.Option()] = "DEBUG", - llm_timeout: Annotated[int, typer.Option()] = 600, -) -> 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) - - signal_bot_config = DATABASES["signal_bot"].alembic_config() - upgrade(signal_bot_config, "head") - engine = get_postgres_engine(name="SIGNALBOT") - sync_roles(engine) - config = BotConfig( - signal_api_url=signal_api_url, - phone_number=phone_number, - inventory_api_url=inventory_api_url, - ha_url=getenv("HA_URL"), - ha_token=getenv("HA_TOKEN"), - engine=engine, - ) - - 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, timeout=llm_timeout) as llm, - ): - registry = DeviceRegistry(signal, engine) - bot = Bot(signal, llm, registry, config) - bot.run() - - -if __name__ == "__main__": - typer.run(main) diff --git a/python/signal_bot/models.py b/python/signal_bot/models.py deleted file mode 100644 index 7b4c902..0000000 --- a/python/signal_bot/models.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Models for the Signal command and control bot.""" - -from __future__ import annotations - -from datetime import datetime # noqa: TC003 - pydantic needs this at runtime -from enum import StrEnum -from typing import Any - -from pydantic import BaseModel, ConfigDict -from sqlalchemy.engine import Engine # noqa: TC002 - pydantic needs this at runtime - - -class TrustLevel(StrEnum): - """Device trust level.""" - - VERIFIED = "verified" - UNVERIFIED = "unverified" - BLOCKED = "blocked" - - -class Role(StrEnum): - """RBAC roles — one per command, plus admin which grants all.""" - - ADMIN = "admin" - STATUS = "status" - INVENTORY = "inventory" - LOCATION = "location" - - -class MessageStatus(StrEnum): - """Dead letter queue message status.""" - - UNPROCESSED = "unprocessed" - PROCESSED = "processed" - - -class Device(BaseModel): - """A registered device tracked by safety number.""" - - phone_number: str - safety_number: str - trust_level: TrustLevel = TrustLevel.UNVERIFIED - first_seen: datetime - last_seen: datetime - - -class SignalMessage(BaseModel): - """An incoming Signal message.""" - - source: str - timestamp: int - message: str = "" - attachments: list[str] = [] - group_id: str | None = None - is_receipt: bool = False - - -class SignalEnvelope(BaseModel): - """Raw envelope from signal-cli-rest-api.""" - - envelope: dict[str, Any] - account: str | None = None - - -class InventoryItem(BaseModel): - """An item in the van inventory.""" - - name: str - quantity: float = 1 - unit: str = "each" - category: str = "" - notes: str = "" - - -class InventoryUpdate(BaseModel): - """Result of processing an inventory update.""" - - items: list[InventoryItem] = [] - raw_response: str = "" - source_type: str = "" # "receipt_photo" or "text_list" - - -class BotConfig(BaseModel): - """Top-level bot configuration.""" - - model_config = ConfigDict(arbitrary_types_allowed=True) - - signal_api_url: str - phone_number: str - inventory_api_url: str - ha_url: str | None = None - ha_token: str | None = None - engine: Engine - reconnect_delay: int = 5 - max_reconnect_delay: int = 300 - max_retries: int = 10 - max_message_attempts: int = 3 diff --git a/python/signal_bot/signal_client.py b/python/signal_bot/signal_client.py deleted file mode 100644 index 0e2eaab..0000000 --- a/python/signal_bot/signal_client.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Client for the signal-cli-rest-api.""" - -from __future__ import annotations - -import json -import logging -from typing import TYPE_CHECKING, Any, Self - -import httpx -import websockets.sync.client - -if TYPE_CHECKING: - from collections.abc import Generator - -from python.signal_bot.models import SignalMessage - -logger = logging.getLogger(__name__) - - -def _parse_envelope(envelope: dict[str, Any]) -> SignalMessage | None: - """Parse a signal-cli envelope into a SignalMessage, or None if not a data message.""" - data_message = envelope.get("dataMessage") - if not data_message: - return None - - attachment_ids = [att["id"] for att in data_message.get("attachments", []) if "id" in att] - - group_info = data_message.get("groupInfo") - group_id = group_info.get("groupId") if group_info else None - - return SignalMessage( - source=envelope.get("source", ""), - timestamp=envelope.get("timestamp", 0), - message=data_message.get("message", "") or "", - attachments=attachment_ids, - group_id=group_id, - ) - - -class SignalClient: - """Communicate with signal-cli-rest-api. - - Args: - base_url: URL of the signal-cli-rest-api (e.g. http://localhost:8989). - phone_number: The registered phone number to send/receive as. - """ - - def __init__(self, base_url: str, phone_number: str) -> None: - self.base_url = base_url.rstrip("/") - self.phone_number = phone_number - self._client = httpx.Client(base_url=self.base_url, timeout=30) - - def _ws_url(self) -> str: - """Build the WebSocket URL from the base HTTP URL.""" - url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") - return f"{url}/v1/receive/{self.phone_number}" - - def listen(self) -> Generator[SignalMessage]: - """Connect via WebSocket and yield messages as they arrive.""" - ws_url = self._ws_url() - logger.info(f"Connecting to WebSocket: {ws_url}") - - with websockets.sync.client.connect(ws_url) as ws: - for raw in ws: - try: - data = json.loads(raw) - envelope = data.get("envelope", {}) - message = _parse_envelope(envelope) - if message: - yield message - except json.JSONDecodeError: - logger.warning(f"Non-JSON WebSocket frame: {raw[:200]}") - - def send(self, recipient: str, message: str) -> None: - """Send a text message.""" - payload = { - "message": message, - "number": self.phone_number, - "recipients": [recipient], - } - response = self._client.post("/v2/send", json=payload) - response.raise_for_status() - - def send_to_group(self, group_id: str, message: str) -> None: - """Send a message to a group.""" - payload = { - "message": message, - "number": self.phone_number, - "recipients": [group_id], - } - response = self._client.post("/v2/send", json=payload) - response.raise_for_status() - - def get_attachment(self, attachment_id: str) -> bytes: - """Download an attachment by ID.""" - response = self._client.get(f"/v1/attachments/{attachment_id}") - response.raise_for_status() - return response.content - - def get_identities(self) -> list[dict[str, Any]]: - """List known identities and their trust levels.""" - response = self._client.get(f"/v1/identities/{self.phone_number}") - response.raise_for_status() - return response.json() - - def get_safety_number(self, phone_number: str) -> str | None: - """Look up the safety number for a contact from signal-cli's local store.""" - for identity in self.get_identities(): - if identity.get("number") == phone_number: - return identity.get("safety_number", identity.get("fingerprint", "")) - return None - - def trust_identity(self, number_to_trust: str, *, trust_all_known_keys: bool = False) -> None: - """Trust an identity (verify safety number).""" - payload: dict[str, Any] = {} - if trust_all_known_keys: - payload["trust_all_known_keys"] = True - response = self._client.put( - f"/v1/identities/{self.phone_number}/trust/{number_to_trust}", - json=payload, - ) - response.raise_for_status() - - def reply(self, message: SignalMessage, text: str) -> None: - """Reply to a message, routing to group or individual.""" - if message.group_id: - self.send_to_group(message.group_id, text) - else: - self.send(message.source, text) - - def __enter__(self) -> Self: - """Enter the context manager.""" - return self - - def __exit__(self, *args: object) -> None: - """Close the HTTP client on exit.""" - self.close() - - def close(self) -> None: - """Close the HTTP client.""" - self._client.close() diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index f626133..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Shared test fixtures.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest -from sqlalchemy import create_engine, event - -from python.orm.signal_bot.base import SignalBotBase - -if TYPE_CHECKING: - from collections.abc import Generator - - from sqlalchemy.engine import Engine - - -@pytest.fixture(scope="session") -def sqlite_engine() -> Generator[Engine]: - """Create an in-memory SQLite engine for testing.""" - engine = create_engine("sqlite:///:memory:") - - @event.listens_for(engine, "connect") - def _set_sqlite_pragma(dbapi_connection, _connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() - - SignalBotBase.metadata.create_all(engine) - yield engine - engine.dispose() - - -@pytest.fixture -def engine(sqlite_engine: Engine) -> Generator[Engine]: - """Yield the shared engine after cleaning all tables between tests.""" - yield sqlite_engine - with sqlite_engine.begin() as connection: - for table in reversed(SignalBotBase.metadata.sorted_tables): - connection.execute(table.delete()) diff --git a/tests/test_signal_bot.py b/tests/test_signal_bot.py deleted file mode 100644 index 9a519dc..0000000 --- a/tests/test_signal_bot.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Tests for the Signal command and control bot.""" - -from __future__ import annotations - -import json -from datetime import timedelta -from unittest.mock import MagicMock, patch - -import pytest - -from python.signal_bot.commands.inventory import ( - _format_summary, - parse_llm_response, -) -from python.signal_bot.commands.location import _format_location, handle_location_request -from python.signal_bot.device_registry import _BLOCKED_TTL, _DEFAULT_TTL, DeviceRegistry, _CacheEntry -from python.signal_bot.llm_client import LLMClient -from python.signal_bot.main import Bot -from python.signal_bot.models import ( - BotConfig, - InventoryItem, - SignalMessage, - TrustLevel, -) -from python.signal_bot.signal_client import SignalClient - - -class TestModels: - def test_trust_level_values(self): - assert TrustLevel.VERIFIED == "verified" - assert TrustLevel.UNVERIFIED == "unverified" - assert TrustLevel.BLOCKED == "blocked" - - def test_signal_message_defaults(self): - msg = SignalMessage(source="+1234", timestamp=0) - assert msg.message == "" - assert msg.attachments == [] - assert msg.group_id is None - - def test_inventory_item_defaults(self): - item = InventoryItem(name="wrench") - assert item.quantity == 1 - assert item.unit == "each" - assert item.category == "" - - -class TestInventoryParsing: - def test_parse_llm_response_basic(self): - raw = '[{"name": "water", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": ""}]' - items = parse_llm_response(raw) - assert len(items) == 1 - assert items[0].name == "water" - assert items[0].quantity == 6 - assert items[0].unit == "gallon" - - def test_parse_llm_response_with_code_fence(self): - raw = '```json\n[{"name": "tape", "quantity": 1, "unit": "each", "category": "tools", "notes": ""}]\n```' - items = parse_llm_response(raw) - assert len(items) == 1 - assert items[0].name == "tape" - - def test_parse_llm_response_invalid_json(self): - with pytest.raises(json.JSONDecodeError): - parse_llm_response("not json at all") - - def test_format_summary(self): - items = [InventoryItem(name="water", quantity=6, unit="gallon", category="supplies")] - summary = _format_summary(items) - assert "water" in summary - assert "x6" in summary - assert "gallon" in summary - - -class TestDeviceRegistry: - @pytest.fixture - def signal_mock(self): - return MagicMock(spec=SignalClient) - - @pytest.fixture - def registry(self, signal_mock, engine): - return DeviceRegistry(signal_mock, engine) - - def test_new_device_is_unverified(self, registry): - registry.record_contact("+1234", "abc123") - assert not registry.is_verified("+1234") - - def test_verify_device(self, registry): - registry.record_contact("+1234", "abc123") - assert registry.verify("+1234") - assert registry.is_verified("+1234") - - def test_verify_unknown_device(self, registry): - assert not registry.verify("+9999") - - def test_block_device(self, registry): - registry.record_contact("+1234", "abc123") - assert registry.block("+1234") - assert not registry.is_verified("+1234") - - def test_safety_number_change_resets_trust(self, registry): - registry.record_contact("+1234", "abc123") - registry.verify("+1234") - assert registry.is_verified("+1234") - registry.record_contact("+1234", "different_safety_number") - assert not registry.is_verified("+1234") - - def test_persistence(self, signal_mock, engine): - reg1 = DeviceRegistry(signal_mock, engine) - reg1.record_contact("+1234", "abc123") - reg1.verify("+1234") - - reg2 = DeviceRegistry(signal_mock, engine) - assert reg2.is_verified("+1234") - - def test_list_devices(self, registry): - registry.record_contact("+1234", "abc") - registry.record_contact("+5678", "def") - assert len(registry.list_devices()) == 2 - - -class TestContactCache: - @pytest.fixture - def signal_mock(self): - return MagicMock(spec=SignalClient) - - @pytest.fixture - def registry(self, signal_mock, engine): - return DeviceRegistry(signal_mock, engine) - - def test_second_call_uses_cache(self, registry): - registry.record_contact("+1234", "abc") - assert "+1234" in registry._contact_cache - - with patch.object(registry, "engine") as mock_engine: - registry.record_contact("+1234", "abc") - mock_engine.assert_not_called() - - def test_unverified_gets_default_ttl(self, registry): - registry.record_contact("+1234", "abc") - from python.common import utcnow - - entry = registry._contact_cache["+1234"] - expected = utcnow() + _DEFAULT_TTL - assert abs((entry.expires - expected).total_seconds()) < 2 - assert entry.trust_level == TrustLevel.UNVERIFIED - assert entry.has_safety_number is True - - def test_blocked_gets_blocked_ttl(self, registry): - registry.record_contact("+1234", "abc") - registry.block("+1234") - from python.common import utcnow - - entry = registry._contact_cache["+1234"] - expected = utcnow() + _BLOCKED_TTL - assert abs((entry.expires - expected).total_seconds()) < 2 - assert entry.trust_level == TrustLevel.BLOCKED - - def test_verify_updates_cache(self, registry): - registry.record_contact("+1234", "abc") - registry.verify("+1234") - entry = registry._contact_cache["+1234"] - assert entry.trust_level == TrustLevel.VERIFIED - - def test_block_updates_cache(self, registry): - registry.record_contact("+1234", "abc") - registry.block("+1234") - entry = registry._contact_cache["+1234"] - assert entry.trust_level == TrustLevel.BLOCKED - - def test_unverify_updates_cache(self, registry): - registry.record_contact("+1234", "abc") - registry.verify("+1234") - registry.unverify("+1234") - entry = registry._contact_cache["+1234"] - assert entry.trust_level == TrustLevel.UNVERIFIED - - def test_is_verified_uses_cache(self, registry): - registry.record_contact("+1234", "abc") - registry.verify("+1234") - with patch.object(registry, "engine") as mock_engine: - assert registry.is_verified("+1234") is True - mock_engine.assert_not_called() - - def test_has_safety_number_uses_cache(self, registry): - registry.record_contact("+1234", "abc") - with patch.object(registry, "engine") as mock_engine: - assert registry.has_safety_number("+1234") is True - mock_engine.assert_not_called() - - def test_no_safety_number_cached(self, registry): - registry.record_contact("+1234", None) - with patch.object(registry, "engine") as mock_engine: - assert registry.has_safety_number("+1234") is False - mock_engine.assert_not_called() - - def test_expired_cache_hits_db(self, registry): - registry.record_contact("+1234", "abc") - old = registry._contact_cache["+1234"] - registry._contact_cache["+1234"] = _CacheEntry( - expires=old.expires - timedelta(minutes=10), - trust_level=old.trust_level, - has_safety_number=old.has_safety_number, - safety_number=old.safety_number, - roles=old.roles, - ) - - with patch("python.signal_bot.device_registry.Session") as mock_session_cls: - mock_session = MagicMock() - mock_session_cls.return_value.__enter__ = MagicMock(return_value=mock_session) - mock_session_cls.return_value.__exit__ = MagicMock(return_value=False) - mock_device = MagicMock() - mock_device.trust_level = TrustLevel.UNVERIFIED - mock_session.scalars.return_value.one_or_none.return_value = mock_device - registry.record_contact("+1234", "abc") - mock_session.scalars.assert_called_once() - - -class TestLocationCommand: - def test_format_location(self): - response = _format_location("12.34", "56.78") - assert "12.34, 56.78" in response - assert "maps.google.com" in response - - def test_handle_location_request_without_config(self): - signal = MagicMock(spec=SignalClient) - message = SignalMessage(source="+1234", timestamp=0, message="location") - handle_location_request(message, signal, None, None) - signal.reply.assert_called_once() - assert "not configured" in signal.reply.call_args[0][1] - - -class TestDispatch: - @pytest.fixture - def signal_mock(self): - return MagicMock(spec=SignalClient) - - @pytest.fixture - def llm_mock(self): - return MagicMock(spec=LLMClient) - - @pytest.fixture - def registry_mock(self): - mock = MagicMock(spec=DeviceRegistry) - mock.is_verified.return_value = True - mock.has_safety_number.return_value = True - mock.has_role.return_value = False - mock.get_roles.return_value = [] - return mock - - @pytest.fixture - def config(self, engine): - return BotConfig( - signal_api_url="http://localhost:8080", - phone_number="+1234567890", - inventory_api_url="http://localhost:9090", - engine=engine, - ) - - @pytest.fixture - def bot(self, signal_mock, llm_mock, registry_mock, config): - return Bot(signal_mock, llm_mock, registry_mock, config) - - def test_unverified_device_ignored(self, bot, signal_mock, registry_mock): - registry_mock.is_verified.return_value = False - msg = SignalMessage(source="+1234", timestamp=0, message="help") - bot.dispatch(msg) - signal_mock.reply.assert_not_called() - - def test_admin_without_safety_number_ignored(self, bot, signal_mock, registry_mock): - registry_mock.has_safety_number.return_value = False - registry_mock.has_role.return_value = True - msg = SignalMessage(source="+1234", timestamp=0, message="help") - bot.dispatch(msg) - signal_mock.reply.assert_not_called() - - def test_non_admin_without_safety_number_allowed(self, bot, signal_mock, registry_mock): - registry_mock.has_safety_number.return_value = False - registry_mock.has_role.return_value = False - registry_mock.get_roles.return_value = [] - msg = SignalMessage(source="+1234", timestamp=0, message="help") - bot.dispatch(msg) - signal_mock.reply.assert_called_once() - - def test_help_command(self, bot, signal_mock, registry_mock): - msg = SignalMessage(source="+1234", timestamp=0, message="help") - bot.dispatch(msg) - signal_mock.reply.assert_called_once() - assert "Available commands" in signal_mock.reply.call_args[0][1] - - def test_unknown_command_ignored(self, bot, signal_mock): - msg = SignalMessage(source="+1234", timestamp=0, message="foobar") - bot.dispatch(msg) - signal_mock.reply.assert_not_called() - - def test_non_command_message_ignored(self, bot, signal_mock): - msg = SignalMessage(source="+1234", timestamp=0, message="hello there") - bot.dispatch(msg) - signal_mock.reply.assert_not_called() - - def test_status_command(self, bot, signal_mock, llm_mock, registry_mock): - llm_mock.list_models.return_value = ["model1", "model2"] - llm_mock.model = "test:7b" - registry_mock.list_devices.return_value = [] - registry_mock.has_role.return_value = True - msg = SignalMessage(source="+1234", timestamp=0, message="status") - bot.dispatch(msg) - signal_mock.reply.assert_called_once() - assert "Bot online" in signal_mock.reply.call_args[0][1] - - def test_location_command(self, bot, signal_mock, registry_mock, config): - registry_mock.has_role.return_value = True - msg = SignalMessage(source="+1234", timestamp=0, message="location") - with patch("python.signal_bot.main.handle_location_request") as mock_location: - bot.dispatch(msg) - - mock_location.assert_called_once_with( - msg, - signal_mock, - config.ha_url, - config.ha_token, - )