Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions opencode-sms-bridge/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,26 @@
CHANNEL_AGENTS = frozenset({"lawnmowerman", "grillmaster", "homesteader", "homerepair"})
EMPTY_TWIML = '<?xml version="1.0" encoding="UTF-8"?><Response/>'
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):
Expand Down Expand Up @@ -491,37 +507,37 @@ 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:
if any(part.get("type") != "text" for part in parts):
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
Expand All @@ -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]]:
Expand Down Expand Up @@ -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"])
Expand All @@ -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"])


Expand Down
44 changes: 44 additions & 0 deletions opencode-sms-bridge/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +23,7 @@
create_ingress_app,
load_routing,
normalize_e164,
process_job,
sanitize_image,
sender_hash,
)
Expand Down Expand Up @@ -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()
Expand Down
Loading