mirror of https://github.com/openclaw/openclaw.git
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:
parent
7b748a57f0
commit
be52594766
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue