mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
updated the van inventory to use the api
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""Van inventory command — parse receipts and item lists via LLM."""
|
"""Van inventory command — parse receipts and item lists via LLM, push to API."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,11 +6,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from python.signal_bot.models import InventoryItem, InventoryUpdate
|
from python.signal_bot.models import InventoryItem, InventoryUpdate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from python.signal_bot.llm_client import LLMClient
|
from python.signal_bot.llm_client import LLMClient
|
||||||
from python.signal_bot.models import SignalMessage
|
from python.signal_bot.models import SignalMessage
|
||||||
from python.signal_bot.signal_client import SignalClient
|
from python.signal_bot.signal_client import SignalClient
|
||||||
@@ -21,12 +21,13 @@ SYSTEM_PROMPT = """\
|
|||||||
You are an inventory assistant. Extract items from the input and return ONLY
|
You are an inventory assistant. Extract items from the input and return ONLY
|
||||||
a JSON array. Each element must have these fields:
|
a JSON array. Each element must have these fields:
|
||||||
- "name": item name (string)
|
- "name": item name (string)
|
||||||
- "quantity": integer count (default 1)
|
- "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.
|
- "category": category like "food", "tools", "supplies", etc.
|
||||||
- "notes": any extra detail (empty string if none)
|
- "notes": any extra detail (empty string if none)
|
||||||
|
|
||||||
Example output:
|
Example output:
|
||||||
[{"name": "water bottles", "quantity": 6, "category": "supplies", "notes": "1 gallon each"}]
|
[{"name": "water bottles", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": "1 gallon each"}]
|
||||||
|
|
||||||
Return ONLY the JSON array, no other text.\
|
Return ONLY the JSON array, no other text.\
|
||||||
"""
|
"""
|
||||||
@@ -48,31 +49,47 @@ def parse_llm_response(raw: str) -> list[InventoryItem]:
|
|||||||
return [InventoryItem.model_validate(item) for item in items_data]
|
return [InventoryItem.model_validate(item) for item in items_data]
|
||||||
|
|
||||||
|
|
||||||
def load_inventory(path: Path) -> list[InventoryItem]:
|
def _upsert_item(api_url: str, item: InventoryItem) -> None:
|
||||||
"""Load existing inventory from disk."""
|
"""Create or update an item via the van_inventory API.
|
||||||
if not path.exists():
|
|
||||||
return []
|
|
||||||
data: list[dict[str, Any]] = json.loads(path.read_text())
|
|
||||||
return [InventoryItem.model_validate(item) for item in data]
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
def save_inventory(path: Path, items: list[InventoryItem]) -> None:
|
match = next((e for e in existing if e["name"].lower() == item.name.lower()), None)
|
||||||
"""Save inventory to disk."""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
if match:
|
||||||
data = [item.model_dump() for item in items]
|
new_qty = match["quantity"] + item.quantity
|
||||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
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(
|
def handle_inventory_update(
|
||||||
message: SignalMessage,
|
message: SignalMessage,
|
||||||
signal: SignalClient,
|
signal: SignalClient,
|
||||||
llm: LLMClient,
|
llm: LLMClient,
|
||||||
inventory_path: Path,
|
api_url: str,
|
||||||
) -> InventoryUpdate:
|
) -> InventoryUpdate:
|
||||||
"""Process an inventory update from a Signal message.
|
"""Process an inventory update from a Signal message.
|
||||||
|
|
||||||
Accepts either an image (receipt photo) or text list.
|
Accepts either an image (receipt photo) or text list.
|
||||||
Uses the LLM to extract structured items, then merges into inventory.
|
Uses the LLM to extract structured items, then pushes to the van_inventory API.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if message.attachments:
|
if message.attachments:
|
||||||
@@ -94,9 +111,9 @@ def handle_inventory_update(
|
|||||||
return InventoryUpdate()
|
return InventoryUpdate()
|
||||||
|
|
||||||
new_items = parse_llm_response(raw_response)
|
new_items = parse_llm_response(raw_response)
|
||||||
existing = load_inventory(inventory_path)
|
|
||||||
merged = _merge_items(existing, new_items)
|
for item in new_items:
|
||||||
save_inventory(inventory_path, merged)
|
_upsert_item(api_url, item)
|
||||||
|
|
||||||
summary = _format_summary(new_items)
|
summary = _format_summary(new_items)
|
||||||
signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}")
|
signal.reply(message, f"Inventory updated with {len(new_items)} item(s):\n{summary}")
|
||||||
@@ -109,26 +126,7 @@ def handle_inventory_update(
|
|||||||
return InventoryUpdate()
|
return InventoryUpdate()
|
||||||
|
|
||||||
|
|
||||||
def _merge_items(existing: list[InventoryItem], new: list[InventoryItem]) -> list[InventoryItem]:
|
|
||||||
"""Merge new items into existing inventory, summing quantities for matches."""
|
|
||||||
by_name: dict[str, InventoryItem] = {item.name.lower(): item for item in existing}
|
|
||||||
for item in new:
|
|
||||||
key = item.name.lower()
|
|
||||||
if key in by_name:
|
|
||||||
current = by_name[key]
|
|
||||||
by_name[key] = current.model_copy(
|
|
||||||
update={
|
|
||||||
"quantity": current.quantity + item.quantity,
|
|
||||||
"category": item.category or current.category,
|
|
||||||
"notes": item.notes or current.notes,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
by_name[key] = item
|
|
||||||
return list(by_name.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _format_summary(items: list[InventoryItem]) -> str:
|
def _format_summary(items: list[InventoryItem]) -> str:
|
||||||
"""Format items into a readable summary."""
|
"""Format items into a readable summary."""
|
||||||
lines = [f" - {item.name} x{item.quantity} [{item.category}]" for item in items]
|
lines = [f" - {item.name} x{item.quantity} {item.unit} [{item.category}]" for item in items]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from os import getenv
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@@ -35,9 +35,9 @@ def help_action(
|
|||||||
message: SignalMessage,
|
message: SignalMessage,
|
||||||
_llm: LLMClient,
|
_llm: LLMClient,
|
||||||
_registry: DeviceRegistry,
|
_registry: DeviceRegistry,
|
||||||
_inventory_path: Path,
|
_config: BotConfig,
|
||||||
_cmd: str,
|
_cmd: str,
|
||||||
) -> str:
|
) -> None:
|
||||||
"""Return the help text for the bot."""
|
"""Return the help text for the bot."""
|
||||||
signal.reply(message, HELP_TEXT)
|
signal.reply(message, HELP_TEXT)
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ def status_action(
|
|||||||
message: SignalMessage,
|
message: SignalMessage,
|
||||||
llm: LLMClient,
|
llm: LLMClient,
|
||||||
registry: DeviceRegistry,
|
registry: DeviceRegistry,
|
||||||
_inventory_path: Path,
|
_config: BotConfig,
|
||||||
_cmd: str,
|
_cmd: str,
|
||||||
) -> str:
|
) -> None:
|
||||||
"""Return the status of the bot."""
|
"""Return the status of the bot."""
|
||||||
models = llm.list_models()
|
models = llm.list_models()
|
||||||
model_list = ", ".join(models[:10])
|
model_list = ", ".join(models[:10])
|
||||||
@@ -65,9 +65,9 @@ def unknown_action(
|
|||||||
message: SignalMessage,
|
message: SignalMessage,
|
||||||
_llm: LLMClient,
|
_llm: LLMClient,
|
||||||
_registry: DeviceRegistry,
|
_registry: DeviceRegistry,
|
||||||
_inventory_path: Path,
|
_config: BotConfig,
|
||||||
cmd: str,
|
cmd: str,
|
||||||
) -> str:
|
) -> None:
|
||||||
"""Return an error message for an unknown command."""
|
"""Return an error message for an unknown command."""
|
||||||
signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}")
|
signal.reply(message, f"Unknown command: {cmd}\n\n{HELP_TEXT}")
|
||||||
|
|
||||||
@@ -77,11 +77,11 @@ def inventory_action(
|
|||||||
message: SignalMessage,
|
message: SignalMessage,
|
||||||
llm: LLMClient,
|
llm: LLMClient,
|
||||||
_registry: DeviceRegistry,
|
_registry: DeviceRegistry,
|
||||||
inventory_path: Path,
|
config: BotConfig,
|
||||||
_cmd: str,
|
_cmd: str,
|
||||||
) -> str:
|
) -> None:
|
||||||
"""Process an inventory update."""
|
"""Process an inventory update."""
|
||||||
handle_inventory_update(message, signal, llm, inventory_path)
|
handle_inventory_update(message, signal, llm, config.inventory_api_url)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(
|
def dispatch(
|
||||||
@@ -89,7 +89,6 @@ def dispatch(
|
|||||||
signal: SignalClient,
|
signal: SignalClient,
|
||||||
llm: LLMClient,
|
llm: LLMClient,
|
||||||
registry: DeviceRegistry,
|
registry: DeviceRegistry,
|
||||||
inventory_path: Path,
|
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Route an incoming message to the right command handler."""
|
"""Route an incoming message to the right command handler."""
|
||||||
@@ -104,7 +103,6 @@ def dispatch(
|
|||||||
prefix = config.cmd_prefix
|
prefix = config.cmd_prefix
|
||||||
if not text.startswith(prefix) and not message.attachments:
|
if not text.startswith(prefix) and not message.attachments:
|
||||||
return
|
return
|
||||||
text.startswith(prefix)
|
|
||||||
cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else ""
|
cmd = text.lstrip(prefix).split()[0].lower() if text.startswith(prefix) else ""
|
||||||
|
|
||||||
commands = {
|
commands = {
|
||||||
@@ -114,7 +112,7 @@ def dispatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
action = commands.get(cmd, unknown_action)
|
action = commands.get(cmd, unknown_action)
|
||||||
action(signal, message, llm, registry, inventory_path, cmd)
|
action(signal, message, llm, registry, config, cmd)
|
||||||
|
|
||||||
|
|
||||||
def run_loop(
|
def run_loop(
|
||||||
@@ -124,7 +122,6 @@ def run_loop(
|
|||||||
registry: DeviceRegistry,
|
registry: DeviceRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Listen for messages via WebSocket, reconnecting on failure."""
|
"""Listen for messages via WebSocket, reconnecting on failure."""
|
||||||
inventory_path = Path(config.inventory_file)
|
|
||||||
logger.info("Bot started — listening via WebSocket")
|
logger.info("Bot started — listening via WebSocket")
|
||||||
|
|
||||||
retries = 0
|
retries = 0
|
||||||
@@ -136,7 +133,7 @@ def run_loop(
|
|||||||
logger.info(f"Message from {message.source}: {message.message[:80]}")
|
logger.info(f"Message from {message.source}: {message.message[:80]}")
|
||||||
safety_number = signal.get_safety_number(message.source)
|
safety_number = signal.get_safety_number(message.source)
|
||||||
registry.record_contact(message.source, safety_number)
|
registry.record_contact(message.source, safety_number)
|
||||||
dispatch(message, signal, llm, registry, inventory_path, config)
|
dispatch(message, signal, llm, registry, config)
|
||||||
retries = 0
|
retries = 0
|
||||||
delay = config.reconnect_delay
|
delay = config.reconnect_delay
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -148,25 +145,36 @@ def run_loop(
|
|||||||
logger.critical("Max retries exceeded, shutting down")
|
logger.critical("Max retries exceeded, shutting down")
|
||||||
|
|
||||||
|
|
||||||
def main(
|
def main(log_level: Annotated[str, typer.Option()] = "INFO") -> None:
|
||||||
signal_api_url: Annotated[str, typer.Option(envvar="SIGNAL_API_URL")],
|
|
||||||
phone_number: Annotated[str, typer.Option(envvar="SIGNAL_PHONE_NUMBER")],
|
|
||||||
llm_host: Annotated[str, typer.Option(envvar="LLM_HOST")],
|
|
||||||
llm_model: Annotated[str, typer.Option(envvar="LLM_MODEL")] = "qwen3-vl:32b",
|
|
||||||
llm_port: Annotated[int, typer.Option(envvar="LLM_PORT")] = 11434,
|
|
||||||
inventory_file: Annotated[str, typer.Option(envvar="INVENTORY_FILE")] = "/var/lib/signal-bot/van_inventory.json",
|
|
||||||
log_level: Annotated[str, typer.Option()] = "INFO",
|
|
||||||
) -> None:
|
|
||||||
"""Run the Signal command and control bot."""
|
"""Run the Signal command and control bot."""
|
||||||
configure_logger(log_level)
|
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)
|
||||||
|
|
||||||
config = BotConfig(
|
config = BotConfig(
|
||||||
signal_api_url=signal_api_url,
|
signal_api_url=signal_api_url,
|
||||||
phone_number=phone_number,
|
phone_number=phone_number,
|
||||||
inventory_file=inventory_file,
|
inventory_api_url=inventory_api_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
engine = get_postgres_engine(name="RICHIE")
|
engine = get_postgres_engine(name="RICHIE")
|
||||||
|
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 (
|
with (
|
||||||
SignalClient(config.signal_api_url, config.phone_number) as signal,
|
SignalClient(config.signal_api_url, config.phone_number) as signal,
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class InventoryItem(BaseModel):
|
|||||||
"""An item in the van inventory."""
|
"""An item in the van inventory."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
quantity: int = 1
|
quantity: float = 1
|
||||||
|
unit: str = "each"
|
||||||
category: str = ""
|
category: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ class BotConfig(BaseModel):
|
|||||||
|
|
||||||
signal_api_url: str
|
signal_api_url: str
|
||||||
phone_number: str
|
phone_number: str
|
||||||
inventory_file: str = "van_inventory.json"
|
inventory_api_url: str
|
||||||
cmd_prefix: str = "!"
|
cmd_prefix: str = "!"
|
||||||
reconnect_delay: int = 5
|
reconnect_delay: int = 5
|
||||||
max_reconnect_delay: int = 300
|
max_reconnect_delay: int = 300
|
||||||
|
|||||||
@@ -9,15 +9,13 @@ import pytest
|
|||||||
|
|
||||||
from python.signal_bot.commands.inventory import (
|
from python.signal_bot.commands.inventory import (
|
||||||
_format_summary,
|
_format_summary,
|
||||||
_merge_items,
|
|
||||||
load_inventory,
|
|
||||||
parse_llm_response,
|
parse_llm_response,
|
||||||
save_inventory,
|
|
||||||
)
|
)
|
||||||
from python.signal_bot.device_registry import DeviceRegistry
|
from python.signal_bot.device_registry import DeviceRegistry
|
||||||
from python.signal_bot.llm_client import LLMClient
|
from python.signal_bot.llm_client import LLMClient
|
||||||
from python.signal_bot.main import dispatch
|
from python.signal_bot.main import dispatch
|
||||||
from python.signal_bot.models import (
|
from python.signal_bot.models import (
|
||||||
|
BotConfig,
|
||||||
InventoryItem,
|
InventoryItem,
|
||||||
SignalMessage,
|
SignalMessage,
|
||||||
TrustLevel,
|
TrustLevel,
|
||||||
@@ -40,19 +38,21 @@ class TestModels:
|
|||||||
def test_inventory_item_defaults(self):
|
def test_inventory_item_defaults(self):
|
||||||
item = InventoryItem(name="wrench")
|
item = InventoryItem(name="wrench")
|
||||||
assert item.quantity == 1
|
assert item.quantity == 1
|
||||||
|
assert item.unit == "each"
|
||||||
assert item.category == ""
|
assert item.category == ""
|
||||||
|
|
||||||
|
|
||||||
class TestInventoryParsing:
|
class TestInventoryParsing:
|
||||||
def test_parse_llm_response_basic(self):
|
def test_parse_llm_response_basic(self):
|
||||||
raw = '[{"name": "water", "quantity": 6, "category": "supplies", "notes": ""}]'
|
raw = '[{"name": "water", "quantity": 6, "unit": "gallon", "category": "supplies", "notes": ""}]'
|
||||||
items = parse_llm_response(raw)
|
items = parse_llm_response(raw)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0].name == "water"
|
assert items[0].name == "water"
|
||||||
assert items[0].quantity == 6
|
assert items[0].quantity == 6
|
||||||
|
assert items[0].unit == "gallon"
|
||||||
|
|
||||||
def test_parse_llm_response_with_code_fence(self):
|
def test_parse_llm_response_with_code_fence(self):
|
||||||
raw = '```json\n[{"name": "tape", "quantity": 1, "category": "tools", "notes": ""}]\n```'
|
raw = '```json\n[{"name": "tape", "quantity": 1, "unit": "each", "category": "tools", "notes": ""}]\n```'
|
||||||
items = parse_llm_response(raw)
|
items = parse_llm_response(raw)
|
||||||
assert len(items) == 1
|
assert len(items) == 1
|
||||||
assert items[0].name == "tape"
|
assert items[0].name == "tape"
|
||||||
@@ -61,43 +61,12 @@ class TestInventoryParsing:
|
|||||||
with pytest.raises(json.JSONDecodeError):
|
with pytest.raises(json.JSONDecodeError):
|
||||||
parse_llm_response("not json at all")
|
parse_llm_response("not json at all")
|
||||||
|
|
||||||
def test_merge_items_new(self):
|
|
||||||
existing = [InventoryItem(name="tape", quantity=2, category="tools")]
|
|
||||||
new = [InventoryItem(name="rope", quantity=1, category="supplies")]
|
|
||||||
merged = _merge_items(existing, new)
|
|
||||||
assert len(merged) == 2
|
|
||||||
|
|
||||||
def test_merge_items_duplicate_sums_quantity(self):
|
|
||||||
existing = [InventoryItem(name="tape", quantity=2, category="tools")]
|
|
||||||
new = [InventoryItem(name="tape", quantity=3, category="tools")]
|
|
||||||
merged = _merge_items(existing, new)
|
|
||||||
assert len(merged) == 1
|
|
||||||
assert merged[0].quantity == 5
|
|
||||||
|
|
||||||
def test_merge_items_case_insensitive(self):
|
|
||||||
existing = [InventoryItem(name="Tape", quantity=1)]
|
|
||||||
new = [InventoryItem(name="tape", quantity=2)]
|
|
||||||
merged = _merge_items(existing, new)
|
|
||||||
assert len(merged) == 1
|
|
||||||
assert merged[0].quantity == 3
|
|
||||||
|
|
||||||
def test_format_summary(self):
|
def test_format_summary(self):
|
||||||
items = [InventoryItem(name="water", quantity=6, category="supplies")]
|
items = [InventoryItem(name="water", quantity=6, unit="gallon", category="supplies")]
|
||||||
summary = _format_summary(items)
|
summary = _format_summary(items)
|
||||||
assert "water" in summary
|
assert "water" in summary
|
||||||
assert "x6" in summary
|
assert "x6" in summary
|
||||||
|
assert "gallon" in summary
|
||||||
def test_save_and_load_inventory(self, tmp_path):
|
|
||||||
path = tmp_path / "inventory.json"
|
|
||||||
items = [InventoryItem(name="wrench", quantity=1, category="tools")]
|
|
||||||
save_inventory(path, items)
|
|
||||||
loaded = load_inventory(path)
|
|
||||||
assert len(loaded) == 1
|
|
||||||
assert loaded[0].name == "wrench"
|
|
||||||
|
|
||||||
def test_load_inventory_missing_file(self, tmp_path):
|
|
||||||
path = tmp_path / "does_not_exist.json"
|
|
||||||
assert load_inventory(path) == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeviceRegistry:
|
class TestDeviceRegistry:
|
||||||
@@ -160,45 +129,45 @@ class TestDispatch:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def registry_mock(self):
|
def registry_mock(self):
|
||||||
mock = MagicMock(spec=DeviceRegistry)
|
mock = MagicMock(spec=DeviceRegistry)
|
||||||
mock.is_blocked.return_value = False
|
|
||||||
mock.is_verified.return_value = True
|
mock.is_verified.return_value = True
|
||||||
|
mock.has_safety_number.return_value = True
|
||||||
return mock
|
return mock
|
||||||
|
|
||||||
def test_blocked_device_ignored(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
@pytest.fixture
|
||||||
registry_mock.is_blocked.return_value = True
|
def config(self):
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="!help")
|
return BotConfig(
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
signal_api_url="http://localhost:8080",
|
||||||
signal_mock.reply.assert_not_called()
|
phone_number="+1234567890",
|
||||||
|
)
|
||||||
|
|
||||||
def test_unverified_device_gets_warning(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
def test_unverified_device_ignored(self, signal_mock, llm_mock, registry_mock, config):
|
||||||
registry_mock.is_verified.return_value = False
|
registry_mock.is_verified.return_value = False
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="!help")
|
msg = SignalMessage(source="+1234", timestamp=0, message="!help")
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
dispatch(msg, signal_mock, llm_mock, registry_mock, config)
|
||||||
signal_mock.reply.assert_called_once()
|
signal_mock.reply.assert_not_called()
|
||||||
assert "not verified" in signal_mock.reply.call_args[0][1]
|
|
||||||
|
|
||||||
def test_help_command(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
def test_help_command(self, signal_mock, llm_mock, registry_mock, config):
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="!help")
|
msg = SignalMessage(source="+1234", timestamp=0, message="!help")
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
dispatch(msg, signal_mock, llm_mock, registry_mock, config)
|
||||||
signal_mock.reply.assert_called_once()
|
signal_mock.reply.assert_called_once()
|
||||||
assert "Available commands" in signal_mock.reply.call_args[0][1]
|
assert "Available commands" in signal_mock.reply.call_args[0][1]
|
||||||
|
|
||||||
def test_unknown_command(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
def test_unknown_command(self, signal_mock, llm_mock, registry_mock, config):
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="!foobar")
|
msg = SignalMessage(source="+1234", timestamp=0, message="!foobar")
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
dispatch(msg, signal_mock, llm_mock, registry_mock, config)
|
||||||
signal_mock.reply.assert_called_once()
|
signal_mock.reply.assert_called_once()
|
||||||
assert "Unknown command" in signal_mock.reply.call_args[0][1]
|
assert "Unknown command" in signal_mock.reply.call_args[0][1]
|
||||||
|
|
||||||
def test_non_command_message_ignored(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
def test_non_command_message_ignored(self, signal_mock, llm_mock, registry_mock, config):
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="hello there")
|
msg = SignalMessage(source="+1234", timestamp=0, message="hello there")
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
dispatch(msg, signal_mock, llm_mock, registry_mock, config)
|
||||||
signal_mock.reply.assert_not_called()
|
signal_mock.reply.assert_not_called()
|
||||||
|
|
||||||
def test_status_command(self, signal_mock, llm_mock, registry_mock, tmp_path):
|
def test_status_command(self, signal_mock, llm_mock, registry_mock, config):
|
||||||
llm_mock.list_models.return_value = ["model1", "model2"]
|
llm_mock.list_models.return_value = ["model1", "model2"]
|
||||||
llm_mock.model = "test:7b"
|
llm_mock.model = "test:7b"
|
||||||
registry_mock.list_devices.return_value = []
|
registry_mock.list_devices.return_value = []
|
||||||
msg = SignalMessage(source="+1234", timestamp=0, message="!status")
|
msg = SignalMessage(source="+1234", timestamp=0, message="!status")
|
||||||
dispatch(msg, signal_mock, llm_mock, registry_mock, tmp_path / "inv.json")
|
dispatch(msg, signal_mock, llm_mock, registry_mock, config)
|
||||||
signal_mock.reply.assert_called_once()
|
signal_mock.reply.assert_called_once()
|
||||||
assert "Bot online" in signal_mock.reply.call_args[0][1]
|
assert "Bot online" in signal_mock.reply.call_args[0][1]
|
||||||
|
|||||||
Reference in New Issue
Block a user