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
6 changes: 6 additions & 0 deletions opencode-sms-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ One single-replica pod runs two copies of this image:

The state database stores encrypted message payloads and HMAC sender identifiers. It deliberately marks uncertain outbound sends as `delivery-unknown` rather than retrying and risking duplicate SMS. The first release is intentionally single replica; do not scale it without replacing SQLite queue/session coordination.

## PII-safe operational telemetry

The bridge emits structured lifecycle events to container logs without access logs or payload data. Events may include the fixed agent channel, media count, stage, and a bounded reason; they never include phone numbers, message SID values, message bodies, media URLs, sender hashes, session IDs, credentials, or provider exception detail.

Ingress events distinguish rejected signatures, ignored account/destination/sender combinations, queued messages, and duplicates. Worker events distinguish claimed jobs, unsupported media, OpenCode failures, state-transition skips, uncertain Twilio delivery, and successful sends. The persistent encrypted queue remains authoritative for detailed recovery; do not log or export its contents.

## Required configuration

All required values come from cluster-owned Secret mounts or safe chart values. Do not place values in this repository or chart `values.yaml`.
Expand Down
16 changes: 15 additions & 1 deletion opencode-sms-bridge/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,20 @@ async def inbound(request: FastAPIRequest) -> Response:
raise HTTPException(status_code=413, detail="request too large")
form = parse_form(body)
if not validate_webhook(settings, form, request.headers.get("x-twilio-signature")):
LOG.warning("event=inbound_rejected reason=invalid-signature")
raise HTTPException(status_code=403, detail="invalid signature")
message = incoming_payload(settings, form)
if message is None:
LOG.info("event=inbound_ignored reason=account-destination-or-sender")
return empty_twiml()
message_sid, channel, source_id, payload = message
store.enqueue(message_sid, channel, source_id, payload)
queued = store.enqueue(message_sid, channel, source_id, payload)
LOG.info(
"event=%s channel=%s media_count=%d",
"inbound_queued" if queued else "inbound_duplicate",
channel,
len(payload["media"]),
)
return empty_twiml()

return app
Expand Down Expand Up @@ -538,19 +546,24 @@ def process_job(settings: Settings, store: SQLiteStore, client: OpenCodeClient,
session_id = store.remember_session(job["channel"], job["sender_hash"], client.create_session(job["payload"]["agent"]))
response = client.prompt(session_id, build_parts(settings, job["payload"]))
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")
return
if not store.begin_send(job["message_sid"]):
LOG.warning("event=job_skipped stage=state channel=%s", job["channel"])
return
try:
twilio = Client(settings.twilio_api_key_sid, settings.twilio_api_key_secret, settings.routing.account_sid)
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")
return
store.finish(job["message_sid"], "sent", "ok")
LOG.info("event=job_sent channel=%s", job["channel"])


def create_worker_app(settings: Settings, store: SQLiteStore) -> FastAPI:
Expand All @@ -568,6 +581,7 @@ async def loop() -> None:
if job is None:
await asyncio.sleep(1)
continue
LOG.info("event=job_claimed channel=%s", job["channel"])
await asyncio.to_thread(process_job, settings, store, client, job)
asyncio.create_task(loop())

Expand Down
14 changes: 12 additions & 2 deletions opencode-sms-bridge/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ def test_ingress_accepts_signed_approved_message_once(self):
signature = RequestValidator("auth-token").compute_signature(self.settings.canonical_webhook_url, form)
client = TestClient(create_ingress_app(self.settings, self.store))
headers = {"X-Twilio-Signature": signature}
self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200)
with self.assertLogs("opencode-sms-bridge", level="INFO") as captured:
self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200)
telemetry = "\n".join(captured.output)
self.assertIn("event=inbound_queued channel=lawnmowerman media_count=0", telemetry)
for unsafe_value in (form["From"], form["To"], form["MessageSid"], form["Body"]):
self.assertNotIn(unsafe_value, telemetry)
self.assertEqual(client.post("/twilio/inbound", data=form, headers=headers).status_code, 200)
self.assertIsNotNone(self.store.claim())
self.assertIsNone(self.store.claim())
Expand All @@ -114,8 +119,13 @@ def test_ingress_ignores_unapproved_sender_after_signature_validation(self):
form = {"AccountSid": "AC1234567890", "MessageSid": "SM124", "From": "+15558888888", "To": "+15550000001", "Body": "hello", "NumMedia": "0"}
signature = RequestValidator("auth-token").compute_signature(self.settings.canonical_webhook_url, form)
client = TestClient(create_ingress_app(self.settings, self.store))
response = client.post("/twilio/inbound", data=form, headers={"X-Twilio-Signature": signature})
with self.assertLogs("opencode-sms-bridge", level="INFO") as captured:
response = client.post("/twilio/inbound", data=form, headers={"X-Twilio-Signature": signature})
telemetry = "\n".join(captured.output)
self.assertEqual(response.status_code, 200)
self.assertIn("event=inbound_ignored reason=account-destination-or-sender", telemetry)
for unsafe_value in (form["From"], form["To"], form["MessageSid"], form["Body"]):
self.assertNotIn(unsafe_value, telemetry)
self.assertIsNone(self.store.claim())

def test_image_sanitization_removes_exif(self):
Expand Down
Loading