fix(matrix): emit spec-compliant mentions (#59323)

Merged via squash.

Prepared head SHA: 4b641e35a2
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-04-02 02:00:24 -04:00 committed by GitHub
parent 7b748a57f0
commit be52594766
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 988 additions and 43 deletions

View File

@ -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

View File

@ -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<string, unknown> {
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: [

View File

@ -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(

View File

@ -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("<br");
});
it("renders qualified Matrix user mentions as matrix.to links and m.mentions metadata", 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.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</a>.");
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</a>.");
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('<a href="https://example.com">@alice:example.org</a>');
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("<code>\\@alice:example.org</code>");
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("<code>@alice:example.org</code>");
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("<pre><code>\\@alice:example.org\n</code></pre>");
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("<pre><code>\\@alice:example.org\n</code></pre>");
expect(result.mentions).toEqual({});
});
});

View File

@ -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<typeof md.parse>[number];
type MarkdownInlineToken = NonNullable<MarkdownToken["children"]>[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<NonNullable<typeof md.renderer.rules.link_open>>[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<string | null> {
const getUserId = (client as { getUserId?: () => Promise<string> | 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<string>;
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<string>();
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<MatrixMentions> {
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,
};
}

View File

@ -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();

View File

@ -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();

View File

@ -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<string, unknown> | 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<string, unknown>;
const newContent = content["m.new_content"];
return newContent && typeof newContent === "object"
? (newContent as Record<string, unknown>)
: 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<string, unknown> = 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<Record<string, unknown> | null> {
const getEvent = (
client as {
getEvent?: (roomId: string, eventId: string) => Promise<Record<string, unknown>>;
}
).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<string> {
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<string, unknown> = {
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,
};

View File

@ -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<void> {
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<MatrixMentions> {
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<string, unknown> | 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 {

View File

@ -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;
}