matrix-js: harden reaction handling

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 15:35:21 -04:00
parent e2826a9df9
commit fcba58cff2
22 changed files with 877 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }],
});
});
});

View File

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

View File

@@ -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. */

View File

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