diff --git a/opencode-sms-bridge/server.py b/opencode-sms-bridge/server.py index eda4647..4932d41 100644 --- a/opencode-sms-bridge/server.py +++ b/opencode-sms-bridge/server.py @@ -31,10 +31,26 @@ CHANNEL_AGENTS = frozenset({"lawnmowerman", "grillmaster", "homesteader", "homerepair"}) EMPTY_TWIML = '' MAX_WEBHOOK_BYTES = 64 * 1024 +ERROR_OK = "ok" +ERROR_OPENCODE_REQUEST_FAILED = "opencode-request-failed" +ERROR_OPENCODE_RESPONSE_INVALID = "opencode-response-invalid" +ERROR_OPENCODE_INPUT_INVALID = "opencode-input-invalid" +ERROR_TWILIO_SEND_FAILED = "twilio-send-failed" +BRIDGE_ERROR_CODES = frozenset( + { + ERROR_OK, + ERROR_OPENCODE_REQUEST_FAILED, + ERROR_OPENCODE_RESPONSE_INVALID, + ERROR_OPENCODE_INPUT_INVALID, + ERROR_TWILIO_SEND_FAILED, + } +) class BridgeError(RuntimeError): - pass + def __init__(self, message: str, error_code: str = ERROR_OPENCODE_RESPONSE_INVALID): + super().__init__(message) + self.error_code = error_code if error_code in BRIDGE_ERROR_CODES else ERROR_OPENCODE_RESPONSE_INVALID class UnsupportedMedia(BridgeError): @@ -491,20 +507,20 @@ def _request(self, path: str, payload: dict[str, Any] | None = None, method: str with build_opener(NoRedirect).open(request, timeout=self.settings.opencode_timeout_seconds) as response: body = response.read() except (HTTPError, URLError, OSError, ValueError) as error: - raise BridgeError("OpenCode request failed") from error + raise BridgeError("OpenCode request failed", ERROR_OPENCODE_REQUEST_FAILED) from error if not body: return {} try: return json.loads(body.decode()) except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise BridgeError("OpenCode response was invalid") from error + raise BridgeError("OpenCode response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) from error def create_session(self, agent: str) -> str: response = self._request("/api/session", {"agent": agent}) data = response.get("data", response) session_id = data.get("id") if isinstance(data, dict) else None if not isinstance(session_id, str) or not session_id: - raise BridgeError("OpenCode session response was invalid") + raise BridgeError("OpenCode session response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) return session_id def prompt(self, session_id: str, parts: list[dict[str, str]]) -> str: @@ -512,16 +528,16 @@ def prompt(self, session_id: str, parts: list[dict[str, str]]) -> str: raise UnsupportedMedia("V2 file prompt mapping is not implemented") text = "\n".join(part.get("text", "") for part in parts).strip() if not text: - raise BridgeError("OpenCode prompt has no text") + raise BridgeError("OpenCode prompt has no text", ERROR_OPENCODE_INPUT_INVALID) admission = self._request(f"/api/session/{session_id}/prompt", {"prompt": {"text": text}}) data = admission.get("data") if not isinstance(data, dict) or not isinstance(data.get("id"), str): - raise BridgeError("OpenCode prompt admission was invalid") + raise BridgeError("OpenCode prompt admission was invalid", ERROR_OPENCODE_RESPONSE_INVALID) self._request(f"/api/session/{session_id}/wait") response = self._request(f"/api/session/{session_id}/message?order=desc&limit=200", method="GET") messages = response.get("data") if not isinstance(messages, list): - raise BridgeError("OpenCode messages response was invalid") + raise BridgeError("OpenCode messages response was invalid", ERROR_OPENCODE_RESPONSE_INVALID) for message in messages: if not isinstance(message, dict) or message.get("type") != "assistant": continue @@ -535,7 +551,7 @@ def prompt(self, session_id: str, parts: list[dict[str, str]]) -> str: ) if reply.strip(): return reply.strip() - raise BridgeError("OpenCode response did not contain text") + raise BridgeError("OpenCode response did not contain text", ERROR_OPENCODE_RESPONSE_INVALID) def build_parts(settings: Settings, payload: dict[str, Any]) -> list[dict[str, str]]: @@ -575,9 +591,9 @@ def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient, except UnsupportedMedia: LOG.info("event=job_unsupported_media channel=%s", job["channel"]) response = "This channel cannot process that attachment yet. Please send text or try a supported attachment later." - except BridgeError: - LOG.warning("event=job_failed stage=opencode channel=%s", job["channel"]) - store.finish(job["message_sid"], "failed", "opencode-failed") + except BridgeError as error: + LOG.warning("event=job_failed stage=opencode channel=%s error_code=%s", job["channel"], error.error_code) + store.finish(job["message_sid"], "failed", error.error_code) return if not store.begin_send(job["message_sid"]): LOG.warning("event=job_skipped stage=state channel=%s", job["channel"]) @@ -587,9 +603,9 @@ def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient, twilio.messages.create(to=job["payload"]["from"], from_=job["payload"]["to"], body=sms_body(response)) except Exception: # The helper library's exception details can include provider data; do not log them. LOG.warning("event=job_delivery_unknown stage=twilio channel=%s", job["channel"]) - store.finish(job["message_sid"], "delivery-unknown", "twilio-send-failed") + store.finish(job["message_sid"], "delivery-unknown", ERROR_TWILIO_SEND_FAILED) return - store.finish(job["message_sid"], "sent", "ok") + store.finish(job["message_sid"], "sent", ERROR_OK) LOG.info("event=job_sent channel=%s", job["channel"]) diff --git a/opencode-sms-bridge/test_server.py b/opencode-sms-bridge/test_server.py index 1ca6671..a4b8b23 100644 --- a/opencode-sms-bridge/test_server.py +++ b/opencode-sms-bridge/test_server.py @@ -3,8 +3,10 @@ import sqlite3 import tempfile import unittest +from dataclasses import replace from pathlib import Path from unittest.mock import call, patch +from urllib.error import HTTPError, URLError from cryptography.fernet import Fernet from fastapi.testclient import TestClient @@ -21,6 +23,7 @@ create_ingress_app, load_routing, normalize_e164, + process_job, sanitize_image, sender_hash, ) @@ -159,6 +162,47 @@ def test_prompt_rejects_unmapped_file_parts(self): with self.assertRaises(UnsupportedMedia): client.prompt("ses_123", [{"type": "file", "mime": "image/png", "filename": "image", "url": "data:image/png;base64,"}]) + def test_opencode_request_errors_map_to_bounded_request_code(self): + settings = replace(self.settings, opencode_base_url="https://opencode.example.invalid") + client = OpenCodeClient(settings) + failures = ( + URLError("connection detail"), + OSError("socket detail"), + HTTPError("https://opencode.example.invalid/api/session", 500, "server detail", None, None), + ) + for failure in failures: + with self.subTest(failure=failure): + with patch("server.build_opener") as opener_factory: + opener_factory.return_value.open.side_effect = failure + with self.assertRaises(BridgeError) as raised: + client.create_session("lawnmowerman") + self.assertEqual(raised.exception.error_code, "opencode-request-failed") + self.assertNotIn("detail", str(raised.exception)) + + def test_bridge_error_defaults_to_safe_bounded_code(self): + self.assertEqual(BridgeError("worker configuration is incomplete").error_code, "opencode-response-invalid") + self.assertEqual(BridgeError("legacy detail", "opencode-failed").error_code, "opencode-response-invalid") + self.assertEqual(BridgeError("OpenCode prompt has no text", "opencode-input-invalid").error_code, "opencode-input-invalid") + + def test_process_job_persists_bounded_error_code(self): + payload = {"from": "+15559999999", "to": "+15550000001", "body": "hello", "media": [], "agent": "lawnmowerman"} + identifier = sender_hash(self.settings.sender_hash_key, payload["from"]) + self.store.enqueue("SM301", "lawnmowerman", identifier, payload) + job = self.store.claim() + self.store.remember_session("lawnmowerman", identifier, "ses_301") + client = OpenCodeClient(self.settings) + failure = BridgeError("OpenCode prompt has no text", "opencode-input-invalid") + with patch.object(client, "prompt", side_effect=failure): + with self.assertLogs("opencode-sms-bridge", level="WARNING") as captured: + process_job(self.settings, self.store, client, job) + telemetry = "\n".join(captured.output) + self.assertIn("event=job_failed stage=opencode channel=lawnmowerman error_code=opencode-input-invalid", telemetry) + self.assertNotIn("opencode-failed", telemetry) + self.assertNotIn(payload["body"], telemetry) + with sqlite3.connect(self.settings.state_path) as connection: + row = connection.execute("SELECT status, detail_code FROM jobs WHERE message_sid='SM301'").fetchone() + self.assertEqual(tuple(row), ("failed", "opencode-input-invalid")) + def test_image_sanitization_removes_exif(self): image = Image.new("RGB", (8, 8), color="red") original = tempfile.SpooledTemporaryFile()