deleting signal bot
This commit is contained in:
@@ -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"
|
||||
|
||||
-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",
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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