mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
* feat(matrix): handle voice preflight and threads Co-authored-by: Frank Dierolf <frank_dierolf@web.de> Co-authored-by: marc.wilson <marcwilson@gazasrv15i5.globaladvisors.biz> * test(matrix): satisfy ci guards * fix(matrix): preserve thread relations on edits * chore: annotate deprecated compatibility aliases * fix(matrix): include poll thread roots in reads * test(matrix): enable audio preflight qa config * test(matrix): make voice preflight QA mention deterministic --------- Co-authored-by: Frank Dierolf <frank_dierolf@web.de> Co-authored-by: marc.wilson <marcwilson@gazasrv15i5.globaladvisors.biz>
259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
// Matrix tests cover replies plugin behavior.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js";
|
|
import type { MatrixClient } from "../sdk.js";
|
|
|
|
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
|
|
const chunkMatrixTextMock = vi.hoisted(() =>
|
|
vi.fn((text: string, _opts?: unknown) => ({
|
|
trimmedText: text.trim(),
|
|
convertedText: text,
|
|
singleEventLimit: 4000,
|
|
fitsInSingleEvent: true,
|
|
chunks: text ? [text] : [],
|
|
})),
|
|
);
|
|
|
|
vi.mock("../send.js", () => ({
|
|
chunkMatrixText: (text: string, opts?: unknown) => chunkMatrixTextMock(text, opts),
|
|
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
|
|
sendMessageMatrixMock(to, message, opts),
|
|
}));
|
|
|
|
import { setMatrixRuntime } from "../../runtime.js";
|
|
import { deliverMatrixReplies } from "./replies.js";
|
|
|
|
function sendCall(index: number) {
|
|
const call = sendMessageMatrixMock.mock.calls.at(index);
|
|
if (!call) {
|
|
throw new Error(`Expected send call at index ${index}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function sendOptions(index: number): Record<string, unknown> {
|
|
const options = sendCall(index)[2];
|
|
if (!options || typeof options !== "object") {
|
|
throw new Error(`Expected send options at call ${index}`);
|
|
}
|
|
return options as Record<string, unknown>;
|
|
}
|
|
|
|
describe("deliverMatrixReplies", () => {
|
|
const cfg = { channels: { matrix: {} } };
|
|
const loadConfigMock = vi.fn(() => ({}));
|
|
const resolveMarkdownTableModeMock = vi.fn<(params: unknown) => string>(() => "code");
|
|
const convertMarkdownTablesMock = vi.fn((text: string) => text);
|
|
const resolveChunkModeMock = vi.fn<
|
|
(cfg: unknown, channel: unknown, accountId?: unknown) => string
|
|
>(() => "length");
|
|
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
|
|
|
|
const runtimeStub = {
|
|
config: {
|
|
current: () => loadConfigMock(),
|
|
},
|
|
channel: {
|
|
text: {
|
|
resolveMarkdownTableMode: (params: unknown) => resolveMarkdownTableModeMock(params),
|
|
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
|
resolveChunkMode: (cfgLocal: unknown, channel: unknown, accountId?: unknown) =>
|
|
resolveChunkModeMock(cfgLocal, channel, accountId),
|
|
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
|
|
},
|
|
},
|
|
logging: {
|
|
shouldLogVerbose: () => false,
|
|
},
|
|
} as unknown as PluginRuntime;
|
|
|
|
const runtimeEnv: RuntimeEnv = {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
} as unknown as RuntimeEnv;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
setMatrixRuntime(runtimeStub);
|
|
chunkMatrixTextMock.mockReset().mockImplementation((text: string) => ({
|
|
trimmedText: text.trim(),
|
|
convertedText: text,
|
|
singleEventLimit: 4000,
|
|
fitsInSingleEvent: true,
|
|
chunks: text ? [text] : [],
|
|
}));
|
|
});
|
|
|
|
it("keeps replyToId on first reply only when replyToMode=first", async () => {
|
|
chunkMatrixTextMock.mockImplementation((text: string) => ({
|
|
trimmedText: text.trim(),
|
|
convertedText: text,
|
|
singleEventLimit: 4000,
|
|
fitsInSingleEvent: true,
|
|
chunks: text.split("|"),
|
|
}));
|
|
|
|
await deliverMatrixReplies({
|
|
cfg,
|
|
replies: [
|
|
{ text: "first-a|first-b", replyToId: "reply-1" },
|
|
{ text: "second", replyToId: "reply-2" },
|
|
],
|
|
roomId: "room:1",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "first",
|
|
});
|
|
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
|
expect(sendOptions(0).replyToId).toBe("reply-1");
|
|
expect(sendOptions(0).threadId).toBeUndefined();
|
|
expect(sendOptions(1).replyToId).toBe("reply-1");
|
|
expect(sendOptions(1).threadId).toBeUndefined();
|
|
expect(sendOptions(2).replyToId).toBeUndefined();
|
|
expect(sendOptions(2).threadId).toBeUndefined();
|
|
});
|
|
|
|
it("keeps replyToId on every reply when replyToMode=all", async () => {
|
|
await deliverMatrixReplies({
|
|
cfg,
|
|
replies: [
|
|
{
|
|
text: "caption",
|
|
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
|
replyToId: "reply-media",
|
|
audioAsVoice: true,
|
|
},
|
|
{ text: "plain", replyToId: "reply-text" },
|
|
],
|
|
roomId: "room:2",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "all",
|
|
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
|
});
|
|
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
|
expect(sendCall(0)[0]).toBe("room:2");
|
|
expect(sendCall(0)[1]).toBe("caption");
|
|
expect(sendOptions(0).mediaUrl).toBe("https://example.com/a.jpg");
|
|
expect(sendOptions(0).mediaLocalRoots).toEqual(["/tmp/openclaw-matrix-test"]);
|
|
expect(sendOptions(0).replyToId).toBe("reply-media");
|
|
expect(sendCall(1)[0]).toBe("room:2");
|
|
expect(sendCall(1)[1]).toBe("");
|
|
expect(sendOptions(1).mediaUrl).toBe("https://example.com/b.jpg");
|
|
expect(sendOptions(1).mediaLocalRoots).toEqual(["/tmp/openclaw-matrix-test"]);
|
|
expect(sendOptions(1).replyToId).toBe("reply-media");
|
|
expect(sendOptions(2).replyToId).toBe("reply-text");
|
|
});
|
|
|
|
it("keeps replyToId when threadId is set so Matrix can send fallback metadata", async () => {
|
|
chunkMatrixTextMock.mockImplementation((text: string) => ({
|
|
trimmedText: text.trim(),
|
|
convertedText: text,
|
|
singleEventLimit: 4000,
|
|
fitsInSingleEvent: true,
|
|
chunks: text.split("|"),
|
|
}));
|
|
|
|
await deliverMatrixReplies({
|
|
cfg,
|
|
replies: [{ text: "hello|thread" }],
|
|
roomId: "room:3",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "off",
|
|
threadId: "thread-77",
|
|
replyToId: "reply-thread",
|
|
});
|
|
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
|
expect(sendOptions(0).replyToId).toBe("reply-thread");
|
|
expect(sendOptions(0).threadId).toBe("thread-77");
|
|
expect(sendOptions(1).replyToId).toBe("reply-thread");
|
|
expect(sendOptions(1).threadId).toBe("thread-77");
|
|
});
|
|
|
|
it("suppresses reasoning-only text before Matrix sends", async () => {
|
|
await deliverMatrixReplies({
|
|
cfg,
|
|
replies: [
|
|
{ text: "Reasoning:\n_hidden_" },
|
|
{ text: "<think>still hidden</think>" },
|
|
{ text: "Visible answer" },
|
|
],
|
|
roomId: "room:5",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "off",
|
|
});
|
|
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
|
expect(sendCall(0)[0]).toBe("room:5");
|
|
expect(sendCall(0)[1]).toBe("Visible answer");
|
|
expect(sendOptions(0).cfg).toBe(cfg);
|
|
});
|
|
|
|
it("uses supplied cfg for chunking and send delivery without reloading runtime config", async () => {
|
|
const explicitCfg = {
|
|
channels: {
|
|
matrix: {
|
|
accounts: {
|
|
ops: {
|
|
chunkMode: "newline",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
loadConfigMock.mockImplementation(() => {
|
|
throw new Error("deliverMatrixReplies should not reload runtime config when cfg is provided");
|
|
});
|
|
|
|
await deliverMatrixReplies({
|
|
cfg: explicitCfg,
|
|
replies: [{ text: "hello", replyToId: "reply-1" }],
|
|
roomId: "room:4",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "all",
|
|
accountId: "ops",
|
|
});
|
|
|
|
expect(loadConfigMock).not.toHaveBeenCalled();
|
|
expect(chunkMatrixTextMock).toHaveBeenCalledWith("hello", {
|
|
cfg: explicitCfg,
|
|
accountId: "ops",
|
|
tableMode: "code",
|
|
});
|
|
expect(sendCall(0)[0]).toBe("room:4");
|
|
expect(sendCall(0)[1]).toBe("hello");
|
|
expect(sendOptions(0).cfg).toBe(explicitCfg);
|
|
expect(sendOptions(0).accountId).toBe("ops");
|
|
expect(sendOptions(0).replyToId).toBe("reply-1");
|
|
});
|
|
|
|
it("passes raw media captions through to sendMessageMatrix without pre-converting them", async () => {
|
|
convertMarkdownTablesMock.mockImplementation((text: string) => `converted:${text}`);
|
|
|
|
await deliverMatrixReplies({
|
|
cfg,
|
|
replies: [{ text: "caption", mediaUrl: "https://example.com/a.jpg" }],
|
|
roomId: "room:6",
|
|
client: {} as MatrixClient,
|
|
runtime: runtimeEnv,
|
|
textLimit: 4000,
|
|
replyToMode: "off",
|
|
});
|
|
|
|
expect(sendCall(0)[0]).toBe("room:6");
|
|
expect(sendCall(0)[1]).toBe("caption");
|
|
expect(sendOptions(0).mediaUrl).toBe("https://example.com/a.jpg");
|
|
});
|
|
});
|