mirror of https://github.com/openclaw/openclaw.git
matrix-js: harden reaction handling
This commit is contained in:
parent
8061bebec2
commit
8ecbda54e0
|
|
@ -225,6 +225,41 @@ Inbound SAS requests are auto-confirmed by the bot device, so once the user conf
|
|||
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
|
||||
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
||||
|
||||
## Reactions
|
||||
|
||||
Matrix-js supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
|
||||
|
||||
- Outbound reaction tooling is gated by `channels["matrix-js"].actions.reactions`.
|
||||
- `react` adds a reaction to a specific Matrix event.
|
||||
- `reactions` lists the current reaction summary for a specific Matrix event.
|
||||
- `emoji=""` removes the bot account's own reactions on that event.
|
||||
- `remove: true` removes only the specified emoji reaction from the bot account.
|
||||
|
||||
Ack reactions use the standard OpenClaw resolution order:
|
||||
|
||||
- `channels["matrix-js"].accounts.<accountId>.ackReaction`
|
||||
- `channels["matrix-js"].ackReaction`
|
||||
- `messages.ackReaction`
|
||||
- agent identity emoji fallback
|
||||
|
||||
Ack reaction scope resolves in this order:
|
||||
|
||||
- `channels["matrix-js"].accounts.<accountId>.ackReactionScope`
|
||||
- `channels["matrix-js"].ackReactionScope`
|
||||
- `messages.ackReactionScope`
|
||||
|
||||
Reaction notification mode resolves in this order:
|
||||
|
||||
- `channels["matrix-js"].accounts.<accountId>.reactionNotifications`
|
||||
- `channels["matrix-js"].reactionNotifications`
|
||||
- default: `own`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages.
|
||||
- `reactionNotifications: "off"` disables reaction system events.
|
||||
- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals.
|
||||
|
||||
## DM and room policy example
|
||||
|
||||
```json5
|
||||
|
|
@ -296,6 +331,9 @@ See [Groups](/channels/groups) for mention-gating and allowlist behavior.
|
|||
- `textChunkLimit`: outbound message chunk size.
|
||||
- `chunkMode`: `length` or `newline`.
|
||||
- `responsePrefix`: optional message prefix for outbound replies.
|
||||
- `ackReaction`: optional ack reaction override for this channel/account.
|
||||
- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`).
|
||||
- `reactionNotifications`: inbound reaction notification mode (`own`, `off`).
|
||||
- `mediaMaxMb`: outbound media size cap in MB.
|
||||
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`).
|
||||
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Channel notes:
|
|||
|
||||
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Matrix-js**: empty `emoji` removes the bot account's own reactions on the message; `remove: true` removes just that emoji; inbound reaction notifications on bot-authored messages are controlled by `reactionNotifications`.
|
||||
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
|
||||
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||
- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ export const MatrixConfigSchema = z.object({
|
|||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z
|
||||
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
|
||||
.optional(),
|
||||
reactionNotifications: z.enum(["off", "own"]).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
|
|
|
|||
|
|
@ -106,4 +106,30 @@ describe("matrix reaction actions", () => {
|
|||
expect(result).toEqual({ removed: 0 });
|
||||
expect(redactEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an empty list when the relations response is malformed", async () => {
|
||||
const doRequest = vi.fn(async () => ({ chunk: null }));
|
||||
const client = {
|
||||
doRequest,
|
||||
getUserId: vi.fn(async () => "@me:example.org"),
|
||||
redactEvent: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const result = await listMatrixReactions("!room:example.org", "$msg", { client });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects blank message ids before querying Matrix relations", async () => {
|
||||
const { client, doRequest } = createReactionsClient({
|
||||
chunk: [],
|
||||
userId: "@me:example.org",
|
||||
});
|
||||
|
||||
await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow(
|
||||
"messageId",
|
||||
);
|
||||
expect(doRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,32 @@
|
|||
import {
|
||||
buildMatrixReactionRelationsPath,
|
||||
selectOwnMatrixReactionEventIds,
|
||||
summarizeMatrixReactionEvents,
|
||||
} from "../reaction-common.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixRawEvent,
|
||||
type MatrixReactionSummary,
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
|
||||
|
||||
async function listMatrixReactionEvents(
|
||||
client: ActionClient,
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
limit: number,
|
||||
): Promise<MatrixRawEvent[]> {
|
||||
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
|
||||
dir: "b",
|
||||
limit,
|
||||
})) as { chunk?: MatrixRawEvent[] };
|
||||
return Array.isArray(res.chunk) ? res.chunk : [];
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
|
|
@ -16,36 +34,9 @@ export async function listMatrixReactions(
|
|||
): Promise<MatrixReactionSummary[]> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.max(1, Math.floor(opts.limit))
|
||||
: 100;
|
||||
// Relations are queried via the low-level endpoint for compatibility.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit },
|
||||
)) as { chunk: MatrixRawEvent[] };
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of res.chunk) {
|
||||
const content = event.content as ReactionEventContent;
|
||||
const key = content["m.relates_to"]?.key;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const sender = event.sender ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 100);
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
return summarizeMatrixReactionEvents(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -56,27 +47,12 @@ export async function removeMatrixReactions(
|
|||
): Promise<{ removed: number }> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
|
||||
{ dir: "b", limit: 200 },
|
||||
)) as { chunk: MatrixRawEvent[] };
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = res.chunk
|
||||
.filter((event) => event.sender === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) {
|
||||
return true;
|
||||
}
|
||||
const content = event.content as ReactionEventContent;
|
||||
return content["m.relates_to"]?.key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.event_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji);
|
||||
if (toRemove.length === 0) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
MATRIX_REACTION_EVENT_TYPE,
|
||||
type MatrixReactionEventContent,
|
||||
type MatrixReactionSummary,
|
||||
} from "../reaction-common.js";
|
||||
import type { MatrixClient, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
|
||||
|
|
@ -7,14 +13,14 @@ export const MsgType = {
|
|||
|
||||
export const RelationType = {
|
||||
Replace: "m.replace",
|
||||
Annotation: "m.annotation",
|
||||
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
} as const;
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomPinnedEvents: "m.room.pinned_events",
|
||||
RoomTopic: "m.room.topic",
|
||||
Reaction: "m.reaction",
|
||||
Reaction: MATRIX_REACTION_EVENT_TYPE,
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
|
|
@ -28,13 +34,7 @@ export type RoomMessageEventContent = MessageEventContent & {
|
|||
};
|
||||
};
|
||||
|
||||
export type ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: string;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
export type ReactionEventContent = MatrixReactionEventContent;
|
||||
|
||||
export type RoomPinnedEventsEventContent = {
|
||||
pinned: string[];
|
||||
|
|
@ -63,12 +63,6 @@ export type MatrixMessageSummary = {
|
|||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
|
||||
|
||||
describe("resolveMatrixAckReactionConfig", () => {
|
||||
it("prefers account-level ack reaction and scope overrides", () => {
|
||||
expect(
|
||||
resolveMatrixAckReactionConfig({
|
||||
cfg: {
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "all",
|
||||
},
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
ackReaction: "✅",
|
||||
ackReactionScope: "group-all",
|
||||
accounts: {
|
||||
ops: {
|
||||
ackReaction: "🟢",
|
||||
ackReactionScope: "direct",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "ops-agent",
|
||||
accountId: "ops",
|
||||
}),
|
||||
).toEqual({
|
||||
ackReaction: "🟢",
|
||||
ackReactionScope: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to channel then global settings", () => {
|
||||
expect(
|
||||
resolveMatrixAckReactionConfig({
|
||||
cfg: {
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "all",
|
||||
},
|
||||
channels: {
|
||||
"matrix-js": {
|
||||
ackReaction: "✅",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "ops-agent",
|
||||
accountId: "missing",
|
||||
}),
|
||||
).toEqual({
|
||||
ackReaction: "✅",
|
||||
ackReactionScope: "all",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { resolveAckReaction, type OpenClawConfig } from "openclaw/plugin-sdk/matrix-js";
|
||||
|
||||
type MatrixAckReactionScope = "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
|
||||
|
||||
export function resolveMatrixAckReactionConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
}): { ackReaction: string; ackReactionScope: MatrixAckReactionScope } {
|
||||
const matrixConfig = params.cfg.channels?.["matrix-js"];
|
||||
const accountConfig =
|
||||
params.accountId && params.accountId !== "default"
|
||||
? matrixConfig?.accounts?.[params.accountId]
|
||||
: undefined;
|
||||
const ackReaction = resolveAckReaction(params.cfg, params.agentId, {
|
||||
channel: "matrix-js",
|
||||
accountId: params.accountId ?? undefined,
|
||||
}).trim();
|
||||
const ackReactionScope =
|
||||
accountConfig?.ackReactionScope ??
|
||||
matrixConfig?.ackReactionScope ??
|
||||
params.cfg.messages?.ackReactionScope ??
|
||||
"group-mentions";
|
||||
return { ackReaction, ackReactionScope };
|
||||
}
|
||||
|
|
@ -69,6 +69,32 @@ function createHarness(params?: {
|
|||
}
|
||||
|
||||
describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
it("forwards reaction room events into the shared room handler", async () => {
|
||||
const { onRoomMessage, sendMessage, roomEventListener } = createHarness();
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$reaction1",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.Reaction,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: "$msg1",
|
||||
key: "👍",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }),
|
||||
);
|
||||
});
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts verification request notices directly into the room", async () => {
|
||||
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
|
||||
if (!roomMessageListener) {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,10 @@ export function registerMatrixMonitorEvents(params: {
|
|||
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
if (eventType === EventType.Reaction) {
|
||||
void onRoomMessage(roomId, event);
|
||||
return;
|
||||
}
|
||||
|
||||
routeVerificationEvent(roomId, event);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,90 @@ vi.mock("../send.js", () => ({
|
|||
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[]);
|
||||
|
|
@ -305,4 +389,115 @@ describe("matrix monitor handler pairing account scope", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
sendReadReceiptMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "../send.js";
|
||||
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
import { resolveMentions } from "./mentions.js";
|
||||
import { handleInboundMatrixReaction } from "./reaction-events.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
|
|
@ -155,15 +157,21 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
}
|
||||
|
||||
const isPollEvent = isPollStartType(eventType);
|
||||
const isReactionEvent = eventType === EventType.Reaction;
|
||||
const locationContent = event.content as LocationMessageEventContent;
|
||||
const isLocationEvent =
|
||||
eventType === EventType.Location ||
|
||||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
||||
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
|
||||
if (
|
||||
eventType !== EventType.RoomMessage &&
|
||||
!isPollEvent &&
|
||||
!isLocationEvent &&
|
||||
!isReactionEvent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
logVerboseMessage(
|
||||
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||
`matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||
);
|
||||
if (event.unsigned?.redacted_because) {
|
||||
return;
|
||||
|
|
@ -295,7 +303,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
});
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
if (!isReactionEvent && dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "matrix-js",
|
||||
id: senderId,
|
||||
|
|
@ -330,9 +338,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
);
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
if (isReactionEvent || dmPolicy !== "pairing") {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
`matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -373,6 +381,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
if (isReactionEvent) {
|
||||
await handleInboundMatrixReaction({
|
||||
client,
|
||||
core,
|
||||
cfg,
|
||||
accountId,
|
||||
roomId,
|
||||
event,
|
||||
senderId,
|
||||
senderLabel: senderName,
|
||||
selfUserId,
|
||||
isDirectMessage,
|
||||
logVerboseMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody =
|
||||
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||
let media: {
|
||||
|
|
@ -585,8 +610,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
accountId,
|
||||
});
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReaction &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { extractMatrixReactionAnnotation } from "../reaction-common.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
export type MatrixReactionNotificationMode = "off" | "own";
|
||||
|
||||
export function resolveMatrixReactionNotificationMode(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
}): MatrixReactionNotificationMode {
|
||||
const matrixConfig = params.cfg.channels?.["matrix-js"];
|
||||
const accountConfig = matrixConfig?.accounts?.[params.accountId];
|
||||
return accountConfig?.reactionNotifications ?? matrixConfig?.reactionNotifications ?? "own";
|
||||
}
|
||||
|
||||
export async function handleInboundMatrixReaction(params: {
|
||||
client: MatrixClient;
|
||||
core: PluginRuntime;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
roomId: string;
|
||||
event: MatrixRawEvent;
|
||||
senderId: string;
|
||||
senderLabel: string;
|
||||
selfUserId: string;
|
||||
isDirectMessage: boolean;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const notificationMode = resolveMatrixReactionNotificationMode({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (notificationMode === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
const reaction = extractMatrixReactionAnnotation(params.event.content);
|
||||
if (!reaction?.eventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetEvent = await params.client.getEvent(params.roomId, reaction.eventId).catch((err) => {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed resolving reaction target room=${params.roomId} id=${reaction.eventId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const targetSender =
|
||||
targetEvent && typeof targetEvent.sender === "string" ? targetEvent.sender.trim() : "";
|
||||
if (!targetSender) {
|
||||
return;
|
||||
}
|
||||
if (notificationMode === "own" && targetSender !== params.selfUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const route = params.core.channel.routing.resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "matrix-js",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: params.isDirectMessage ? "direct" : "channel",
|
||||
id: params.isDirectMessage ? params.senderId : params.roomId,
|
||||
},
|
||||
});
|
||||
const text = `Matrix reaction added: ${reaction.key} by ${params.senderLabel} on msg ${reaction.eventId}`;
|
||||
params.core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:reaction:add:${params.roomId}:${reaction.eventId}:${params.senderId}:${reaction.key}`,
|
||||
});
|
||||
params.logVerboseMessage(
|
||||
`matrix: reaction event enqueued room=${params.roomId} target=${reaction.eventId} sender=${params.senderId} emoji=${reaction.key}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
|
||||
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
|
||||
|
|
@ -6,6 +7,7 @@ export const EventType = {
|
|||
RoomMessageEncrypted: "m.room.encrypted",
|
||||
RoomMember: "m.room.member",
|
||||
Location: "m.location",
|
||||
Reaction: MATRIX_REACTION_EVENT_TYPE,
|
||||
} as const;
|
||||
|
||||
export const RelationType = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMatrixReactionContent,
|
||||
buildMatrixReactionRelationsPath,
|
||||
extractMatrixReactionAnnotation,
|
||||
selectOwnMatrixReactionEventIds,
|
||||
summarizeMatrixReactionEvents,
|
||||
} from "./reaction-common.js";
|
||||
|
||||
describe("matrix reaction helpers", () => {
|
||||
it("builds trimmed reaction content and relation paths", () => {
|
||||
expect(buildMatrixReactionContent(" $msg ", " 👍 ")).toEqual({
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: "$msg",
|
||||
key: "👍",
|
||||
},
|
||||
});
|
||||
expect(buildMatrixReactionRelationsPath("!room:example.org", " $msg ")).toContain(
|
||||
"/rooms/!room%3Aexample.org/relations/%24msg/m.annotation/m.reaction",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes reactions by emoji and unique sender", () => {
|
||||
expect(
|
||||
summarizeMatrixReactionEvents([
|
||||
{ sender: "@alice:example.org", content: { "m.relates_to": { key: "👍" } } },
|
||||
{ sender: "@alice:example.org", content: { "m.relates_to": { key: "👍" } } },
|
||||
{ sender: "@bob:example.org", content: { "m.relates_to": { key: "👍" } } },
|
||||
{ sender: "@alice:example.org", content: { "m.relates_to": { key: "👎" } } },
|
||||
{ sender: "@ignored:example.org", content: {} },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
key: "👍",
|
||||
count: 3,
|
||||
users: ["@alice:example.org", "@bob:example.org"],
|
||||
},
|
||||
{
|
||||
key: "👎",
|
||||
count: 1,
|
||||
users: ["@alice:example.org"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects only matching reaction event ids for the current user", () => {
|
||||
expect(
|
||||
selectOwnMatrixReactionEventIds(
|
||||
[
|
||||
{
|
||||
event_id: "$1",
|
||||
sender: "@me:example.org",
|
||||
content: { "m.relates_to": { key: "👍" } },
|
||||
},
|
||||
{
|
||||
event_id: "$2",
|
||||
sender: "@me:example.org",
|
||||
content: { "m.relates_to": { key: "👎" } },
|
||||
},
|
||||
{
|
||||
event_id: "$3",
|
||||
sender: "@other:example.org",
|
||||
content: { "m.relates_to": { key: "👍" } },
|
||||
},
|
||||
],
|
||||
"@me:example.org",
|
||||
"👍",
|
||||
),
|
||||
).toEqual(["$1"]);
|
||||
});
|
||||
|
||||
it("extracts annotations and ignores non-annotation relations", () => {
|
||||
expect(
|
||||
extractMatrixReactionAnnotation({
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: " $msg ",
|
||||
key: " 👍 ",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
eventId: "$msg",
|
||||
key: "👍",
|
||||
});
|
||||
expect(
|
||||
extractMatrixReactionAnnotation({
|
||||
"m.relates_to": {
|
||||
rel_type: "m.replace",
|
||||
event_id: "$msg",
|
||||
key: "👍",
|
||||
},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
export const MATRIX_ANNOTATION_RELATION_TYPE = "m.annotation";
|
||||
export const MATRIX_REACTION_EVENT_TYPE = "m.reaction";
|
||||
|
||||
export type MatrixReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: typeof MATRIX_ANNOTATION_RELATION_TYPE;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export type MatrixReactionAnnotation = {
|
||||
key: string;
|
||||
eventId?: string;
|
||||
};
|
||||
|
||||
type MatrixReactionEventLike = {
|
||||
content?: unknown;
|
||||
sender?: string | null;
|
||||
event_id?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeMatrixReactionMessageId(messageId: string): string {
|
||||
const normalized = messageId.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("Matrix reaction requires a messageId");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeMatrixReactionEmoji(emoji: string): string {
|
||||
const normalized = emoji.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("Matrix reaction requires an emoji");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildMatrixReactionContent(
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
): MatrixReactionEventContent {
|
||||
return {
|
||||
"m.relates_to": {
|
||||
rel_type: MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
event_id: normalizeMatrixReactionMessageId(messageId),
|
||||
key: normalizeMatrixReactionEmoji(emoji),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatrixReactionRelationsPath(roomId: string, messageId: string): string {
|
||||
return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(normalizeMatrixReactionMessageId(messageId))}/${MATRIX_ANNOTATION_RELATION_TYPE}/${MATRIX_REACTION_EVENT_TYPE}`;
|
||||
}
|
||||
|
||||
export function extractMatrixReactionAnnotation(
|
||||
content: unknown,
|
||||
): MatrixReactionAnnotation | undefined {
|
||||
if (!content || typeof content !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const relatesTo = (
|
||||
content as {
|
||||
"m.relates_to"?: {
|
||||
rel_type?: unknown;
|
||||
event_id?: unknown;
|
||||
key?: unknown;
|
||||
};
|
||||
}
|
||||
)["m.relates_to"];
|
||||
if (!relatesTo || typeof relatesTo !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof relatesTo.rel_type === "string" &&
|
||||
relatesTo.rel_type !== MATRIX_ANNOTATION_RELATION_TYPE
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const key = typeof relatesTo.key === "string" ? relatesTo.key.trim() : "";
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const eventId = typeof relatesTo.event_id === "string" ? relatesTo.event_id.trim() : "";
|
||||
return {
|
||||
key,
|
||||
eventId: eventId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractMatrixReactionKey(content: unknown): string | undefined {
|
||||
return extractMatrixReactionAnnotation(content)?.key;
|
||||
}
|
||||
|
||||
export function summarizeMatrixReactionEvents(
|
||||
events: Iterable<Pick<MatrixReactionEventLike, "content" | "sender">>,
|
||||
): MatrixReactionSummary[] {
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of events) {
|
||||
const key = extractMatrixReactionKey(event.content);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const sender = event.sender?.trim() ?? "";
|
||||
const entry = summaries.get(key) ?? { key, count: 0, users: [] };
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
}
|
||||
|
||||
export function selectOwnMatrixReactionEventIds(
|
||||
events: Iterable<Pick<MatrixReactionEventLike, "content" | "event_id" | "sender">>,
|
||||
userId: string,
|
||||
emoji?: string,
|
||||
): string[] {
|
||||
const senderId = userId.trim();
|
||||
if (!senderId) {
|
||||
return [];
|
||||
}
|
||||
const targetEmoji = emoji?.trim();
|
||||
const ids: string[] = [];
|
||||
for (const event of events) {
|
||||
if ((event.sender?.trim() ?? "") !== senderId) {
|
||||
continue;
|
||||
}
|
||||
if (targetEmoji && extractMatrixReactionKey(event.content) !== targetEmoji) {
|
||||
continue;
|
||||
}
|
||||
const eventId = event.event_id?.trim();
|
||||
if (eventId) {
|
||||
ids.push(eventId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import { buildMatrixReactionContent } from "./reaction-common.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
|
||||
import {
|
||||
|
|
@ -20,11 +21,9 @@ import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
|
|||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixOutboundContent,
|
||||
type MatrixSendOpts,
|
||||
type MatrixSendResult,
|
||||
type ReactionEventContent,
|
||||
} from "./send/types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
|
|
@ -33,6 +32,24 @@ const getCore = () => getMatrixRuntime();
|
|||
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
|
||||
export { resolveMatrixRoomId } from "./send/targets.js";
|
||||
|
||||
type MatrixClientResolveOpts = {
|
||||
client?: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
function normalizeMatrixClientResolveOpts(
|
||||
opts?: MatrixClient | MatrixClientResolveOpts,
|
||||
): MatrixClientResolveOpts {
|
||||
if (!opts) {
|
||||
return {};
|
||||
}
|
||||
if (typeof (opts as MatrixClient).sendEvent === "function") {
|
||||
return { client: opts as MatrixClient };
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
export async function sendMessageMatrix(
|
||||
to: string,
|
||||
message: string,
|
||||
|
|
@ -238,23 +255,17 @@ export async function reactMatrixMessage(
|
|||
roomId: string,
|
||||
messageId: string,
|
||||
emoji: string,
|
||||
client?: MatrixClient,
|
||||
opts?: MatrixClient | MatrixClientResolveOpts,
|
||||
): Promise<void> {
|
||||
if (!emoji.trim()) {
|
||||
throw new Error("Matrix reaction requires an emoji");
|
||||
}
|
||||
const clientOpts = normalizeMatrixClientResolveOpts(opts);
|
||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||
client,
|
||||
client: clientOpts.client,
|
||||
timeoutMs: clientOpts.timeoutMs,
|
||||
accountId: clientOpts.accountId ?? undefined,
|
||||
});
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
|
||||
const reaction: ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: messageId,
|
||||
key: emoji,
|
||||
},
|
||||
};
|
||||
const reaction = buildMatrixReactionContent(messageId, emoji);
|
||||
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
MATRIX_REACTION_EVENT_TYPE,
|
||||
type MatrixReactionEventContent,
|
||||
} from "../reaction-common.js";
|
||||
import type {
|
||||
DimensionalFileInfo,
|
||||
EncryptedFile,
|
||||
|
|
@ -20,7 +25,7 @@ export const MsgType = {
|
|||
|
||||
// Relation types
|
||||
export const RelationType = {
|
||||
Annotation: "m.annotation",
|
||||
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
Replace: "m.replace",
|
||||
Thread: "m.thread",
|
||||
} as const;
|
||||
|
|
@ -28,7 +33,7 @@ export const RelationType = {
|
|||
// Event types
|
||||
export const EventType = {
|
||||
Direct: "m.direct",
|
||||
Reaction: "m.reaction",
|
||||
Reaction: MATRIX_REACTION_EVENT_TYPE,
|
||||
RoomMessage: "m.room.message",
|
||||
} as const;
|
||||
|
||||
|
|
@ -71,13 +76,7 @@ export type MatrixMediaContent = MessageEventContent &
|
|||
|
||||
export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
|
||||
|
||||
export type ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: typeof RelationType.Annotation;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
export type ReactionEventContent = MatrixReactionEventContent;
|
||||
|
||||
export type MatrixSendResult = {
|
||||
messageId: string;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import type { CoreConfig } from "./types.js";
|
|||
const mocks = vi.hoisted(() => ({
|
||||
voteMatrixPoll: vi.fn(),
|
||||
reactMatrixMessage: vi.fn(),
|
||||
listMatrixReactions: vi.fn(),
|
||||
removeMatrixReactions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/actions.js")>("./matrix/actions.js");
|
||||
return {
|
||||
...actual,
|
||||
listMatrixReactions: mocks.listMatrixReactions,
|
||||
removeMatrixReactions: mocks.removeMatrixReactions,
|
||||
voteMatrixPoll: mocks.voteMatrixPoll,
|
||||
};
|
||||
});
|
||||
|
|
@ -34,6 +38,8 @@ describe("handleMatrixAction pollVote", () => {
|
|||
labels: ["Pizza", "Sushi"],
|
||||
maxSelections: 2,
|
||||
});
|
||||
mocks.listMatrixReactions.mockResolvedValue([{ key: "👍", count: 1, users: ["@u:example"] }]);
|
||||
mocks.removeMatrixReactions.mockResolvedValue({ removed: 1 });
|
||||
});
|
||||
|
||||
it("parses snake_case vote params and forwards normalized selectors", async () => {
|
||||
|
|
@ -77,4 +83,62 @@ describe("handleMatrixAction pollVote", () => {
|
|||
),
|
||||
).rejects.toThrow("pollId required");
|
||||
});
|
||||
|
||||
it("passes account-scoped opts to add reactions", async () => {
|
||||
await handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
accountId: "ops",
|
||||
roomId: "!room:example",
|
||||
messageId: "$msg",
|
||||
emoji: "👍",
|
||||
},
|
||||
{ channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig,
|
||||
);
|
||||
|
||||
expect(mocks.reactMatrixMessage).toHaveBeenCalledWith("!room:example", "$msg", "👍", {
|
||||
accountId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes account-scoped opts to remove reactions", async () => {
|
||||
await handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
account_id: "ops",
|
||||
room_id: "!room:example",
|
||||
message_id: "$msg",
|
||||
emoji: "👍",
|
||||
remove: true,
|
||||
},
|
||||
{ channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig,
|
||||
);
|
||||
|
||||
expect(mocks.removeMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", {
|
||||
accountId: "ops",
|
||||
emoji: "👍",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes account-scoped opts and limit to reaction listing", async () => {
|
||||
const result = await handleMatrixAction(
|
||||
{
|
||||
action: "reactions",
|
||||
account_id: "ops",
|
||||
room_id: "!room:example",
|
||||
message_id: "$msg",
|
||||
limit: "5",
|
||||
},
|
||||
{ channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig,
|
||||
);
|
||||
|
||||
expect(mocks.listMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", {
|
||||
accountId: "ops",
|
||||
limit: 5,
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
reactions: [{ key: "👍", count: 1 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -143,14 +143,19 @@ export async function handleMatrixAction(
|
|||
});
|
||||
if (remove || isEmpty) {
|
||||
const result = await removeMatrixReactions(roomId, messageId, {
|
||||
accountId,
|
||||
emoji: remove ? emoji : undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, removed: result.removed });
|
||||
}
|
||||
await reactMatrixMessage(roomId, messageId, emoji);
|
||||
await reactMatrixMessage(roomId, messageId, emoji, { accountId });
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
const reactions = await listMatrixReactions(roomId, messageId);
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
const reactions = await listMatrixReactions(roomId, messageId, {
|
||||
accountId,
|
||||
limit: limit ?? undefined,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ export type MatrixConfig = {
|
|||
chunkMode?: "length" | "newline";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** Ack reaction emoji override for this channel/account. */
|
||||
ackReaction?: string;
|
||||
/** Ack reaction scope override for this channel/account. */
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
|
||||
/** Inbound reaction notifications for bot-authored Matrix messages. */
|
||||
reactionNotifications?: "off" | "own";
|
||||
/** Max outbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Auto-join invites (always|allowlist|off). Default: always. */
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
|||
export type { ChannelSetupInput } from "../channels/plugins/types.js";
|
||||
export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
export { resolveAckReaction } from "../agents/identity.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
|
|
|
|||
Loading…
Reference in New Issue