Merge pull request 'Handle leading mentions in Matrix messages' (#60) from matrix-mentions into main
CI / checks (push) Successful in 1m18s
CI / checks (push) Successful in 1m18s
This commit was merged in pull request #60.
This commit is contained in:
commit
8283e9eb1c
5 files changed
+199
-8
No files matched your search
@@ -314,7 +314,10 @@ class Orchestrator:
|
|||||||
|
|
||||||
def _record(self, msg: InboundMessage) -> None:
|
def _record(self, msg: InboundMessage) -> None:
|
||||||
self._store.append_event(
|
self._store.append_event(
|
||||||
UserMessage(text=msg.text.strip(), source=f"transport:{msg.sender}")
|
UserMessage(
|
||||||
|
text=(msg.original or msg.text).strip(),
|
||||||
|
source=f"transport:{msg.sender}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _finish_stop(self, msg: InboundMessage) -> None:
|
def _finish_stop(self, msg: InboundMessage) -> None:
|
||||||
@@ -356,7 +359,10 @@ class Orchestrator:
|
|||||||
# turns see feedback and answers as conversation rather than as
|
# turns see feedback and answers as conversation rather than as
|
||||||
# out-of-band parameters (A10).
|
# out-of-band parameters (A10).
|
||||||
self._store.append_event(
|
self._store.append_event(
|
||||||
UserMessage(text=msg.text.strip(), source=f"transport:{msg.sender}")
|
UserMessage(
|
||||||
|
text=(msg.original or msg.text).strip(),
|
||||||
|
source=f"transport:{msg.sender}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
state = self._store.get_state()
|
state = self._store.get_state()
|
||||||
current = state.state if state else IDLE
|
current = state.state if state else IDLE
|
||||||
|
|||||||
@@ -11,15 +11,26 @@ loop backs off (capped) and retries indefinitely — an outage delays reactions
|
|||||||
but never fails anything.
|
but never fails anything.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nisse_dev.transport import InboundMessage
|
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):
|
class TransportError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -50,6 +61,9 @@ class MatrixTransport:
|
|||||||
transport=http_transport,
|
transport=http_transport,
|
||||||
)
|
)
|
||||||
self._callback: Callable[[InboundMessage], None] | None = None
|
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._stop = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
@@ -119,24 +133,83 @@ class MatrixTransport:
|
|||||||
continue
|
continue
|
||||||
backoff = 1.0
|
backoff = 1.0
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
self._learn_display_name(data)
|
||||||
if since is not None:
|
if since is not None:
|
||||||
self._deliver(data)
|
self._deliver(data)
|
||||||
since = data.get("next_batch", since)
|
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:
|
def _deliver(self, sync_data: dict) -> None:
|
||||||
room = sync_data.get("rooms", {}).get("join", {}).get(self._config.room_id, {})
|
for event in self._room(sync_data).get("timeline", {}).get("events", []):
|
||||||
for event in room.get("timeline", {}).get("events", []):
|
|
||||||
if event.get("type") != "m.room.message":
|
if event.get("type") != "m.room.message":
|
||||||
continue
|
continue
|
||||||
sender = event.get("sender", "")
|
sender = event.get("sender", "")
|
||||||
if sender == self._config.user_id:
|
if sender == self._config.user_id:
|
||||||
continue
|
continue
|
||||||
body = event.get("content", {}).get("body")
|
content = event.get("content", {})
|
||||||
|
body = content.get("body")
|
||||||
if not body:
|
if not body:
|
||||||
continue
|
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
|
assert self._callback is not None
|
||||||
self._callback(InboundMessage(sender=sender, text=body))
|
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 (A4)."""
|
||||||
|
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:
|
def close(self) -> None:
|
||||||
self.stop()
|
self.stop()
|
||||||
self._http.close()
|
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()
|
||||||
@@ -13,7 +13,11 @@ from typing import Protocol
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
sender: str
|
sender: str
|
||||||
|
# What commands are matched against: a leading mention of the agent,
|
||||||
|
# which only says who the message is for, is already removed.
|
||||||
text: str
|
text: str
|
||||||
|
# The message as sent, when that differs from text; the record keeps it.
|
||||||
|
original: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -51,6 +55,6 @@ class InMemoryTransport:
|
|||||||
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
|
def on_message(self, callback: Callable[[InboundMessage], None]) -> None:
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
|
|
||||||
def receive(self, sender: str, text: str) -> None:
|
def receive(self, sender: str, text: str, original: str | None = None) -> None:
|
||||||
assert self._callback is not None, "no message handler registered"
|
assert self._callback is not None, "no message handler registered"
|
||||||
self._callback(InboundMessage(sender, text))
|
self._callback(InboundMessage(sender, text, original))
|
||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nisse_dev.canonical import UserMessage
|
||||||
from nisse_dev.control import (
|
from nisse_dev.control import (
|
||||||
AWAIT_ARCH_SIGNOFF,
|
AWAIT_ARCH_SIGNOFF,
|
||||||
AWAIT_INPUT,
|
AWAIT_INPUT,
|
||||||
@@ -125,6 +126,19 @@ def test_start_milestone_requires_approval_first(h: Harness) -> None:
|
|||||||
assert h.state == EXECUTING
|
assert h.state == EXECUTING
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_message_is_matched_as_stripped_but_recorded_as_sent(
|
||||||
|
h: Harness,
|
||||||
|
) -> None:
|
||||||
|
h.transport.receive("don", "use demo")
|
||||||
|
h.transport.receive("don", "approve", original="Nisse Dev: approve")
|
||||||
|
assert h.state == AWAIT_MILESTONE_SIGNOFF
|
||||||
|
recorded = [
|
||||||
|
e.event.text for e in h.store.read_events() if isinstance(e.event, UserMessage)
|
||||||
|
]
|
||||||
|
assert "Nisse Dev: approve" in recorded
|
||||||
|
assert "approve" not in recorded
|
||||||
|
|
||||||
|
|
||||||
def test_status_reports_state_and_gate(h: Harness) -> None:
|
def test_status_reports_state_and_gate(h: Harness) -> None:
|
||||||
h.transport.receive("don", "use demo")
|
h.transport.receive("don", "use demo")
|
||||||
h.transport.receive("don", "status")
|
h.transport.receive("don", "status")
|
||||||
|
|||||||
@@ -143,3 +143,97 @@ def test_start_without_handler_raises() -> None:
|
|||||||
)
|
)
|
||||||
with pytest.raises(TransportError, match="no message handler"):
|
with pytest.raises(TransportError, match="no message handler"):
|
||||||
transport.start()
|
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")
|
||||||
|
]
|
||||||
Reference in new issue
Block a user