Matrix threads in the transport (M2.1, PR A) #96

Merged
dongar merged 1 commits from matrix-threads into main 2026-10-03 16:42:40 +00:00
4 changed files with 266 additions and 12 deletions

No files matched your search

+83 -7
View File
@@ -9,12 +9,18 @@ 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.
Threads (L5): each work item's conversation is a Matrix thread. A message in a
thread carries its root's event ID; a plain reply from a client without
thread support is routed to the thread of the message it replies to, when
that is known. The quoted lines such replies carry are not part of the text.
"""
import html
import re
import threading
import uuid
from collections import OrderedDict
from collections.abc import Callable
from dataclasses import dataclass, field
from urllib.parse import unquote
@@ -30,6 +36,8 @@ _LEADING_PILL = re.compile(
)
# A user ID typed at the start of a plain body, e.g. "@alice:example: ...".
_LEADING_USER_ID = re.compile(r"\s*(@[^\s:]+:[^\s:]+)")
# How many event-to-thread mappings to remember, for routing plain replies.
_REMEMBERED_EVENTS = 2000
class TransportError(RuntimeError):
@@ -66,25 +74,54 @@ class MatrixTransport:
self._display_name: str | None = None
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# event ID -> the root of the thread it is in (a root maps to
# itself), for routing replies from clients without thread support.
self._threads: OrderedDict[str, str] = OrderedDict()
self._threads_lock = threading.Lock()
def _remember(self, event_id: str, root: str) -> None:
with self._threads_lock:
self._threads[event_id] = root
self._threads.move_to_end(event_id)
while len(self._threads) > _REMEMBERED_EVENTS:
self._threads.popitem(last=False)
def _thread_of(self, event_id: str) -> str | None:
with self._threads_lock:
return self._threads.get(event_id)
# -- outbound ---------------------------------------------------------
def post(self, text: str, *, thread: str | None = None) -> str:
content: dict = {"msgtype": "m.text", "body": text}
if thread is not None:
content["m.relates_to"] = {
"rel_type": "m.thread",
"event_id": thread,
# For clients without threads: show it as a reply to the root.
"is_falling_back": True,
"m.in_reply_to": {"event_id": thread},
}
event_id = self._send(content)
# A top-level post may root a thread; one in a thread belongs to it.
self._remember(event_id, thread or event_id)
return event_id
def send_message(self, text: str) -> None:
self._send(text)
self.post(text)
def send_approval_request(self, text: str) -> None:
self._send(text)
self.post(text)
def ask_question(self, text: str) -> None:
self._send(text)
self.post(text)
def _send(self, text: str) -> None:
def _send(self, content: dict) -> str:
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},
f"/rooms/{room}/send/m.room.message/{txn}", json=content
)
except httpx.HTTPError as exc:
raise TransportError(f"matrix send failed: {exc}") from exc
@@ -92,6 +129,7 @@ class MatrixTransport:
raise TransportError(
f"matrix send returned {response.status_code}: {response.text[:300]}"
)
return str(response.json().get("event_id", ""))
# -- inbound ----------------------------------------------------------
@@ -160,12 +198,23 @@ class MatrixTransport:
if event.get("type") != "m.room.message":
continue
sender = event.get("sender", "")
content = event.get("content", {})
event_id = str(event.get("event_id", ""))
thread = self._thread_for(content)
if event_id:
self._remember(event_id, thread or event_id)
if sender == self._config.user_id:
continue
content = event.get("content", {})
body = content.get("body")
if not body:
continue
if "m.in_reply_to" in content.get("m.relates_to", {}):
# A rich reply quotes what it replies to; the quote isn't
# what the sender said.
body = _strip_reply_fallback(body)
content = {**content, "body": 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:
@@ -177,9 +226,22 @@ class MatrixTransport:
sender=sender,
text=text,
original=body if text != body else None,
event_id=event_id,
thread=thread,
)
)
def _thread_for(self, content: dict) -> str | None:
"""The thread root a message belongs to, or None at top level."""
relates = content.get("m.relates_to") or {}
if relates.get("rel_type") == "m.thread" and relates.get("event_id"):
return str(relates["event_id"])
replied = (relates.get("m.in_reply_to") or {}).get("event_id")
if replied:
# A plain reply: in the thread of whatever it replies to, if known.
return self._thread_of(str(replied))
return 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
@@ -210,6 +272,20 @@ class MatrixTransport:
self._http.close()
def _strip_reply_fallback(body: str) -> str:
"""The body of a rich reply without the quoted lines clients prepend
("> <@alice:example> original text", then a blank line)."""
lines = body.split("\n")
if not lines or not lines[0].startswith("> "):
return body
index = 0
while index < len(lines) and lines[index].startswith(">"):
index += 1
if index < len(lines) and not lines[index].strip():
index += 1
return "\n".join(lines[index:]).strip()
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()
+39 -5
View File
@@ -18,15 +18,27 @@ class InboundMessage:
text: str
# The message as sent, when that differs from text; the record keeps it.
original: str | None = None
# The platform's ID for this message, and the root of the thread it was
# sent in (None at top level). A work item's conversation is a thread
# (L5), so this is how a reply finds its item.
event_id: str = ""
thread: str | None = None
@dataclass(frozen=True)
class OutboundMessage:
kind: str # "message" | "approval_request" | "question"
text: str
thread: str | None = None
event_id: str = ""
class Transport(Protocol):
def post(self, text: str, *, thread: str | None = None) -> str:
"""Send a message, in a thread when one is given; returns its ID,
which can root a new thread."""
...
def send_message(self, text: str) -> None: ...
def send_approval_request(self, text: str) -> None: ...
@@ -42,15 +54,27 @@ class InMemoryTransport:
def __init__(self) -> None:
self.outbound: list[OutboundMessage] = []
self._callback: Callable[[InboundMessage], None] | None = None
self._next_id = 0
def _id(self) -> str:
self._next_id += 1
return f"$event{self._next_id}"
def post(self, text: str, *, thread: str | None = None) -> str:
event_id = self._id()
self.outbound.append(OutboundMessage("message", text, thread, event_id))
return event_id
def send_message(self, text: str) -> None:
self.outbound.append(OutboundMessage("message", text))
self.outbound.append(OutboundMessage("message", text, None, self._id()))
def send_approval_request(self, text: str) -> None:
self.outbound.append(OutboundMessage("approval_request", text))
self.outbound.append(
OutboundMessage("approval_request", text, None, self._id())
)
def ask_question(self, text: str) -> None:
self.outbound.append(OutboundMessage("question", text))
self.outbound.append(OutboundMessage("question", text, None, self._id()))
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
self._callback = callback
@@ -61,6 +85,16 @@ class InMemoryTransport:
def close(self) -> None:
"""Nothing to disconnect from."""
def receive(self, sender: str, text: str, original: str | None = None) -> None:
def receive(
self,
sender: str,
text: str,
original: str | None = None,
*,
thread: str | None = None,
) -> str:
"""Deliver a message as if the operator sent it; returns its ID."""
assert self._callback is not None, "no message handler registered"
self._callback(InboundMessage(sender, text, original))
event_id = self._id()
self._callback(InboundMessage(sender, text, original, event_id, thread))
return event_id
+122
View File
@@ -237,3 +237,125 @@ def test_a_mention_later_in_the_message_is_left_alone() -> None:
assert received == [
InboundMessage(sender="@don:test", text="approve, cc @alice:test")
]
# --- threads (L5): a work item's conversation is a thread --------------------
def threaded_transport() -> tuple[MatrixTransport, list[dict], list[InboundMessage]]:
sent: list[dict] = []
ids = iter(f"$sent{n}" for n in range(1, 100))
def handler(request: httpx.Request) -> httpx.Response:
sent.append(json.loads(request.content))
return httpx.Response(200, json={"event_id": next(ids)})
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
received: list[InboundMessage] = []
transport.on_message(received.append)
return transport, sent, received
def event(event_id: str, body: str, relates: dict | None = None) -> dict:
content: dict = {"msgtype": "m.text", "body": body}
if relates is not None:
content["m.relates_to"] = relates
return {
"type": "m.room.message",
"sender": "@don:test",
"event_id": event_id,
"content": content,
}
def deliver(transport: MatrixTransport, *events: dict) -> None:
transport._deliver(sync_body(list(events), "b"))
def test_post_returns_the_event_id_and_threads_a_reply() -> None:
transport, sent, _ = threaded_transport()
root = transport.post("queued sandbox#14")
transport.post("As I understand it: ...", thread=root)
assert root == "$sent1"
assert "m.relates_to" not in sent[0]
assert sent[1]["m.relates_to"] == {
"rel_type": "m.thread",
"event_id": "$sent1",
# Clients without threads show it as a reply to the root.
"is_falling_back": True,
"m.in_reply_to": {"event_id": "$sent1"},
}
def test_a_message_in_a_thread_carries_its_root() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$top", "status"),
event("$in", "go", {"rel_type": "m.thread", "event_id": "$root"}),
)
assert [(m.text, m.event_id, m.thread) for m in received] == [
("status", "$top", None),
("go", "$in", "$root"),
]
def test_a_plain_reply_to_a_threaded_message_joins_its_thread() -> None:
# Clients without thread support send an ordinary reply, quoting.
transport, _, received = threaded_transport()
root = transport.post("queued sandbox#14")
in_thread = transport.post("As I understand it: ...", thread=root)
deliver(
transport,
event(
"$r1",
"> <@nisse-dev:test> As I understand it: ...\n\nyes, go",
{"m.in_reply_to": {"event_id": in_thread}},
),
event(
"$r2",
"> <@nisse-dev:test> queued sandbox#14\n\nthanks",
{"m.in_reply_to": {"event_id": root}},
),
)
assert [(m.text, m.thread) for m in received] == [
("yes, go", root), # the quote is not what was said
("thanks", root), # a reply to the root joins its thread
]
def test_a_reply_to_an_unknown_message_stays_at_top_level() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$r", "> <@x:test> old\n\nhm", {"m.in_reply_to": {"event_id": "$old"}}),
)
assert [(m.text, m.thread) for m in received] == [("hm", None)]
def test_a_reply_to_the_operators_own_threaded_message_joins_the_thread() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$t", "first", {"rel_type": "m.thread", "event_id": "$root"}),
event(
"$r",
"> <@don:test> first\n\nand also",
{"m.in_reply_to": {"event_id": "$t"}},
),
)
assert [m.thread for m in received] == ["$root", "$root"]
def test_in_memory_transport_plays_threads() -> None:
from nisse_dev.transport import InMemoryTransport
transport = InMemoryTransport()
seen: list[InboundMessage] = []
transport.on_message(seen.append)
root = transport.post("queued #14")
transport.post("restated", thread=root)
reply = transport.receive("@don:test", "go", thread=root)
assert transport.outbound[-1].thread == root
assert (seen[0].thread, seen[0].event_id) == (root, reply)
+22
View File
@@ -51,3 +51,25 @@ def test_send_lands_in_test_room(live_config: AgentConfig) -> None:
assert marker in bodies
finally:
transport.close()
def test_a_threaded_post_is_kept_in_its_thread(live_config: AgentConfig) -> None:
"""The homeserver accepts the thread relation and lists the reply under
its root (L5: a work item's conversation is a thread)."""
config = live_config.matrix
marker = uuid.uuid4().hex
transport = MatrixTransport(config)
try:
_assert_testing_identity(transport, config)
root = transport.post(f"thread smoke test root {marker}")
reply = transport.post(f"thread smoke test reply {marker}", thread=root)
# The relations API is v1, not the transport's v3.
response = transport._http.get(
f"{config.homeserver}/_matrix/client/v1/rooms/{config.room_id}"
f"/relations/{root}/m.thread"
)
response.raise_for_status()
in_thread = [e["event_id"] for e in response.json().get("chunk", [])]
assert reply in in_thread
finally:
transport.close()