CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
216 lines
8.0 KiB
Python
216 lines
8.0 KiB
Python
"""MatrixTransport (architecture L5): 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 L5's intake posture: on homeserver errors the sync
|
|
loop backs off (capped) and retries indefinitely — an outage delays reactions
|
|
but never fails anything.
|
|
"""
|
|
|
|
import html
|
|
import re
|
|
import threading
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from urllib.parse import unquote
|
|
|
|
import httpx
|
|
|
|
from nisse_dev.transport import InboundMessage
|
|
|
|
# A mention pill at the start of an HTML body: the link holds the user ID,
|
|
# the link text is what the plain body starts with.
|
|
_LEADING_PILL = re.compile(
|
|
r'\s*<a href="https://matrix\.to/#/([^"?]+)[^"]*">(.*?)</a>', re.DOTALL
|
|
)
|
|
# A user ID typed at the start of a plain body, e.g. "@alice:example: ...".
|
|
_LEADING_USER_ID = re.compile(r"\s*(@[^\s:]+:[^\s:]+)")
|
|
|
|
|
|
class TransportError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MatrixConfig:
|
|
homeserver: str
|
|
user_id: str
|
|
token: str
|
|
room_id: str
|
|
|
|
|
|
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
|
|
# The agent's display name in the room, learnt from its own member
|
|
# event; clients write it in front of messages addressed to it.
|
|
self._display_name: str | 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, L5)."""
|
|
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()
|
|
self._learn_display_name(data)
|
|
if since is not None:
|
|
self._deliver(data)
|
|
since = data.get("next_batch", since)
|
|
|
|
def _room(self, sync_data: dict) -> dict:
|
|
return sync_data.get("rooms", {}).get("join", {}).get(self._config.room_id, {})
|
|
|
|
def _learn_display_name(self, sync_data: dict) -> None:
|
|
room = self._room(sync_data)
|
|
events = room.get("state", {}).get("events", []) + room.get("timeline", {}).get(
|
|
"events", []
|
|
)
|
|
for event in events:
|
|
if (
|
|
event.get("type") == "m.room.member"
|
|
and event.get("state_key") == self._config.user_id
|
|
):
|
|
name = event.get("content", {}).get("displayname")
|
|
if name:
|
|
self._display_name = name
|
|
|
|
def _deliver(self, sync_data: dict) -> None:
|
|
for event in self._room(sync_data).get("timeline", {}).get("events", []):
|
|
if event.get("type") != "m.room.message":
|
|
continue
|
|
sender = event.get("sender", "")
|
|
if sender == self._config.user_id:
|
|
continue
|
|
content = event.get("content", {})
|
|
body = content.get("body")
|
|
if not body:
|
|
continue
|
|
addressee, text = self._addressing(content)
|
|
if addressee is not None and addressee != self._config.user_id:
|
|
# Addressed to someone else: in a shared room, "Nisse Ops:
|
|
# approve" must not reach this agent as rejection feedback.
|
|
continue
|
|
assert self._callback is not None
|
|
self._callback(
|
|
InboundMessage(
|
|
sender=sender,
|
|
text=text,
|
|
original=body if text != body else None,
|
|
)
|
|
)
|
|
|
|
def _addressing(self, content: dict) -> tuple[str | None, str]:
|
|
"""Who a leading mention addresses, and the body without a leading
|
|
mention of this agent. Only the start of the message counts: a
|
|
mention later on ("approve, cc @alice") is part of the text. The
|
|
sender is never read from here; it comes from the event (L3)."""
|
|
body: str = content["body"]
|
|
own = self._config.user_id
|
|
if content.get("format") == "org.matrix.custom.html":
|
|
pill = _LEADING_PILL.match(content.get("formatted_body", ""))
|
|
if pill:
|
|
addressee = unquote(pill.group(1))
|
|
label = html.unescape(re.sub(r"<[^>]+>", "", pill.group(2)))
|
|
if addressee == own and label and body.startswith(label):
|
|
return own, _after_prefix(body, len(label))
|
|
return addressee, body
|
|
if body.startswith(own):
|
|
return own, _after_prefix(body, len(own))
|
|
name = self._display_name
|
|
if name and body.startswith(name) and body[len(name) :].startswith(":"):
|
|
return own, _after_prefix(body, len(name))
|
|
typed = _LEADING_USER_ID.match(body)
|
|
if typed and body[typed.end() :].startswith(":"):
|
|
return typed.group(1), body
|
|
return None, body
|
|
|
|
def close(self) -> None:
|
|
self.stop()
|
|
self._http.close()
|
|
|
|
|
|
def _after_prefix(body: str, length: int) -> str:
|
|
"""The body past a leading mention and the ':' clients put after it."""
|
|
return body[length:].lstrip().removeprefix(":").strip()
|