CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m24s
Clients put the addressee's name in front of a message that mentions
it ("Nisse Dev Testing: re-plan"), and the transport passed that text
on whole, so no command matched: the live run's re-plan was asked again,
and an addressed 'approve' would have counted as rejection feedback.
- A leading mention of the agent (an HTML pill, its user ID, or its
display name followed by ':') is removed before commands are matched.
The display name is learnt from the agent's own member event.
- A message whose leading mention is someone else is skipped: in a
shared room it is addressed to another agent or person.
- Only the start counts; a mention later in the text is left alone.
- The transcript records the message as sent (InboundMessage.original).
The sender is unaffected: it still comes from the event's homeserver-set
sender field, never from the text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
240 lines
7.6 KiB
Python
240 lines
7.6 KiB
Python
import json
|
|
import threading
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from nisse_dev.matrix import MatrixConfig, MatrixTransport, TransportError
|
|
from nisse_dev.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'<a href="https://matrix.to/#/{user_id}">{label}</a>: {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")
|
|
]
|