fix(matrix): add outbound mention payloads
This commit is contained in:
parent
d7528d43ac
commit
6769a0aece
@ -114,6 +114,9 @@ _CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
|
|||||||
# Grace period: ignore messages older than this many seconds before startup.
|
# Grace period: ignore messages older than this many seconds before startup.
|
||||||
_STARTUP_GRACE_SECONDS = 5
|
_STARTUP_GRACE_SECONDS = 5
|
||||||
|
|
||||||
|
_OUTBOUND_MENTION_RE = re.compile(
|
||||||
|
r"(?<![\w/])(@[0-9A-Za-z._=/-]+:[0-9A-Za-z.-]+(?::\d+)?)"
|
||||||
|
)
|
||||||
|
|
||||||
_E2EE_INSTALL_HINT = (
|
_E2EE_INSTALL_HINT = (
|
||||||
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
||||||
@ -728,16 +731,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
last_event_id = None
|
last_event_id = None
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
msg_content: Dict[str, Any] = {
|
msg_content = self._build_text_message_content(chunk)
|
||||||
"msgtype": "m.text",
|
|
||||||
"body": chunk,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Convert markdown to HTML for rich rendering.
|
|
||||||
html = self._markdown_to_html(chunk)
|
|
||||||
if html and html != chunk:
|
|
||||||
msg_content["format"] = "org.matrix.custom.html"
|
|
||||||
msg_content["formatted_body"] = html
|
|
||||||
|
|
||||||
# Reply-to support.
|
# Reply-to support.
|
||||||
if reply_to:
|
if reply_to:
|
||||||
@ -844,25 +838,21 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
"""Edit an existing message (via m.replace)."""
|
"""Edit an existing message (via m.replace)."""
|
||||||
|
|
||||||
formatted = self.format_message(content)
|
formatted = self.format_message(content)
|
||||||
|
new_content = self._build_text_message_content(formatted)
|
||||||
msg_content: Dict[str, Any] = {
|
msg_content: Dict[str, Any] = {
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
"body": f"* {formatted}",
|
"body": f"* {formatted}",
|
||||||
"m.new_content": {
|
"m.new_content": new_content,
|
||||||
"msgtype": "m.text",
|
|
||||||
"body": formatted,
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
"rel_type": "m.replace",
|
|
||||||
"event_id": message_id,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
if "m.mentions" in new_content:
|
||||||
html = self._markdown_to_html(formatted)
|
msg_content["m.mentions"] = new_content["m.mentions"]
|
||||||
if html and html != formatted:
|
if "formatted_body" in new_content:
|
||||||
msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
|
|
||||||
msg_content["m.new_content"]["formatted_body"] = html
|
|
||||||
msg_content["format"] = "org.matrix.custom.html"
|
msg_content["format"] = "org.matrix.custom.html"
|
||||||
msg_content["formatted_body"] = f"* {html}"
|
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
|
||||||
|
msg_content["m.relates_to"] = {
|
||||||
|
"rel_type": "m.replace",
|
||||||
|
"event_id": message_id,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event_id = await self._client.send_message_event(
|
event_id = await self._client.send_message_event(
|
||||||
@ -1979,11 +1969,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
if not self._client or not text:
|
if not self._client or not text:
|
||||||
return SendResult(success=False, error="No client or empty text")
|
return SendResult(success=False, error="No client or empty text")
|
||||||
|
|
||||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
msg_content = self._build_text_message_content(text, msgtype=msgtype)
|
||||||
html = self._markdown_to_html(text)
|
|
||||||
if html and html != text:
|
|
||||||
msg_content["format"] = "org.matrix.custom.html"
|
|
||||||
msg_content["formatted_body"] = html
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event_id = await self._client.send_message_event(
|
event_id = await self._client.send_message_event(
|
||||||
@ -2046,6 +2032,77 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
# Mention detection helpers
|
# Mention detection helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dict[str, Any]:
|
||||||
|
"""Build Matrix text content with HTML and outbound mention metadata."""
|
||||||
|
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||||
|
mention_user_ids = self._extract_outbound_mentions(text)
|
||||||
|
if mention_user_ids:
|
||||||
|
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||||
|
|
||||||
|
html_source = self._inject_outbound_mention_links(text)
|
||||||
|
html = self._markdown_to_html(html_source)
|
||||||
|
if html and html != text:
|
||||||
|
msg_content["format"] = "org.matrix.custom.html"
|
||||||
|
msg_content["formatted_body"] = html
|
||||||
|
|
||||||
|
return msg_content
|
||||||
|
|
||||||
|
def _extract_outbound_mentions(self, text: str) -> list[str]:
|
||||||
|
"""Return unique Matrix user IDs mentioned in outbound text."""
|
||||||
|
protected, _ = self._protect_outbound_mention_regions(text)
|
||||||
|
seen: Set[str] = set()
|
||||||
|
mentions: list[str] = []
|
||||||
|
for match in _OUTBOUND_MENTION_RE.finditer(protected):
|
||||||
|
user_id = match.group(1)
|
||||||
|
if user_id not in seen:
|
||||||
|
seen.add(user_id)
|
||||||
|
mentions.append(user_id)
|
||||||
|
return mentions
|
||||||
|
|
||||||
|
def _inject_outbound_mention_links(self, text: str) -> str:
|
||||||
|
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
protected, placeholders = self._protect_outbound_mention_regions(text)
|
||||||
|
|
||||||
|
linked = _OUTBOUND_MENTION_RE.sub(
|
||||||
|
lambda match: f"[{match.group(1)}](https://matrix.to/#/{match.group(1)})",
|
||||||
|
protected,
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, original in enumerate(placeholders):
|
||||||
|
linked = linked.replace(f"\x00MENTION_PROTECTED{idx}\x00", original)
|
||||||
|
|
||||||
|
return linked
|
||||||
|
|
||||||
|
def _protect_outbound_mention_regions(self, text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Protect markdown regions where outbound mentions should stay literal."""
|
||||||
|
placeholders: list[str] = []
|
||||||
|
|
||||||
|
def _protect(fragment: str) -> str:
|
||||||
|
idx = len(placeholders)
|
||||||
|
placeholders.append(fragment)
|
||||||
|
return f"\x00MENTION_PROTECTED{idx}\x00"
|
||||||
|
|
||||||
|
protected = re.sub(
|
||||||
|
r"```[\s\S]*?```",
|
||||||
|
lambda match: _protect(match.group(0)),
|
||||||
|
text or "",
|
||||||
|
)
|
||||||
|
protected = re.sub(
|
||||||
|
r"`[^`\n]+`",
|
||||||
|
lambda match: _protect(match.group(0)),
|
||||||
|
protected,
|
||||||
|
)
|
||||||
|
protected = re.sub(
|
||||||
|
r"\[[^\]]+\]\([^)]+\)",
|
||||||
|
lambda match: _protect(match.group(0)),
|
||||||
|
protected,
|
||||||
|
)
|
||||||
|
|
||||||
|
return protected, placeholders
|
||||||
|
|
||||||
def _is_bot_mentioned(
|
def _is_bot_mentioned(
|
||||||
self,
|
self,
|
||||||
body: str,
|
body: str,
|
||||||
|
|||||||
@ -173,6 +173,83 @@ class TestStripMention:
|
|||||||
assert result == ""
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Outbound mention payloads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutboundMentions:
|
||||||
|
def setup_method(self):
|
||||||
|
self.adapter = _make_adapter()
|
||||||
|
self.mock_client = MagicMock()
|
||||||
|
self.mock_client.send_message_event = AsyncMock(return_value="$evt1")
|
||||||
|
self.adapter._client = self.mock_client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sent_content(mock_client):
|
||||||
|
call_args = mock_client.send_message_event.call_args
|
||||||
|
return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_adds_matrix_mentions_and_formatted_body(self):
|
||||||
|
result = await self.adapter.send(
|
||||||
|
"!room1:example.org",
|
||||||
|
"Hello @alice:example.org, please check this.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
content = self._sent_content(self.mock_client)
|
||||||
|
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||||
|
assert content["formatted_body"] == (
|
||||||
|
'Hello <a href="https://matrix.to/#/@alice:example.org">'
|
||||||
|
"@alice:example.org</a>, please check this."
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_dedupes_mentions_and_ignores_code_spans(self):
|
||||||
|
await self.adapter.send(
|
||||||
|
"!room1:example.org",
|
||||||
|
"Ping @alice:example.org and @alice:example.org, not `@code:example.org`.",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = self._sent_content(self.mock_client)
|
||||||
|
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||||
|
assert "@code:example.org</a>" not in content["formatted_body"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_message_preserves_mentions(self):
|
||||||
|
result = await self.adapter.edit_message(
|
||||||
|
"!room1:example.org",
|
||||||
|
"$original",
|
||||||
|
"Updated for @alice:example.org",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
content = self._sent_content(self.mock_client)
|
||||||
|
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||||
|
assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||||
|
assert content["m.new_content"]["formatted_body"] == (
|
||||||
|
'Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||||
|
"@alice:example.org</a>"
|
||||||
|
)
|
||||||
|
assert content["formatted_body"] == (
|
||||||
|
'* Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||||
|
"@alice:example.org</a>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_notice_adds_mentions(self):
|
||||||
|
result = await self.adapter.send_notice(
|
||||||
|
"!room1:example.org",
|
||||||
|
"Heads up @alice:example.org",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
content = self._sent_content(self.mock_client)
|
||||||
|
assert content["msgtype"] == "m.notice"
|
||||||
|
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Require-mention gating in _on_room_message
|
# Require-mention gating in _on_room_message
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user