- matrix.py: Transport implementation over the Matrix client-server HTTP API directly (token auth, /sync long-poll, /send) using the existing httpx dependency — no Matrix SDK; four endpoints total. Deviation from the presented plan (matrix-nio) flagged in the PR - First sync learns position and skips backlog, so room history is never delivered as context (A1); own messages and non-message events filtered; sender MXID passed through for the orchestrator's operator allowlist - Sync loop backs off (capped at 60s) and retries indefinitely on homeserver errors, mirroring B2's polling posture - Encrypted rooms unsupported: bot rooms are created unencrypted until E2E lands (operator decision; room recreated accordingly) - NISSE_MATRIX_TEST_ROOM_ID added: the live smoke test binds only to the dedicated test room, never the production room - 8 unit tests over a mocked HTTP layer; live integration test verified against the real homeserver Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
5.6 KiB
Python
168 lines
5.6 KiB
Python
"""MatrixTransport (spec A1): Matrix as strictly a UI channel.
|
|
|
|
Implements the Transport interface directly over the Matrix client-server HTTP
|
|
API (token auth, /sync long-poll, /send) with the httpx client already in the
|
|
dependency tree — no Matrix SDK. The surface used is four endpoints; an SDK
|
|
earns its keep when E2E arrives, and bot rooms are created unencrypted until
|
|
then, so encrypted rooms are not supported here.
|
|
|
|
Inbound handling follows B2's polling posture: on homeserver errors the sync
|
|
loop backs off (capped) and retries indefinitely — an outage delays reactions
|
|
but never fails anything.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
import uuid
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from typing import ClassVar
|
|
|
|
import httpx
|
|
|
|
from nisse_dev.transport import InboundMessage
|
|
|
|
|
|
class TransportError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MatrixConfig:
|
|
homeserver: str
|
|
user_id: str
|
|
token: str
|
|
room_id: str
|
|
|
|
_ENV: ClassVar[tuple[str, ...]] = (
|
|
"NISSE_MATRIX_HOMESERVER",
|
|
"NISSE_MATRIX_USER_ID",
|
|
"NISSE_MATRIX_TOKEN",
|
|
)
|
|
|
|
@classmethod
|
|
def from_env(
|
|
cls,
|
|
env: Mapping[str, str] = os.environ,
|
|
room_var: str = "NISSE_MATRIX_ROOM_ID",
|
|
) -> "MatrixConfig":
|
|
needed = (*cls._ENV, room_var)
|
|
missing = [var for var in needed if not env.get(var)]
|
|
if missing:
|
|
raise TransportError(f"matrix config incomplete; set: {', '.join(missing)}")
|
|
return cls(
|
|
homeserver=env["NISSE_MATRIX_HOMESERVER"].rstrip("/"),
|
|
user_id=env["NISSE_MATRIX_USER_ID"],
|
|
token=env["NISSE_MATRIX_TOKEN"],
|
|
room_id=env[room_var],
|
|
)
|
|
|
|
|
|
class MatrixTransport:
|
|
def __init__(
|
|
self,
|
|
config: MatrixConfig,
|
|
*,
|
|
poll_timeout_ms: int = 30000,
|
|
http_transport: httpx.BaseTransport | None = None,
|
|
) -> None:
|
|
self._config = config
|
|
self._poll_timeout_ms = poll_timeout_ms
|
|
self._http = httpx.Client(
|
|
base_url=f"{config.homeserver}/_matrix/client/v3",
|
|
headers={"Authorization": f"Bearer {config.token}"},
|
|
timeout=(poll_timeout_ms / 1000) + 30,
|
|
transport=http_transport,
|
|
)
|
|
self._callback: Callable[[InboundMessage], None] | None = None
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
|
|
# -- outbound ---------------------------------------------------------
|
|
|
|
def send_message(self, text: str) -> None:
|
|
self._send(text)
|
|
|
|
def send_approval_request(self, text: str) -> None:
|
|
self._send(text)
|
|
|
|
def ask_question(self, text: str) -> None:
|
|
self._send(text)
|
|
|
|
def _send(self, text: str) -> None:
|
|
room = self._config.room_id
|
|
txn = uuid.uuid4().hex
|
|
try:
|
|
response = self._http.put(
|
|
f"/rooms/{room}/send/m.room.message/{txn}",
|
|
json={"msgtype": "m.text", "body": text},
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
raise TransportError(f"matrix send failed: {exc}") from exc
|
|
if response.status_code != 200:
|
|
raise TransportError(
|
|
f"matrix send returned {response.status_code}: {response.text[:300]}"
|
|
)
|
|
|
|
# -- inbound ----------------------------------------------------------
|
|
|
|
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
|
|
self._callback = callback
|
|
|
|
def start(self) -> None:
|
|
"""Begin delivering new room messages to the registered callback.
|
|
History from before start() is never delivered (Matrix room history
|
|
must not become load-bearing context, A1)."""
|
|
if self._callback is None:
|
|
raise TransportError("no message handler registered; call on_message first")
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=(self._poll_timeout_ms / 1000) + 5)
|
|
self._thread = None
|
|
|
|
def _sync_loop(self) -> None:
|
|
since: str | None = None
|
|
backoff = 1.0
|
|
while not self._stop.is_set():
|
|
params: dict[str, str] = {}
|
|
if since is None:
|
|
params["timeout"] = "0" # first sync: learn position, skip backlog
|
|
else:
|
|
params["timeout"] = str(self._poll_timeout_ms)
|
|
params["since"] = since
|
|
try:
|
|
response = self._http.get("/sync", params=params)
|
|
response.raise_for_status()
|
|
except httpx.HTTPError:
|
|
self._stop.wait(backoff)
|
|
backoff = min(backoff * 2, 60.0)
|
|
continue
|
|
backoff = 1.0
|
|
data = response.json()
|
|
if since is not None:
|
|
self._deliver(data)
|
|
since = data.get("next_batch", since)
|
|
|
|
def _deliver(self, sync_data: dict) -> None:
|
|
room = sync_data.get("rooms", {}).get("join", {}).get(self._config.room_id, {})
|
|
for event in room.get("timeline", {}).get("events", []):
|
|
if event.get("type") != "m.room.message":
|
|
continue
|
|
sender = event.get("sender", "")
|
|
if sender == self._config.user_id:
|
|
continue
|
|
body = event.get("content", {}).get("body")
|
|
if not body:
|
|
continue
|
|
assert self._callback is not None
|
|
self._callback(InboundMessage(sender=sender, text=body))
|
|
|
|
def close(self) -> None:
|
|
self.stop()
|
|
self._http.close()
|