Files
nisse/nisse-dev/src/nisse_dev/matrix.py
T
dongarandClaude Opus 5.5 15d69799bf
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
Make the config file the only home for settings
With no production instance yet, there is no reason to carry two ways
of configuring the agent. Every environment variable that duplicated a
config key is gone; the environment now supplies only the secrets the
file references by ${…}, plus NISSE_CONFIG and NISSE_ENV to find the
files.

- AgentConfig loads from a file (load, from_file) or a parsed tree
  (from_tree, for tests); a missing file is an error naming the example
- The from_env constructors on LiteLLMClient, AnthropicPassthroughClient,
  GiteaClient, MatrixConfig and RoleMap are removed, with the env
  reading for max output tokens; clients take their settings from config
- The live integration tests share a committed agent config,
  tests/integration/nisse-test.yaml, aimed at test resources, and one
  conftest that loads it and builds clients. NISSE_LIVE_PLANNER_MODEL
  becomes its planner role; NISSE_CAPABILITY_MODELS stays as a per-run
  switch
- A missing secret now fails the live tests instead of skipping them
- CI passes only the three tokens
- .env.example is secrets only; spec A5 says config comes from the file

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:25:58 +00:00

143 lines
4.9 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 threading
import uuid
from collections.abc import Callable
from dataclasses import dataclass
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
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()