mirror of https://github.com/openclaw/openclaw.git
504 lines
14 KiB
TypeScript
504 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createMatrixRoomMessageHandler } from "./handler.js";
|
|
import { EventType, type MatrixRawEvent } from "./types.js";
|
|
|
|
const sendMessageMatrixMock = vi.hoisted(() =>
|
|
vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })),
|
|
);
|
|
|
|
vi.mock("../send.js", () => ({
|
|
reactMatrixMessage: vi.fn(async () => {}),
|
|
sendMessageMatrix: sendMessageMatrixMock,
|
|
sendReadReceiptMatrix: vi.fn(async () => {}),
|
|
sendTypingMatrix: vi.fn(async () => {}),
|
|
}));
|
|
|
|
function createReactionHarness(params?: {
|
|
cfg?: unknown;
|
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
allowFrom?: string[];
|
|
storeAllowFrom?: string[];
|
|
targetSender?: string;
|
|
isDirectMessage?: boolean;
|
|
senderName?: string;
|
|
}) {
|
|
const readAllowFromStore = vi.fn(async () => params?.storeAllowFrom ?? []);
|
|
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
|
const resolveAgentRoute = vi.fn(() => ({
|
|
agentId: "ops",
|
|
channel: "matrix-js",
|
|
accountId: "ops",
|
|
sessionKey: "agent:ops:main",
|
|
mainSessionKey: "agent:ops:main",
|
|
matchedBy: "binding.account",
|
|
}));
|
|
const enqueueSystemEvent = vi.fn();
|
|
|
|
const handler = createMatrixRoomMessageHandler({
|
|
client: {
|
|
getUserId: async () => "@bot:example.org",
|
|
getEvent: async () => ({ sender: params?.targetSender ?? "@bot:example.org" }),
|
|
} as never,
|
|
core: {
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore,
|
|
upsertPairingRequest,
|
|
buildPairingReply: () => "pairing",
|
|
},
|
|
commands: {
|
|
shouldHandleTextCommands: () => false,
|
|
},
|
|
text: {
|
|
hasControlCommand: () => false,
|
|
},
|
|
routing: {
|
|
resolveAgentRoute,
|
|
},
|
|
},
|
|
system: {
|
|
enqueueSystemEvent,
|
|
},
|
|
} as never,
|
|
cfg: (params?.cfg ?? {}) as never,
|
|
accountId: "ops",
|
|
runtime: {
|
|
error: () => {},
|
|
} as never,
|
|
logger: {
|
|
info: () => {},
|
|
warn: () => {},
|
|
} as never,
|
|
logVerboseMessage: () => {},
|
|
allowFrom: params?.allowFrom ?? [],
|
|
mentionRegexes: [],
|
|
groupPolicy: "open",
|
|
replyToMode: "off",
|
|
threadReplies: "inbound",
|
|
dmEnabled: true,
|
|
dmPolicy: params?.dmPolicy ?? "open",
|
|
textLimit: 8_000,
|
|
mediaMaxBytes: 10_000_000,
|
|
startupMs: 0,
|
|
startupGraceMs: 0,
|
|
directTracker: {
|
|
isDirectMessage: async () => params?.isDirectMessage ?? true,
|
|
},
|
|
getRoomInfo: async () => ({ altAliases: [] }),
|
|
getMemberDisplayName: async () => params?.senderName ?? "sender",
|
|
});
|
|
|
|
return {
|
|
handler,
|
|
enqueueSystemEvent,
|
|
readAllowFromStore,
|
|
resolveAgentRoute,
|
|
upsertPairingRequest,
|
|
};
|
|
}
|
|
|
|
describe("matrix monitor handler pairing account scope", () => {
|
|
it("caches account-scoped allowFrom store reads on hot path", async () => {
|
|
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
|
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
|
sendMessageMatrixMock.mockClear();
|
|
|
|
const handler = createMatrixRoomMessageHandler({
|
|
client: {
|
|
getUserId: async () => "@bot:example.org",
|
|
} as never,
|
|
core: {
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore,
|
|
upsertPairingRequest,
|
|
buildPairingReply: () => "pairing",
|
|
},
|
|
},
|
|
} as never,
|
|
cfg: {} as never,
|
|
accountId: "ops",
|
|
runtime: {} as never,
|
|
logger: {
|
|
info: () => {},
|
|
warn: () => {},
|
|
} as never,
|
|
logVerboseMessage: () => {},
|
|
allowFrom: [],
|
|
mentionRegexes: [],
|
|
groupPolicy: "open",
|
|
replyToMode: "off",
|
|
threadReplies: "inbound",
|
|
dmEnabled: true,
|
|
dmPolicy: "pairing",
|
|
textLimit: 8_000,
|
|
mediaMaxBytes: 10_000_000,
|
|
startupMs: 0,
|
|
startupGraceMs: 0,
|
|
directTracker: {
|
|
isDirectMessage: async () => true,
|
|
},
|
|
getRoomInfo: async () => ({ altAliases: [] }),
|
|
getMemberDisplayName: async () => "sender",
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.RoomMessage,
|
|
sender: "@user:example.org",
|
|
event_id: "$event1",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
msgtype: "m.text",
|
|
body: "hello",
|
|
"m.mentions": { room: true },
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.RoomMessage,
|
|
sender: "@user:example.org",
|
|
event_id: "$event2",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
msgtype: "m.text",
|
|
body: "hello again",
|
|
"m.mentions": { room: true },
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sends pairing reminders for pending requests with cooldown", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
|
|
try {
|
|
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
|
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
|
sendMessageMatrixMock.mockClear();
|
|
|
|
const handler = createMatrixRoomMessageHandler({
|
|
client: {
|
|
getUserId: async () => "@bot:example.org",
|
|
} as never,
|
|
core: {
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore,
|
|
upsertPairingRequest,
|
|
buildPairingReply: () => "Pairing code: ABCDEFGH",
|
|
},
|
|
},
|
|
} as never,
|
|
cfg: {} as never,
|
|
accountId: "ops",
|
|
runtime: {} as never,
|
|
logger: {
|
|
info: () => {},
|
|
warn: () => {},
|
|
} as never,
|
|
logVerboseMessage: () => {},
|
|
allowFrom: [],
|
|
mentionRegexes: [],
|
|
groupPolicy: "open",
|
|
replyToMode: "off",
|
|
threadReplies: "inbound",
|
|
dmEnabled: true,
|
|
dmPolicy: "pairing",
|
|
textLimit: 8_000,
|
|
mediaMaxBytes: 10_000_000,
|
|
startupMs: 0,
|
|
startupGraceMs: 0,
|
|
directTracker: {
|
|
isDirectMessage: async () => true,
|
|
},
|
|
getRoomInfo: async () => ({ altAliases: [] }),
|
|
getMemberDisplayName: async () => "sender",
|
|
});
|
|
|
|
const makeEvent = (id: string): MatrixRawEvent =>
|
|
({
|
|
type: EventType.RoomMessage,
|
|
sender: "@user:example.org",
|
|
event_id: id,
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
msgtype: "m.text",
|
|
body: "hello",
|
|
"m.mentions": { room: true },
|
|
},
|
|
}) as MatrixRawEvent;
|
|
|
|
await handler("!room:example.org", makeEvent("$event1"));
|
|
await handler("!room:example.org", makeEvent("$event2"));
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
|
expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain(
|
|
"Pairing request is still pending approval.",
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
|
|
await handler("!room:example.org", makeEvent("$event3"));
|
|
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("uses account-scoped pairing store reads and upserts for dm pairing", async () => {
|
|
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
|
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
|
|
|
|
const handler = createMatrixRoomMessageHandler({
|
|
client: {
|
|
getUserId: async () => "@bot:example.org",
|
|
} as never,
|
|
core: {
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore,
|
|
upsertPairingRequest,
|
|
},
|
|
},
|
|
} as never,
|
|
cfg: {} as never,
|
|
accountId: "ops",
|
|
runtime: {} as never,
|
|
logger: {
|
|
info: () => {},
|
|
warn: () => {},
|
|
} as never,
|
|
logVerboseMessage: () => {},
|
|
allowFrom: [],
|
|
mentionRegexes: [],
|
|
groupPolicy: "open",
|
|
replyToMode: "off",
|
|
threadReplies: "inbound",
|
|
dmEnabled: true,
|
|
dmPolicy: "pairing",
|
|
textLimit: 8_000,
|
|
mediaMaxBytes: 10_000_000,
|
|
startupMs: 0,
|
|
startupGraceMs: 0,
|
|
directTracker: {
|
|
isDirectMessage: async () => true,
|
|
},
|
|
getRoomInfo: async () => ({ altAliases: [] }),
|
|
getMemberDisplayName: async () => "sender",
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.RoomMessage,
|
|
sender: "@user:example.org",
|
|
event_id: "$event1",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
msgtype: "m.text",
|
|
body: "hello",
|
|
"m.mentions": { room: true },
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(readAllowFromStore).toHaveBeenCalledWith({
|
|
channel: "matrix-js",
|
|
env: process.env,
|
|
accountId: "ops",
|
|
});
|
|
expect(upsertPairingRequest).toHaveBeenCalledWith({
|
|
channel: "matrix-js",
|
|
id: "@user:example.org",
|
|
accountId: "ops",
|
|
meta: { name: "sender" },
|
|
});
|
|
});
|
|
|
|
it("passes accountId into route resolution for inbound dm messages", async () => {
|
|
const resolveAgentRoute = vi.fn(() => ({
|
|
agentId: "ops",
|
|
channel: "matrix-js",
|
|
accountId: "ops",
|
|
sessionKey: "agent:ops:main",
|
|
mainSessionKey: "agent:ops:main",
|
|
matchedBy: "binding.account",
|
|
}));
|
|
|
|
const handler = createMatrixRoomMessageHandler({
|
|
client: {
|
|
getUserId: async () => "@bot:example.org",
|
|
} as never,
|
|
core: {
|
|
channel: {
|
|
pairing: {
|
|
readAllowFromStore: async () => [] as string[],
|
|
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
|
|
},
|
|
commands: {
|
|
shouldHandleTextCommands: () => false,
|
|
},
|
|
text: {
|
|
hasControlCommand: () => false,
|
|
},
|
|
routing: {
|
|
resolveAgentRoute,
|
|
},
|
|
},
|
|
} as never,
|
|
cfg: {} as never,
|
|
accountId: "ops",
|
|
runtime: {
|
|
error: () => {},
|
|
} as never,
|
|
logger: {
|
|
info: () => {},
|
|
warn: () => {},
|
|
} as never,
|
|
logVerboseMessage: () => {},
|
|
allowFrom: [],
|
|
mentionRegexes: [],
|
|
groupPolicy: "open",
|
|
replyToMode: "off",
|
|
threadReplies: "inbound",
|
|
dmEnabled: true,
|
|
dmPolicy: "open",
|
|
textLimit: 8_000,
|
|
mediaMaxBytes: 10_000_000,
|
|
startupMs: 0,
|
|
startupGraceMs: 0,
|
|
directTracker: {
|
|
isDirectMessage: async () => true,
|
|
},
|
|
getRoomInfo: async () => ({ altAliases: [] }),
|
|
getMemberDisplayName: async () => "sender",
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.RoomMessage,
|
|
sender: "@user:example.org",
|
|
event_id: "$event2",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
msgtype: "m.text",
|
|
body: "hello",
|
|
"m.mentions": { room: true },
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "matrix-js",
|
|
accountId: "ops",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("enqueues system events for reactions on bot-authored messages", async () => {
|
|
const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness();
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.Reaction,
|
|
sender: "@user:example.org",
|
|
event_id: "$reaction1",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
"m.relates_to": {
|
|
rel_type: "m.annotation",
|
|
event_id: "$msg1",
|
|
key: "👍",
|
|
},
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "matrix-js",
|
|
accountId: "ops",
|
|
}),
|
|
);
|
|
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
|
"Matrix reaction added: 👍 by sender on msg $msg1",
|
|
{
|
|
sessionKey: "agent:ops:main",
|
|
contextKey: "matrix:reaction:add:!room:example.org:$msg1:@user:example.org:👍",
|
|
},
|
|
);
|
|
});
|
|
|
|
it("ignores reactions that do not target bot-authored messages", async () => {
|
|
const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness({
|
|
targetSender: "@other:example.org",
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.Reaction,
|
|
sender: "@user:example.org",
|
|
event_id: "$reaction2",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
"m.relates_to": {
|
|
rel_type: "m.annotation",
|
|
event_id: "$msg2",
|
|
key: "👀",
|
|
},
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
|
expect(resolveAgentRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not create pairing requests for unauthorized dm reactions", async () => {
|
|
const { handler, enqueueSystemEvent, upsertPairingRequest } = createReactionHarness({
|
|
dmPolicy: "pairing",
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.Reaction,
|
|
sender: "@user:example.org",
|
|
event_id: "$reaction3",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
"m.relates_to": {
|
|
rel_type: "m.annotation",
|
|
event_id: "$msg3",
|
|
key: "🔥",
|
|
},
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(upsertPairingRequest).not.toHaveBeenCalled();
|
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("honors account-scoped reaction notification overrides", async () => {
|
|
const { handler, enqueueSystemEvent } = createReactionHarness({
|
|
cfg: {
|
|
channels: {
|
|
"matrix-js": {
|
|
reactionNotifications: "own",
|
|
accounts: {
|
|
ops: {
|
|
reactionNotifications: "off",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await handler("!room:example.org", {
|
|
type: EventType.Reaction,
|
|
sender: "@user:example.org",
|
|
event_id: "$reaction4",
|
|
origin_server_ts: Date.now(),
|
|
content: {
|
|
"m.relates_to": {
|
|
rel_type: "m.annotation",
|
|
event_id: "$msg4",
|
|
key: "✅",
|
|
},
|
|
},
|
|
} as MatrixRawEvent);
|
|
|
|
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|