import json import threading import httpx import pytest from nisse_core.conversation.matrix import MatrixConfig, MatrixTransport, TransportError from nisse_core.conversation.transport import InboundMessage CONFIG = MatrixConfig( homeserver="https://matrix.test", user_id="@nisse-dev:test", token="syt_secret", room_id="!room:test", ) def sync_body(events: list[dict], next_batch: str) -> dict: return { "next_batch": next_batch, "rooms": {"join": {"!room:test": {"timeline": {"events": events}}}}, } def message_event(sender: str, body: str) -> dict: return { "type": "m.room.message", "sender": sender, "content": {"msgtype": "m.text", "body": body}, } def test_send_message_puts_event_with_auth_and_unique_txn() -> None: seen: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(200, json={"event_id": "$e"}) transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler)) transport.send_message("hello") transport.send_approval_request("plan rev-1") first, second = seen assert first.headers["authorization"] == "Bearer syt_secret" assert "/rooms/!room:test/send/m.room.message/" in str(first.url) assert json.loads(first.content) == {"msgtype": "m.text", "body": "hello"} assert str(first.url) != str(second.url) # txn ids must be unique def test_send_failure_raises_transport_error() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(403, text="forbidden") transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler)) with pytest.raises(TransportError, match="403"): transport.send_message("hello") class SyncScript: """Serves scripted /sync responses, then endless empty ones.""" def __init__(self, responses: list[dict]) -> None: self._responses = responses self._i = 0 self.batches_seen: list[str | None] = [] def __call__(self, request: httpx.Request) -> httpx.Response: assert request.url.path.endswith("/sync") self.batches_seen.append(request.url.params.get("since")) if self._i < len(self._responses): body = self._responses[self._i] self._i += 1 return httpx.Response(200, json=body) return httpx.Response(200, json=sync_body([], f"b{self._i}")) def collect_messages( responses: list[dict], expected: int, timeout_s: float = 5.0 ) -> list[InboundMessage]: received: list[InboundMessage] = [] done = threading.Event() def on_message(msg: InboundMessage) -> None: received.append(msg) if len(received) >= expected: done.set() transport = MatrixTransport( CONFIG, poll_timeout_ms=10, http_transport=httpx.MockTransport(SyncScript(responses)), ) transport.on_message(on_message) transport.start() done.wait(timeout_s) transport.close() return received def test_listener_delivers_operator_messages() -> None: responses = [ sync_body([message_event("@don:test", "backlog: never delivered")], "b1"), sync_body([message_event("@don:test", "approve")], "b2"), ] received = collect_messages(responses, expected=1) assert received == [InboundMessage(sender="@don:test", text="approve")] def test_own_and_non_message_events_are_filtered() -> None: responses = [ sync_body([], "b1"), sync_body( [ message_event("@nisse-dev:test", "my own echo"), {"type": "m.room.member", "sender": "@don:test", "content": {}}, message_event("@don:test", "status"), ], "b2", ), ] received = collect_messages(responses, expected=1) assert received == [InboundMessage(sender="@don:test", text="status")] def test_first_sync_learns_position_without_delivering() -> None: script = SyncScript([sync_body([message_event("@don:test", "old")], "b1")]) transport = MatrixTransport( CONFIG, poll_timeout_ms=10, http_transport=httpx.MockTransport(script) ) got = threading.Event() transport.on_message(lambda msg: got.set()) transport.start() assert not got.wait(0.3) transport.close() assert script.batches_seen[0] is None # initial sync carries no since assert "b1" in script.batches_seen # later polls resume from its batch def test_start_without_handler_raises() -> None: transport = MatrixTransport( CONFIG, http_transport=httpx.MockTransport(lambda r: httpx.Response(200)) ) with pytest.raises(TransportError, match="no message handler"): transport.start() # --- leading mentions -------------------------------------------------------- def own_member_state(name: str) -> dict: """The first sync's room state, which carries the agent's display name.""" body = sync_body([], "b1") body["rooms"]["join"]["!room:test"]["state"] = { "events": [ { "type": "m.room.member", "state_key": "@nisse-dev:test", "sender": "@nisse-dev:test", "content": {"membership": "join", "displayname": name}, } ] } return body def pill_event(sender: str, user_id: str, label: str, rest: str) -> dict: """A message as Element sends one that starts by mentioning user_id.""" return { "type": "m.room.message", "sender": sender, "content": { "msgtype": "m.text", "body": f"{label}: {rest}", "format": "org.matrix.custom.html", "formatted_body": ( f'{label}: {rest}' ), }, } def test_a_leading_pill_for_the_agent_is_removed_before_matching() -> None: responses = [ sync_body([], "b1"), sync_body( [pill_event("@don:test", "@nisse-dev:test", "Nisse Dev", "approve")], "b2", ), ] [msg] = collect_messages(responses, expected=1) assert msg.sender == "@don:test" # from the event, never from the text assert msg.text == "approve" assert msg.original == "Nisse Dev: approve" def test_the_agents_display_name_typed_in_front_is_removed() -> None: responses = [ own_member_state("Nisse Dev"), sync_body([message_event("@don:test", "Nisse Dev: re-plan")], "b2"), ] [msg] = collect_messages(responses, expected=1) assert msg.text == "re-plan" def test_the_agents_user_id_typed_in_front_is_removed() -> None: responses = [ sync_body([], "b1"), sync_body([message_event("@don:test", "@nisse-dev:test: status")], "b2"), ] [msg] = collect_messages(responses, expected=1) assert msg.text == "status" def test_messages_addressed_to_someone_else_are_skipped() -> None: responses = [ sync_body([], "b1"), sync_body( [ pill_event("@don:test", "@nisse-ops:test", "Nisse Ops", "approve"), message_event("@don:test", "@alice:test: looks good"), message_event("@don:test", "status"), ], "b2", ), ] received = collect_messages(responses, expected=1) assert received == [InboundMessage(sender="@don:test", text="status")] def test_a_mention_later_in_the_message_is_left_alone() -> None: responses = [ sync_body([], "b1"), sync_body([message_event("@don:test", "approve, cc @alice:test")], "b2"), ] received = collect_messages(responses, expected=1) 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_core.conversation.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)