From fcba58cff2a2cddc83cb9a354b021ca471cbdf2b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 8 Mar 2026 15:35:21 -0400 Subject: [PATCH] matrix-js: harden reaction handling --- docs/channels/matrix-js.md | 38 ++++ docs/tools/reactions.md | 1 + extensions/matrix-js/src/config-schema.ts | 5 + .../src/matrix/actions/reactions.test.ts | 26 +++ .../matrix-js/src/matrix/actions/reactions.ts | 76 +++---- .../matrix-js/src/matrix/actions/types.ts | 24 +-- .../src/matrix/monitor/ack-config.test.ts | 57 +++++ .../src/matrix/monitor/ack-config.ts | 25 +++ .../src/matrix/monitor/events.test.ts | 26 +++ .../matrix-js/src/matrix/monitor/events.ts | 4 + .../src/matrix/monitor/handler.test.ts | 195 ++++++++++++++++++ .../matrix-js/src/matrix/monitor/handler.ts | 42 +++- .../src/matrix/monitor/reaction-events.ts | 76 +++++++ .../matrix-js/src/matrix/monitor/types.ts | 2 + .../src/matrix/reaction-common.test.ts | 96 +++++++++ .../matrix-js/src/matrix/reaction-common.ts | 145 +++++++++++++ extensions/matrix-js/src/matrix/send.ts | 39 ++-- extensions/matrix-js/src/matrix/send/types.ts | 17 +- extensions/matrix-js/src/tool-actions.test.ts | 64 ++++++ extensions/matrix-js/src/tool-actions.ts | 9 +- extensions/matrix-js/src/types.ts | 6 + src/plugin-sdk/matrix-js.ts | 1 + 22 files changed, 877 insertions(+), 97 deletions(-) create mode 100644 extensions/matrix-js/src/matrix/monitor/ack-config.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/ack-config.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/reaction-events.ts create mode 100644 extensions/matrix-js/src/matrix/reaction-common.test.ts create mode 100644 extensions/matrix-js/src/matrix/reaction-common.ts diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md index 2ad516f6e72..1fca30a6202 100644 --- a/docs/channels/matrix-js.md +++ b/docs/channels/matrix-js.md @@ -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..ackReaction` +- `channels["matrix-js"].ackReaction` +- `messages.ackReaction` +- agent identity emoji fallback + +Ack reaction scope resolves in this order: + +- `channels["matrix-js"].accounts..ackReactionScope` +- `channels["matrix-js"].ackReactionScope` +- `messages.ackReactionScope` + +Reaction notification mode resolves in this order: + +- `channels["matrix-js"].accounts..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`. diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 17f9cfbb7f9..d281714c6cc 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -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. diff --git a/extensions/matrix-js/src/config-schema.ts b/extensions/matrix-js/src/config-schema.ts index 1876847b939..d4482e95698 100644 --- a/extensions/matrix-js/src/config-schema.ts +++ b/extensions/matrix-js/src/config-schema.ts @@ -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(), diff --git a/extensions/matrix-js/src/matrix/actions/reactions.test.ts b/extensions/matrix-js/src/matrix/actions/reactions.test.ts index 9f5e13f9a12..2aa1eb9a471 100644 --- a/extensions/matrix-js/src/matrix/actions/reactions.test.ts +++ b/extensions/matrix-js/src/matrix/actions/reactions.test.ts @@ -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(); + }); }); diff --git a/extensions/matrix-js/src/matrix/actions/reactions.ts b/extensions/matrix-js/src/matrix/actions/reactions.ts index 18b21d3de30..3be838198f9 100644 --- a/extensions/matrix-js/src/matrix/actions/reactions.ts +++ b/extensions/matrix-js/src/matrix/actions/reactions.ts @@ -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; + +async function listMatrixReactionEvents( + client: ActionClient, + roomId: string, + messageId: string, + limit: number, +): Promise { + 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 { 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(); - 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 }; } diff --git a/extensions/matrix-js/src/matrix/actions/types.ts b/extensions/matrix-js/src/matrix/actions/types.ts index f6f94481c3a..57b3cf6d268 100644 --- a/extensions/matrix-js/src/matrix/actions/types.ts +++ b/extensions/matrix-js/src/matrix/actions/types.ts @@ -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; diff --git a/extensions/matrix-js/src/matrix/monitor/ack-config.test.ts b/extensions/matrix-js/src/matrix/monitor/ack-config.test.ts new file mode 100644 index 00000000000..3ab563a1cfb --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/ack-config.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/ack-config.ts b/extensions/matrix-js/src/matrix/monitor/ack-config.ts new file mode 100644 index 00000000000..607f6bc480d --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/ack-config.ts @@ -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 }; +} diff --git a/extensions/matrix-js/src/matrix/monitor/events.test.ts b/extensions/matrix-js/src/matrix/monitor/events.test.ts index cc9c8290b94..bda9748a32c 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.test.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.test.ts @@ -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) { diff --git a/extensions/matrix-js/src/matrix/monitor/events.ts b/extensions/matrix-js/src/matrix/monitor/events.ts index 5147deb1ec3..93e4b431861 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.ts @@ -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); }); diff --git a/extensions/matrix-js/src/matrix/monitor/handler.test.ts b/extensions/matrix-js/src/matrix/monitor/handler.test.ts index 7eb1e3712ab..ceb73a41c30 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix-js/src/matrix/monitor/handler.test.ts @@ -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(); + }); }); diff --git a/extensions/matrix-js/src/matrix/monitor/handler.ts b/extensions/matrix-js/src/matrix/monitor/handler.ts index 38313f3399e..26423ffd45d 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.ts +++ b/extensions/matrix-js/src/matrix/monitor/handler.ts @@ -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 && diff --git a/extensions/matrix-js/src/matrix/monitor/reaction-events.ts b/extensions/matrix-js/src/matrix/monitor/reaction-events.ts new file mode 100644 index 00000000000..76904ff8edc --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/reaction-events.ts @@ -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 { + 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}`, + ); +} diff --git a/extensions/matrix-js/src/matrix/monitor/types.ts b/extensions/matrix-js/src/matrix/monitor/types.ts index f54d7735819..83552931906 100644 --- a/extensions/matrix-js/src/matrix/monitor/types.ts +++ b/extensions/matrix-js/src/matrix/monitor/types.ts @@ -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 = { diff --git a/extensions/matrix-js/src/matrix/reaction-common.test.ts b/extensions/matrix-js/src/matrix/reaction-common.test.ts new file mode 100644 index 00000000000..299bd20f7cb --- /dev/null +++ b/extensions/matrix-js/src/matrix/reaction-common.test.ts @@ -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(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/reaction-common.ts b/extensions/matrix-js/src/matrix/reaction-common.ts new file mode 100644 index 00000000000..797e5392dfd --- /dev/null +++ b/extensions/matrix-js/src/matrix/reaction-common.ts @@ -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>, +): MatrixReactionSummary[] { + const summaries = new Map(); + 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>, + 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; +} diff --git a/extensions/matrix-js/src/matrix/send.ts b/extensions/matrix-js/src/matrix/send.ts index 230c26915b1..3ed0d9cb4a7 100644 --- a/extensions/matrix-js/src/matrix/send.ts +++ b/extensions/matrix-js/src/matrix/send.ts @@ -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 { - 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) { diff --git a/extensions/matrix-js/src/matrix/send/types.ts b/extensions/matrix-js/src/matrix/send/types.ts index a423294ee0e..d597255a593 100644 --- a/extensions/matrix-js/src/matrix/send/types.ts +++ b/extensions/matrix-js/src/matrix/send/types.ts @@ -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; diff --git a/extensions/matrix-js/src/tool-actions.test.ts b/extensions/matrix-js/src/tool-actions.test.ts index e509e70b505..3099b476232 100644 --- a/extensions/matrix-js/src/tool-actions.test.ts +++ b/extensions/matrix-js/src/tool-actions.test.ts @@ -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("./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 }], + }); + }); }); diff --git a/extensions/matrix-js/src/tool-actions.ts b/extensions/matrix-js/src/tool-actions.ts index 38627a7e920..bf6f1bd42bb 100644 --- a/extensions/matrix-js/src/tool-actions.ts +++ b/extensions/matrix-js/src/tool-actions.ts @@ -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 }); } diff --git a/extensions/matrix-js/src/types.ts b/extensions/matrix-js/src/types.ts index 8b755d86d2e..59b6c07883f 100644 --- a/extensions/matrix-js/src/types.ts +++ b/extensions/matrix-js/src/types.ts @@ -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. */ diff --git a/src/plugin-sdk/matrix-js.ts b/src/plugin-sdk/matrix-js.ts index a725c6fe2c6..5ea8df32aca 100644 --- a/src/plugin-sdk/matrix-js.ts +++ b/src/plugin-sdk/matrix-js.ts @@ -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,