From be525947665cbc419a0dfb5646c7a496433dbb9c Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 2 Apr 2026 02:00:24 -0400 Subject: [PATCH] fix(matrix): emit spec-compliant mentions (#59323) Merged via squash. Prepared head SHA: 4b641e35a293540e8b946fc7a7a57407fa76680c Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../src/matrix/actions/messages.test.ts | 76 ++++- .../matrix/src/matrix/actions/messages.ts | 29 +- extensions/matrix/src/matrix/format.test.ts | 229 ++++++++++++- extensions/matrix/src/matrix/format.ts | 320 ++++++++++++++++++ .../matrix/src/matrix/monitor/config.ts | 5 +- extensions/matrix/src/matrix/send.test.ts | 204 ++++++++++- extensions/matrix/src/matrix/send.ts | 83 ++++- .../matrix/src/matrix/send/formatting.ts | 82 ++++- extensions/matrix/src/matrix/send/media.ts | 2 - 10 files changed, 988 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea63e12380..2778756f2c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon - Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman. - Diffs: add plugin-owned `viewerBaseUrl` so viewer links can use a stable proxy/public origin without passing `baseUrl` on every tool call. (#59341) Related #59227. Thanks @gumadeiras. +- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras. ### Fixes diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts index e74925f7b5b..a173ba95aa8 100644 --- a/extensions/matrix/src/matrix/actions/messages.test.ts +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; -import { readMatrixMessages } from "./messages.js"; +import * as sendModule from "../send.js"; +import { editMatrixMessage, readMatrixMessages } from "./messages.js"; + +function installMatrixActionTestRuntime(): void { + setMatrixRuntime({ + config: { + loadConfig: () => ({}), + }, + channel: { + text: { + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, + }, + }, + } as unknown as import("../../runtime-api.js").PluginRuntime); +} function createPollResponseEvent(): Record { return { @@ -74,6 +90,64 @@ function createMessagesClient(params: { } describe("matrix message actions", () => { + it("forwards timeoutMs to the shared Matrix edit helper", async () => { + const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit"); + + try { + const result = await editMatrixMessage("!room:example.org", "$original", "hello", { + timeoutMs: 12_345, + }); + + expect(result).toEqual({ eventId: "evt-edit" }); + expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", { + cfg: undefined, + accountId: undefined, + client: undefined, + timeoutMs: 12_345, + }); + } finally { + editSpy.mockRestore(); + } + }); + + it("routes edits through the shared Matrix edit helper so mentions are preserved", async () => { + installMatrixActionTestRuntime(); + const sendMessage = vi.fn().mockResolvedValue("evt-edit"); + const client = { + getEvent: vi.fn().mockResolvedValue({ + content: { + body: "hello @alice:example.org", + "m.mentions": { user_ids: ["@alice:example.org"] }, + }, + }), + getJoinedRoomMembers: vi.fn().mockResolvedValue([]), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + sendMessage, + prepareForOneOff: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(() => undefined), + stopAndPersist: vi.fn(async () => undefined), + } as unknown as MatrixClient; + + const result = await editMatrixMessage( + "!room:example.org", + "$original", + "hello @alice:example.org and @bob:example.org", + { client }, + ); + + expect(result).toEqual({ eventId: "evt-edit" }); + expect(sendMessage).toHaveBeenCalledWith( + "!room:example.org", + expect.objectContaining({ + "m.mentions": { user_ids: ["@bob:example.org"] }, + "m.new_content": expect.objectContaining({ + "m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] }, + }), + }), + ); + }); + it("includes poll snapshots when reading message history", async () => { const { client, doRequest, getEvent, getRelations } = createMessagesClient({ chunk: [ diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index a670c708c27..a58c9e8aa43 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -1,17 +1,14 @@ import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js"; import { isPollEventType } from "../poll-types.js"; -import { sendMessageMatrix } from "../send.js"; -import { withResolvedActionClient, withResolvedRoomAction } from "./client.js"; +import { editMessageMatrix, sendMessageMatrix } from "../send.js"; +import { withResolvedRoomAction } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; import { summarizeMatrixRawEvent } from "./summary.js"; import { EventType, - MsgType, - RelationType, type MatrixActionClientOpts, type MatrixMessageSummary, type MatrixRawEvent, - type RoomMessageEventContent, } from "./types.js"; export async function sendMatrixMessage( @@ -47,23 +44,13 @@ export async function editMatrixMessage( if (!trimmed) { throw new Error("Matrix edit requires content"); } - return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => { - const newContent = { - msgtype: MsgType.Text, - body: trimmed, - } satisfies RoomMessageEventContent; - const payload: RoomMessageEventContent = { - msgtype: MsgType.Text, - body: `* ${trimmed}`, - "m.new_content": newContent, - "m.relates_to": { - rel_type: RelationType.Replace, - event_id: messageId, - }, - }; - const eventId = await client.sendMessage(resolvedRoom, payload); - return { eventId: eventId ?? null }; + const eventId = await editMessageMatrix(roomId, messageId, trimmed, { + cfg: opts.cfg, + accountId: opts.accountId ?? undefined, + client: opts.client, + timeoutMs: opts.timeoutMs, }); + return { eventId: eventId || null }; } export async function deleteMatrixMessage( diff --git a/extensions/matrix/src/matrix/format.test.ts b/extensions/matrix/src/matrix/format.test.ts index c929514ee17..1d1fc97a32f 100644 --- a/extensions/matrix/src/matrix/format.test.ts +++ b/extensions/matrix/src/matrix/format.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { markdownToMatrixHtml } from "./format.js"; +import { markdownToMatrixHtml, renderMarkdownToMatrixHtmlWithMentions } from "./format.js"; + +function createMentionClient(selfUserId = "@bot:example.org") { + return { + getUserId: async () => selfUserId, + } as unknown as import("./sdk.js").MatrixClient; +} describe("markdownToMatrixHtml", () => { it("renders basic inline formatting", () => { @@ -43,4 +49,225 @@ describe("markdownToMatrixHtml", () => { const html = markdownToMatrixHtml("line1\nline2"); expect(html).toContain(" { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"'); + expect(result.mentions).toEqual({ + user_ids: ["@alice:example.org"], + }); + }); + + it("url-encodes matrix.to hrefs for valid mxids with path characters", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @foo/bar:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40foo%2Fbar%3Aexample.org"'); + expect(result.mentions).toEqual({ + user_ids: ["@foo/bar:example.org"], + }); + }); + + it("treats mxids that begin with room as user mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @room:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40room%3Aexample.org"'); + expect(result.mentions).toEqual({ + user_ids: ["@room:example.org"], + }); + }); + + it("treats hyphenated room-prefixed mxids as user mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @room-admin:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40room-admin%3Aexample.org"'); + expect(result.mentions).toEqual({ + user_ids: ["@room-admin:example.org"], + }); + }); + + it("keeps explicit room mentions as room mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @room", + client: createMentionClient(), + }); + + expect(result.html).toContain("@room"); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({ + room: true, + }); + }); + + it("treats sentence-ending room mentions as room mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @room.", + client: createMentionClient(), + }); + + expect(result.html).toContain("hello @room."); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({ + room: true, + }); + }); + + it("treats colon-suffixed room mentions as room mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @room:", + client: createMentionClient(), + }); + + expect(result.html).toContain("hello @room:"); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({ + room: true, + }); + }); + + it("trims punctuation before storing mentioned user ids", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice:example.org.", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"'); + expect(result.html).toContain("@alice:example.org."); + expect(result.mentions).toEqual({ + user_ids: ["@alice:example.org"], + }); + }); + + it("does not emit mentions for mxid-like tokens with path suffixes", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice:example.org/path", + client: createMentionClient(), + }); + + expect(result.html).toContain("@alice:example.org/path"); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({}); + }); + + it("accepts bracketed homeservers in matrix mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice:[2001:db8::1]", + client: createMentionClient(), + }); + + expect(result.html).toContain('href="https://matrix.to/#/%40alice%3A%5B2001%3Adb8%3A%3A1%5D"'); + expect(result.mentions).toEqual({ + user_ids: ["@alice:[2001:db8::1]"], + }); + }); + + it("accepts bracketed homeservers with ports in matrix mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice:[2001:db8::1]:8448.", + client: createMentionClient(), + }); + + expect(result.html).toContain( + 'href="https://matrix.to/#/%40alice%3A%5B2001%3Adb8%3A%3A1%5D%3A8448"', + ); + expect(result.html).toContain("@alice:[2001:db8::1]:8448."); + expect(result.mentions).toEqual({ + user_ids: ["@alice:[2001:db8::1]:8448"], + }); + }); + + it("leaves bare localpart text unmentioned", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "hello @alice", + client: createMentionClient(), + }); + + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({}); + }); + + it("does not convert escaped qualified mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "\\@alice:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain("@alice:example.org"); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({}); + }); + + it("does not convert escaped room mentions", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "\\@room", + client: createMentionClient(), + }); + + expect(result.html).toContain("@room"); + expect(result.mentions).toEqual({}); + }); + + it("restores escaped mentions in markdown link labels without linking them", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "[\\@alice:example.org](https://example.com)", + client: createMentionClient(), + }); + + expect(result.html).toContain('@alice:example.org'); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({}); + }); + + it("keeps backslashes inside code spans", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "`\\@alice:example.org`", + client: createMentionClient(), + }); + + expect(result.html).toContain("\\@alice:example.org"); + expect(result.mentions).toEqual({}); + }); + + it("does not convert mentions inside code spans", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "`@alice:example.org`", + client: createMentionClient(), + }); + + expect(result.html).toContain("@alice:example.org"); + expect(result.html).not.toContain("matrix.to"); + expect(result.mentions).toEqual({}); + }); + + it("keeps backslashes inside tilde fenced code blocks", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: "~~~\n\\@alice:example.org\n~~~", + client: createMentionClient(), + }); + + expect(result.html).toContain("
\\@alice:example.org\n
"); + expect(result.mentions).toEqual({}); + }); + + it("keeps backslashes inside indented code blocks", async () => { + const result = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: " \\@alice:example.org", + client: createMentionClient(), + }); + + expect(result.html).toContain("
\\@alice:example.org\n
"); + expect(result.mentions).toEqual({}); + }); }); diff --git a/extensions/matrix/src/matrix/format.ts b/extensions/matrix/src/matrix/format.ts index efb81ebff2a..8469e18bc48 100644 --- a/extensions/matrix/src/matrix/format.ts +++ b/extensions/matrix/src/matrix/format.ts @@ -1,5 +1,7 @@ import MarkdownIt from "markdown-it"; import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime"; +import type { MatrixClient } from "./sdk.js"; +import { isMatrixQualifiedUserId } from "./target-ids.js"; const md = new MarkdownIt({ html: false, @@ -11,6 +13,28 @@ const md = new MarkdownIt({ md.enable("strikethrough"); const { escapeHtml } = md.utils; + +export type MatrixMentions = { + room?: boolean; + user_ids?: string[]; +}; + +type MarkdownToken = ReturnType[number]; +type MarkdownInlineToken = NonNullable[number]; +type MatrixMentionCandidate = { + raw: string; + start: number; + end: number; + kind: "room" | "user"; + userId?: string; +}; + +const ESCAPED_MENTION_SENTINEL = "\uE000"; +const MENTION_PATTERN = /@[A-Za-z0-9._=+\-/:\[\]]+/g; +const MATRIX_MENTION_USER_ID_PATTERN = + /^@[A-Za-z0-9._=+\-/]+:(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/; +const TRIMMABLE_MENTION_SUFFIX = /[),.!?:;\]]/; + function shouldSuppressAutoLink( tokens: Parameters>[0], idx: number, @@ -38,7 +62,303 @@ md.renderer.rules.link_close = (tokens, idx, _options, _env, self) => { return self.renderToken(tokens, idx, _options); }; +function maskEscapedMentions(markdown: string): string { + let masked = ""; + let idx = 0; + let codeFenceLength = 0; + + while (idx < markdown.length) { + if (markdown[idx] === "`") { + let runLength = 1; + while (markdown[idx + runLength] === "`") { + runLength += 1; + } + if (codeFenceLength === 0) { + codeFenceLength = runLength; + } else if (runLength === codeFenceLength) { + codeFenceLength = 0; + } + masked += markdown.slice(idx, idx + runLength); + idx += runLength; + continue; + } + if (codeFenceLength === 0 && markdown[idx] === "\\" && markdown[idx + 1] === "@") { + masked += ESCAPED_MENTION_SENTINEL; + idx += 2; + continue; + } + masked += markdown[idx] ?? ""; + idx += 1; + } + + return masked; +} + +function restoreEscapedMentions(text: string): string { + return text.replaceAll(ESCAPED_MENTION_SENTINEL, "@"); +} + +function restoreEscapedMentionsInCode(text: string): string { + return text.replaceAll(ESCAPED_MENTION_SENTINEL, "\\@"); +} + +function restoreEscapedMentionsInBlockTokens(tokens: MarkdownToken[]): void { + for (const token of tokens) { + if ((token.type === "fence" || token.type === "code_block") && token.content) { + token.content = restoreEscapedMentionsInCode(token.content); + } + } +} + +function isMentionStartBoundary(charBefore: string | undefined): boolean { + return !charBefore || !/[A-Za-z0-9_]/.test(charBefore); +} + +function trimMentionSuffix(raw: string, end: number): { raw: string; end: number } | null { + while (raw.length > 1 && TRIMMABLE_MENTION_SUFFIX.test(raw.at(-1) ?? "")) { + if (raw.at(-1) === "]" && /\[[0-9A-Fa-f:.]+\](?::\d+)?$/i.test(raw)) { + break; + } + raw = raw.slice(0, -1); + end -= 1; + } + if (!raw.startsWith("@") || raw === "@") { + return null; + } + return { raw, end }; +} + +function isMatrixMentionUserId(raw: string): boolean { + return isMatrixQualifiedUserId(raw) && MATRIX_MENTION_USER_ID_PATTERN.test(raw); +} + +function buildMentionCandidate(raw: string, start: number): MatrixMentionCandidate | null { + const normalized = trimMentionSuffix(raw, start + raw.length); + if (!normalized) { + return null; + } + const kind = normalized.raw.toLowerCase() === "@room" ? "room" : "user"; + const base: MatrixMentionCandidate = { + raw: normalized.raw, + start, + end: normalized.end, + kind, + }; + if (kind === "room") { + return base; + } + const userCandidate = isMatrixMentionUserId(normalized.raw) + ? { ...base, userId: normalized.raw } + : null; + if (!userCandidate) { + return null; + } + return userCandidate; +} + +function collectMentionCandidates(text: string): MatrixMentionCandidate[] { + const mentions: MatrixMentionCandidate[] = []; + for (const match of text.matchAll(MENTION_PATTERN)) { + const raw = match[0]; + const start = match.index ?? -1; + if (start < 0 || !raw) { + continue; + } + if (!isMentionStartBoundary(text[start - 1])) { + continue; + } + const candidate = buildMentionCandidate(raw, start); + if (!candidate) { + continue; + } + mentions.push(candidate); + } + return mentions; +} + +function createToken( + sample: MarkdownInlineToken, + type: string, + tag: string, + nesting: number, +): MarkdownInlineToken { + const TokenCtor = sample.constructor as new ( + type: string, + tag: string, + nesting: number, + ) => MarkdownInlineToken; + return new TokenCtor(type, tag, nesting); +} + +function createTextToken(sample: MarkdownInlineToken, content: string): MarkdownInlineToken { + const token = createToken(sample, "text", "", 0); + token.content = content; + return token; +} + +function createMentionLinkTokens(params: { + sample: MarkdownInlineToken; + href: string; + label: string; +}): MarkdownInlineToken[] { + const open = createToken(params.sample, "link_open", "a", 1); + open.attrSet("href", params.href); + const text = createTextToken(params.sample, params.label); + const close = createToken(params.sample, "link_close", "a", -1); + return [open, text, close]; +} + +function resolveMentionUserId(match: MatrixMentionCandidate): string | null { + if (match.kind !== "user") { + return null; + } + return match.userId ?? null; +} + +async function resolveMatrixSelfUserId(client: MatrixClient): Promise { + const getUserId = (client as { getUserId?: () => Promise | string }).getUserId; + if (typeof getUserId !== "function") { + return null; + } + return await Promise.resolve(getUserId.call(client)).catch(() => null); +} + +function mutateInlineTokensWithMentions(params: { + children: MarkdownInlineToken[]; + userIds: string[]; + seenUserIds: Set; + selfUserId: string | null; +}): { children: MarkdownInlineToken[]; roomMentioned: boolean } { + const nextChildren: MarkdownInlineToken[] = []; + let roomMentioned = false; + let insideLinkDepth = 0; + for (const child of params.children) { + if (child.type === "link_open") { + insideLinkDepth += 1; + nextChildren.push(child); + continue; + } + if (child.type === "link_close") { + insideLinkDepth = Math.max(0, insideLinkDepth - 1); + nextChildren.push(child); + continue; + } + if (child.type !== "text" || !child.content) { + nextChildren.push(child); + continue; + } + + const visibleContent = restoreEscapedMentions(child.content); + if (insideLinkDepth > 0) { + nextChildren.push(createTextToken(child, visibleContent)); + continue; + } + const matches = collectMentionCandidates(child.content); + if (matches.length === 0) { + nextChildren.push(createTextToken(child, visibleContent)); + continue; + } + + let cursor = 0; + for (const match of matches) { + if (match.start > cursor) { + nextChildren.push( + createTextToken(child, restoreEscapedMentions(child.content.slice(cursor, match.start))), + ); + } + cursor = match.end; + if (match.kind === "room") { + roomMentioned = true; + nextChildren.push(createTextToken(child, match.raw)); + continue; + } + + const resolvedUserId = resolveMentionUserId(match); + if (!resolvedUserId || resolvedUserId === params.selfUserId) { + nextChildren.push(createTextToken(child, match.raw)); + continue; + } + if (!params.seenUserIds.has(resolvedUserId)) { + params.seenUserIds.add(resolvedUserId); + params.userIds.push(resolvedUserId); + } + nextChildren.push( + ...createMentionLinkTokens({ + sample: child, + href: `https://matrix.to/#/${encodeURIComponent(resolvedUserId)}`, + label: match.raw, + }), + ); + } + if (cursor < child.content.length) { + nextChildren.push( + createTextToken(child, restoreEscapedMentions(child.content.slice(cursor))), + ); + } + } + return { children: nextChildren, roomMentioned }; +} + export function markdownToMatrixHtml(markdown: string): string { const rendered = md.render(markdown ?? ""); return rendered.trimEnd(); } + +async function resolveMarkdownMentionState(params: { + markdown: string; + client: MatrixClient; +}): Promise<{ tokens: MarkdownToken[]; mentions: MatrixMentions }> { + const markdown = maskEscapedMentions(params.markdown ?? ""); + const tokens = md.parse(markdown, {}); + restoreEscapedMentionsInBlockTokens(tokens); + const selfUserId = await resolveMatrixSelfUserId(params.client); + const userIds: string[] = []; + const seenUserIds = new Set(); + let roomMentioned = false; + + for (const token of tokens) { + if (!token.children?.length) { + continue; + } + const mutated = mutateInlineTokensWithMentions({ + children: token.children, + userIds, + seenUserIds, + selfUserId, + }); + token.children = mutated.children; + roomMentioned ||= mutated.roomMentioned; + } + + const mentions: MatrixMentions = {}; + if (userIds.length > 0) { + mentions.user_ids = userIds; + } + if (roomMentioned) { + mentions.room = true; + } + return { + tokens, + mentions, + }; +} + +export async function resolveMatrixMentionsInMarkdown(params: { + markdown: string; + client: MatrixClient; +}): Promise { + const state = await resolveMarkdownMentionState(params); + return state.mentions; +} + +export async function renderMarkdownToMatrixHtmlWithMentions(params: { + markdown: string; + client: MatrixClient; +}): Promise<{ html?: string; mentions: MatrixMentions }> { + const state = await resolveMarkdownMentionState(params); + const html = md.renderer.render(state.tokens, md.options, {}).trimEnd(); + return { + html: html || undefined, + mentions: state.mentions, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts index a8f92c758b6..c345e12cb02 100644 --- a/extensions/matrix/src/matrix/monitor/config.ts +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -1,5 +1,6 @@ import { resolveMatrixTargets } from "../../resolve-targets.js"; import type { CoreConfig, MatrixRoomConfig } from "../../types.js"; +import { isMatrixQualifiedUserId } from "../target-ids.js"; import { normalizeMatrixUserId } from "./allowlist.js"; import { addAllowlistUserEntriesFromConfigEntry, @@ -27,10 +28,6 @@ function normalizeMatrixRoomLookupEntry(raw: string): string { .trim(); } -function isMatrixQualifiedUserId(value: string): boolean { - return value.startsWith("@") && value.includes(":"); -} - function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] { return entries.filter((entry) => { const trimmed = entry.trim(); diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 93007a1d840..1c7397c2ebc 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -2,7 +2,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; import { voteMatrixPoll } from "./actions/polls.js"; -import { sendMessageMatrix, sendSingleTextMessageMatrix, sendTypingMatrix } from "./send.js"; +import { + editMessageMatrix, + sendMessageMatrix, + sendPollMatrix, + sendSingleTextMessageMatrix, + sendTypingMatrix, +} from "./send.js"; const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn()); const loadWebMediaMock = vi.fn().mockResolvedValue({ @@ -79,11 +85,13 @@ const makeClient = () => { const sendMessage = vi.fn().mockResolvedValue("evt1"); const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote"); const getEvent = vi.fn(); + const getJoinedRoomMembers = vi.fn().mockResolvedValue([]); const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); const client = { sendMessage, sendEvent, getEvent, + getJoinedRoomMembers, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), prepareForOneOff: vi.fn(async () => undefined), @@ -91,7 +99,7 @@ const makeClient = () => { stop: vi.fn(() => undefined), stopAndPersist: vi.fn(async () => undefined), } as unknown as import("./sdk.js").MatrixClient; - return { client, sendMessage, sendEvent, getEvent, uploadContent }; + return { client, sendMessage, sendEvent, getEvent, getJoinedRoomMembers, uploadContent }; }; function makeEncryptedMediaClient() { @@ -384,6 +392,132 @@ describe("sendMessageMatrix media", () => { }); }); +describe("sendMessageMatrix mentions", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMatrixSendRuntimeMocks(); + }); + + it("adds an empty m.mentions object for plain messages without mentions", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "hello", + "m.mentions": {}, + }); + }); + + it("emits m.mentions and matrix.to anchors for qualified user mentions", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello @alice:example.org", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "hello @alice:example.org", + "m.mentions": { user_ids: ["@alice:example.org"] }, + }); + expect( + (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body, + ).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"'); + }); + + it("keeps bare localpart text as plain text", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello @alice", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": {}, + }); + expect( + (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body, + ).not.toContain("matrix.to/#/@alice:example.org"); + }); + + it("does not emit mentions for escaped qualified users", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "\\@alice:example.org", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": {}, + }); + expect( + (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body, + ).not.toContain("matrix.to/#/@alice:example.org"); + }); + + it("does not emit mentions for escaped room mentions", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "\\@room please review", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": {}, + }); + }); + + it("marks room mentions via m.mentions.room", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "@room please review", { + client, + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": { room: true }, + }); + }); + + it("adds mention metadata to media captions", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "caption @alice:example.org", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": { user_ids: ["@alice:example.org"] }, + }); + }); + + it("does not emit mentions from fallback filenames when there is no caption", async () => { + const { client, sendMessage } = makeClient(); + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("media"), + fileName: "@room.png", + contentType: "image/png", + kind: "image", + }); + + await sendMessageMatrix("room:!room:example", "", { + client, + mediaUrl: "file:///tmp/room.png", + }); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + body: "@room.png", + "m.mentions": {}, + }); + expect( + (sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body, + ).toBeUndefined(); + }); +}); + describe("sendMessageMatrix threads", () => { beforeEach(() => { vi.clearAllMocks(); @@ -446,6 +580,72 @@ describe("sendSingleTextMessageMatrix", () => { }); }); +describe("editMessageMatrix mentions", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMatrixSendRuntimeMocks(); + }); + + it("stores full mentions in m.new_content and only newly-added mentions in the edit event", async () => { + const { client, sendMessage, getEvent } = makeClient(); + getEvent.mockResolvedValue({ + content: { + body: "hello @alice:example.org", + "m.mentions": { user_ids: ["@alice:example.org"] }, + }, + }); + + await editMessageMatrix( + "room:!room:example", + "$original", + "hello @alice:example.org and @bob:example.org", + { + client, + }, + ); + + expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({ + "m.mentions": { user_ids: ["@bob:example.org"] }, + "m.new_content": { + "m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] }, + }, + }); + }); +}); + +describe("sendPollMatrix mentions", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMatrixSendRuntimeMocks(); + }); + + it("adds m.mentions for poll fallback text", async () => { + const { client, sendEvent } = makeClient(); + + await sendPollMatrix( + "room:!room:example", + { + question: "@room lunch with @alice:example.org?", + options: ["yes", "no"], + }, + { + client, + }, + ); + + expect(sendEvent).toHaveBeenCalledWith( + "!room:example", + "m.poll.start", + expect.objectContaining({ + "m.mentions": { + room: true, + user_ids: ["@alice:example.org"], + }, + }), + ); + }); +}); + describe("voteMatrixPoll", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 96aff27abd0..6fcc42cbea3 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -15,6 +15,10 @@ import { buildReplyRelation, buildTextContent, buildThreadRelation, + diffMatrixMentions, + enrichMatrixFormattedContent, + extractMatrixMentions, + resolveMatrixMentionsForBody, resolveMatrixMsgType, resolveMatrixVoiceDecision, } from "./send/formatting.js"; @@ -79,6 +83,21 @@ function normalizeMatrixClientResolveOpts( }; } +function resolvePreviousEditContent(previousEvent: unknown): Record | undefined { + if (!previousEvent || typeof previousEvent !== "object") { + return undefined; + } + const eventRecord = previousEvent as { content?: unknown }; + if (!eventRecord.content || typeof eventRecord.content !== "object") { + return undefined; + } + const content = eventRecord.content as Record; + const newContent = content["m.new_content"]; + return newContent && typeof newContent === "object" + ? (newContent as Record) + : content; +} + export function prepareMatrixSingleText( text: string, opts: { @@ -197,7 +216,8 @@ export async function sendMessageMatrix( }) : undefined; const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const captionMarkdown = useVoice ? "" : (firstChunk ?? ""); + const body = useVoice ? "Voice message" : captionMarkdown || media.fileName || "(file)"; const content = buildMediaContent({ msgtype, body, @@ -211,6 +231,11 @@ export async function sendMessageMatrix( isVoice: useVoice, imageInfo, }); + await enrichMatrixFormattedContent({ + client, + content, + markdown: captionMarkdown, + }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; const textChunks = useVoice ? chunks : rest; @@ -223,6 +248,11 @@ export async function sendMessageMatrix( continue; } const followup = buildTextContent(text, followupRelation); + await enrichMatrixFormattedContent({ + client, + content: followup, + markdown: text, + }); const followupEventId = await sendContent(followup); lastMessageId = followupEventId ?? lastMessageId; } @@ -233,6 +263,11 @@ export async function sendMessageMatrix( continue; } const content = buildTextContent(text, relation); + await enrichMatrixFormattedContent({ + client, + content, + markdown: text, + }); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; } @@ -267,10 +302,17 @@ export async function sendPollMatrix( async (client) => { const roomId = await resolveMatrixRoomId(client, to); const pollContent = buildPollStartContent(poll); + const fallbackText = + pollContent["m.text"] ?? pollContent["org.matrix.msc1767.text"] ?? poll.question ?? ""; + const mentions = await resolveMatrixMentionsForBody({ + client, + body: fallbackText, + }); const threadId = normalizeThreadId(opts.threadId); - const pollPayload = threadId + const pollPayload: Record = threadId ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } - : pollContent; + : { ...pollContent }; + pollPayload["m.mentions"] = mentions; const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); return { @@ -351,6 +393,11 @@ export async function sendSingleTextMessageMatrix( ? buildThreadRelation(normalizedThreadId, opts.replyToId) : buildReplyRelation(opts.replyToId); const content = buildTextContent(convertedText, relation); + await enrichMatrixFormattedContent({ + client, + content, + markdown: convertedText, + }); const eventId = await client.sendMessage(resolvedRoom, content); return { messageId: eventId ?? "unknown", @@ -360,6 +407,22 @@ export async function sendSingleTextMessageMatrix( ); } +async function getPreviousMatrixEvent( + client: MatrixClient, + roomId: string, + eventId: string, +): Promise | null> { + const getEvent = ( + client as { + getEvent?: (roomId: string, eventId: string) => Promise>; + } + ).getEvent; + if (typeof getEvent !== "function") { + return null; + } + return await Promise.resolve(getEvent.call(client, roomId, eventId)).catch(() => null); +} + export async function editMessageMatrix( roomId: string, originalEventId: string, @@ -369,6 +432,7 @@ export async function editMessageMatrix( cfg?: CoreConfig; threadId?: string; accountId?: string; + timeoutMs?: number; } = {}, ): Promise { return await withResolvedMatrixSendClient( @@ -376,6 +440,7 @@ export async function editMessageMatrix( client: opts.client, cfg: opts.cfg, accountId: opts.accountId, + timeoutMs: opts.timeoutMs, }, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); @@ -387,6 +452,17 @@ export async function editMessageMatrix( }); const convertedText = getCore().channel.text.convertMarkdownTables(newText, tableMode); const newContent = buildTextContent(convertedText); + await enrichMatrixFormattedContent({ + client, + content: newContent, + markdown: convertedText, + }); + const previousEvent = await getPreviousMatrixEvent(client, resolvedRoom, originalEventId); + const previousContent = resolvePreviousEditContent(previousEvent); + const replaceMentions = diffMatrixMentions( + extractMatrixMentions(newContent), + extractMatrixMentions(previousContent), + ); const replaceRelation: Record = { rel_type: RelationType.Replace, @@ -407,6 +483,7 @@ export async function editMessageMatrix( ...(typeof newContent.formatted_body === "string" ? { formatted_body: `* ${newContent.formatted_body}` } : {}), + "m.mentions": replaceMentions, "m.new_content": newContent, "m.relates_to": replaceRelation, }; diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index bf0ed1989be..2a14ebabc07 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -1,5 +1,10 @@ import { getMatrixRuntime } from "../../runtime.js"; -import { markdownToMatrixHtml } from "../format.js"; +import { + resolveMatrixMentionsInMarkdown, + renderMarkdownToMatrixHtmlWithMentions, + type MatrixMentions, +} from "../format.js"; +import type { MatrixClient } from "../sdk.js"; import { MsgType, RelationType, @@ -14,7 +19,7 @@ import { const getCore = () => getMatrixRuntime(); export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent { - const content: MatrixTextContent = relation + return relation ? { msgtype: MsgType.Text, body, @@ -24,17 +29,76 @@ export function buildTextContent(body: string, relation?: MatrixRelation): Matri msgtype: MsgType.Text, body, }; - applyMatrixFormatting(content, body); - return content; } -export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { - const formatted = markdownToMatrixHtml(body ?? ""); - if (!formatted) { +export async function enrichMatrixFormattedContent(params: { + client: MatrixClient; + content: MatrixFormattedContent; + markdown?: string | null; +}): Promise { + const { html, mentions } = await renderMarkdownToMatrixHtmlWithMentions({ + markdown: params.markdown ?? "", + client: params.client, + }); + params.content["m.mentions"] = mentions; + if (!html) { + delete params.content.format; + delete params.content.formatted_body; return; } - content.format = "org.matrix.custom.html"; - content.formatted_body = formatted; + params.content.format = "org.matrix.custom.html"; + params.content.formatted_body = html; +} + +export async function resolveMatrixMentionsForBody(params: { + client: MatrixClient; + body: string; +}): Promise { + return await resolveMatrixMentionsInMarkdown({ + markdown: params.body ?? "", + client: params.client, + }); +} + +function normalizeMentionUserIds(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; +} + +export function extractMatrixMentions( + content: Record | undefined, +): MatrixMentions { + const rawMentions = content?.["m.mentions"]; + if (!rawMentions || typeof rawMentions !== "object") { + return {}; + } + const mentions = rawMentions as { room?: unknown; user_ids?: unknown }; + const normalized: MatrixMentions = {}; + const userIds = normalizeMentionUserIds(mentions.user_ids); + if (userIds.length > 0) { + normalized.user_ids = userIds; + } + if (mentions.room === true) { + normalized.room = true; + } + return normalized; +} + +export function diffMatrixMentions( + current: MatrixMentions, + previous: MatrixMentions, +): MatrixMentions { + const previousUserIds = new Set(previous.user_ids ?? []); + const newUserIds = (current.user_ids ?? []).filter((userId) => !previousUserIds.has(userId)); + const delta: MatrixMentions = {}; + if (newUserIds.length > 0) { + delta.user_ids = newUserIds; + } + if (current.room && !previous.room) { + delta.room = true; + } + return delta; } export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts index 3c81b2dcfcd..42db1ba234f 100644 --- a/extensions/matrix/src/matrix/send/media.ts +++ b/extensions/matrix/src/matrix/send/media.ts @@ -8,7 +8,6 @@ import type { TimedFileInfo, VideoFileInfo, } from "../sdk.js"; -import { applyMatrixFormatting } from "./formatting.js"; import { type MatrixMediaContent, type MatrixMediaInfo, @@ -103,7 +102,6 @@ export function buildMediaContent(params: { if (params.relation) { base["m.relates_to"] = params.relation; } - applyMatrixFormatting(base, params.body); return base; }