deleting signal bot
This commit is contained in:
@@ -84,9 +84,6 @@ lint.ignore = [
|
|||||||
"python/alembic/**" = [
|
"python/alembic/**" = [
|
||||||
"INP001", # (perm) this creates LSP issues for alembic
|
"INP001", # (perm) this creates LSP issues for alembic
|
||||||
]
|
]
|
||||||
"python/signal_bot/**" = [
|
|
||||||
"D107", # (perm) class docstrings cover __init__
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint.pydocstyle]
|
[tool.ruff.lint.pydocstyle]
|
||||||
convention = "google"
|
convention = "google"
|
||||||
|
|||||||
-100
@@ -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 ###
|
|
||||||
@@ -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 ###
|
|
||||||
@@ -83,13 +83,6 @@ DATABASES: dict[str, DatabaseConfig] = {
|
|||||||
base_class_name="VanInventoryBase",
|
base_class_name="VanInventoryBase",
|
||||||
models_module="python.orm.van_inventory.models",
|
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(
|
"data_science_dev": DatabaseConfig(
|
||||||
env_prefix="DATA_SCIENCE_DEV",
|
env_prefix="DATA_SCIENCE_DEV",
|
||||||
version_location="python/alembic/data_science_dev/versions",
|
version_location="python/alembic/data_science_dev/versions",
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
from python.orm.data_science_dev.base import DataScienceDevBase
|
from python.orm.data_science_dev.base import DataScienceDevBase
|
||||||
from python.orm.richie.base import RichieBase
|
from python.orm.richie.base import RichieBase
|
||||||
from python.orm.signal_bot.base import SignalBotBase
|
|
||||||
from python.orm.van_inventory.base import VanInventoryBase
|
from python.orm.van_inventory.base import VanInventoryBase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DataScienceDevBase",
|
"DataScienceDevBase",
|
||||||
"RichieBase",
|
"RichieBase",
|
||||||
"SignalBotBase",
|
|
||||||
"VanInventoryBase",
|
"VanInventoryBase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Signal command and control bot."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Signal bot commands."""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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))
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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())
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user