From a4324c45a35a7de04109f2a63444897915557aae Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 4 Mar 2026 11:25:08 -0500 Subject: [PATCH] make matrix-js atomic and add poll voting support --- extensions/matrix-js/src/actions.test.ts | 69 ++++++++++ extensions/matrix-js/src/actions.ts | 34 ++++- extensions/matrix-js/src/matrix/actions.ts | 1 + .../matrix-js/src/matrix/actions/polls.ts | 111 ++++++++++++++++ .../matrix-js/src/matrix/poll-types.test.ts | 76 ++++++++++- extensions/matrix-js/src/matrix/poll-types.ts | 109 ++++++++++++---- extensions/matrix-js/src/matrix/send.test.ts | 120 +++++++++++++++++- extensions/matrix-js/src/tool-actions.test.ts | 80 ++++++++++++ extensions/matrix-js/src/tool-actions.ts | 25 ++++ src/plugin-sdk/matrix-js.ts | 110 ++++++++++++++-- src/plugin-sdk/matrix.ts | 3 + 11 files changed, 704 insertions(+), 34 deletions(-) create mode 100644 extensions/matrix-js/src/actions.test.ts create mode 100644 extensions/matrix-js/src/matrix/actions/polls.ts create mode 100644 extensions/matrix-js/src/tool-actions.test.ts diff --git a/extensions/matrix-js/src/actions.test.ts b/extensions/matrix-js/src/actions.test.ts new file mode 100644 index 00000000000..582dbb54408 --- /dev/null +++ b/extensions/matrix-js/src/actions.test.ts @@ -0,0 +1,69 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { matrixMessageActions } from "./actions.js"; +import { setMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +const runtimeStub = { + config: { + loadConfig: () => ({}), + }, + media: { + loadWebMedia: async () => { + throw new Error("not used"); + }, + mediaKindFromMime: () => "image", + isVoiceCompatibleAudio: () => false, + getImageMetadata: async () => null, + resizeToJpeg: async () => Buffer.from(""), + }, + state: { + resolveStateDir: () => "/tmp/openclaw-matrix-js-test", + }, + channel: { + text: { + resolveTextChunkLimit: () => 4000, + resolveChunkMode: () => "length", + chunkMarkdownText: (text: string) => (text ? [text] : []), + chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, + }, + }, +} as unknown as PluginRuntime; + +function createConfiguredMatrixJsConfig(): CoreConfig { + return { + channels: { + "matrix-js": { + enabled: true, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + }, + }, + } as CoreConfig; +} + +describe("matrixMessageActions", () => { + beforeEach(() => { + setMatrixRuntime(runtimeStub); + }); + + it("exposes poll create but only handles poll votes inside the plugin", () => { + const listActions = matrixMessageActions.listActions; + const supportsAction = matrixMessageActions.supportsAction; + + expect(listActions).toBeTypeOf("function"); + expect(supportsAction).toBeTypeOf("function"); + + const actions = listActions!({ + cfg: createConfiguredMatrixJsConfig(), + } as never); + + expect(actions).toContain("poll"); + expect(actions).toContain("poll-vote"); + expect(supportsAction!({ action: "poll" } as never)).toBe(false); + expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true); + }); +}); diff --git a/extensions/matrix-js/src/actions.ts b/extensions/matrix-js/src/actions.ts index f18226bd0a2..07bfcc4a93f 100644 --- a/extensions/matrix-js/src/actions.ts +++ b/extensions/matrix-js/src/actions.ts @@ -11,6 +11,26 @@ import { resolveMatrixAccount } from "./matrix/accounts.js"; import { handleMatrixAction } from "./tool-actions.js"; import type { CoreConfig } from "./types.js"; +const MATRIX_JS_PLUGIN_HANDLED_ACTIONS = new Set([ + "send", + "poll-vote", + "react", + "reactions", + "read", + "edit", + "delete", + "pin", + "unpin", + "list-pins", + "member-info", + "channel-info", + "permissions", +]); + +function createMatrixJsExposedActions() { + return new Set(["poll", ...MATRIX_JS_PLUGIN_HANDLED_ACTIONS]); +} + export const matrixMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); @@ -18,7 +38,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { return []; } const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions); - const actions = new Set(["send", "poll"]); + const actions = createMatrixJsExposedActions(); if (gate("reactions")) { actions.add("react"); actions.add("reactions"); @@ -44,7 +64,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { } return Array.from(actions); }, - supportsAction: ({ action }) => action !== "poll", + supportsAction: ({ action }) => MATRIX_JS_PLUGIN_HANDLED_ACTIONS.has(action), extractToolSend: ({ args }): ChannelToolSend | null => { const action = typeof args.action === "string" ? args.action.trim() : ""; if (action !== "sendMessage") { @@ -85,6 +105,16 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { ); } + if (action === "poll-vote") { + return await handleMatrixAction( + { + ...params, + action: "pollVote", + }, + cfg as CoreConfig, + ); + } + if (action === "react") { const messageId = readStringParam(params, "messageId", { required: true }); const emoji = readStringParam(params, "emoji", { allowEmpty: true }); diff --git a/extensions/matrix-js/src/matrix/actions.ts b/extensions/matrix-js/src/matrix/actions.ts index 2ee53c7c16b..b6661351864 100644 --- a/extensions/matrix-js/src/matrix/actions.ts +++ b/extensions/matrix-js/src/matrix/actions.ts @@ -9,6 +9,7 @@ export { deleteMatrixMessage, readMatrixMessages, } from "./actions/messages.js"; +export { voteMatrixPoll } from "./actions/polls.js"; export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; diff --git a/extensions/matrix-js/src/matrix/actions/polls.ts b/extensions/matrix-js/src/matrix/actions/polls.ts new file mode 100644 index 00000000000..346ad3b1d78 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/polls.ts @@ -0,0 +1,111 @@ +import { + buildPollResponseContent, + isPollStartType, + parsePollStart, + type PollStartContent, +} from "../poll-types.js"; +import { resolveMatrixRoomId } from "../send.js"; +import { withResolvedActionClient } from "./client.js"; +import type { MatrixActionClientOpts } from "./types.js"; + +function normalizeOptionIndexes(indexes: number[]): number[] { + const normalized = indexes + .map((index) => Math.trunc(index)) + .filter((index) => Number.isFinite(index) && index > 0); + return Array.from(new Set(normalized)); +} + +function normalizeOptionIds(optionIds: string[]): string[] { + return Array.from( + new Set(optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0)), + ); +} + +function resolveSelectedAnswerIds(params: { + optionIds?: string[]; + optionIndexes?: number[]; + pollContent: PollStartContent; +}): { answerIds: string[]; labels: string[]; maxSelections: number } { + const parsed = parsePollStart(params.pollContent); + if (!parsed) { + throw new Error("Matrix poll vote requires a valid poll start event."); + } + + const selectedById = normalizeOptionIds(params.optionIds ?? []); + const selectedByIndex = normalizeOptionIndexes(params.optionIndexes ?? []).map((index) => { + const answer = parsed.answers[index - 1]; + if (!answer) { + throw new Error( + `Matrix poll option index ${index} is out of range for a poll with ${parsed.answers.length} options.`, + ); + } + return answer.id; + }); + + const answerIds = normalizeOptionIds([...selectedById, ...selectedByIndex]); + if (answerIds.length === 0) { + throw new Error("Matrix poll vote requires at least one poll option id or index."); + } + if (answerIds.length > parsed.maxSelections) { + throw new Error( + `Matrix poll allows at most ${parsed.maxSelections} selection${parsed.maxSelections === 1 ? "" : "s"}.`, + ); + } + + const answerMap = new Map(parsed.answers.map((answer) => [answer.id, answer.text] as const)); + const labels = answerIds.map((answerId) => { + const label = answerMap.get(answerId); + if (!label) { + throw new Error( + `Matrix poll option id "${answerId}" is not valid for poll ${parsed.question}.`, + ); + } + return label; + }); + + return { + answerIds, + labels, + maxSelections: parsed.maxSelections, + }; +} + +export async function voteMatrixPoll( + roomId: string, + pollId: string, + opts: MatrixActionClientOpts & { + optionId?: string; + optionIds?: string[]; + optionIndex?: number; + optionIndexes?: number[]; + } = {}, +) { + return await withResolvedActionClient(opts, async (client) => { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const pollEvent = await client.getEvent(resolvedRoom, pollId); + const eventType = typeof pollEvent.type === "string" ? pollEvent.type : ""; + if (!isPollStartType(eventType)) { + throw new Error(`Event ${pollId} is not a Matrix poll start event.`); + } + + const { answerIds, labels, maxSelections } = resolveSelectedAnswerIds({ + optionIds: [...(opts.optionIds ?? []), ...(opts.optionId ? [opts.optionId] : [])], + optionIndexes: [ + ...(opts.optionIndexes ?? []), + ...(opts.optionIndex !== undefined ? [opts.optionIndex] : []), + ], + pollContent: pollEvent.content as PollStartContent, + }); + + const content = buildPollResponseContent(pollId, answerIds); + const eventId = await client.sendEvent(resolvedRoom, "m.poll.response", content); + return { + eventId: eventId ?? null, + roomId: resolvedRoom, + pollId, + answerIds, + labels, + maxSelections, + }; + }); +} diff --git a/extensions/matrix-js/src/matrix/poll-types.test.ts b/extensions/matrix-js/src/matrix/poll-types.test.ts index 7f1797d99c6..3c78ab1b07c 100644 --- a/extensions/matrix-js/src/matrix/poll-types.test.ts +++ b/extensions/matrix-js/src/matrix/poll-types.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parsePollStartContent } from "./poll-types.js"; +import { + buildPollResponseContent, + buildPollStartContent, + parsePollStart, + parsePollStartContent, +} from "./poll-types.js"; describe("parsePollStartContent", () => { it("parses legacy m.poll payloads", () => { @@ -18,4 +23,73 @@ describe("parsePollStartContent", () => { expect(summary?.question).toBe("Lunch?"); expect(summary?.answers).toEqual(["Yes", "No"]); }); + + it("preserves answer ids when parsing poll start content", () => { + const parsed = parsePollStart({ + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Yes" }, + { id: "a2", "m.text": "No" }, + ], + }, + }); + + expect(parsed).toMatchObject({ + question: "Lunch?", + answers: [ + { id: "a1", text: "Yes" }, + { id: "a2", text: "No" }, + ], + maxSelections: 1, + }); + }); + + it("caps invalid remote max selections to the available answer count", () => { + const parsed = parsePollStart({ + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.undisclosed", + max_selections: 99, + answers: [ + { id: "a1", "m.text": "Yes" }, + { id: "a2", "m.text": "No" }, + ], + }, + }); + + expect(parsed?.maxSelections).toBe(2); + }); +}); + +describe("buildPollStartContent", () => { + it("preserves the requested multiselect cap instead of widening to all answers", () => { + const content = buildPollStartContent({ + question: "Lunch?", + options: ["Pizza", "Sushi", "Tacos"], + maxSelections: 2, + }); + + expect(content["m.poll.start"]?.max_selections).toBe(2); + expect(content["m.poll.start"]?.kind).toBe("m.poll.undisclosed"); + }); +}); + +describe("buildPollResponseContent", () => { + it("builds a poll response payload with a reference relation", () => { + expect(buildPollResponseContent("$poll", ["a2"])).toEqual({ + "m.poll.response": { + answers: ["a2"], + }, + "org.matrix.msc3381.poll.response": { + answers: ["a2"], + }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + }); }); diff --git a/extensions/matrix-js/src/matrix/poll-types.ts b/extensions/matrix-js/src/matrix/poll-types.ts index c3bebd38875..cb287056731 100644 --- a/extensions/matrix-js/src/matrix/poll-types.ts +++ b/extensions/matrix-js/src/matrix/poll-types.ts @@ -7,7 +7,7 @@ * - m.poll.end - Closes a poll */ -import type { PollInput } from "openclaw/plugin-sdk/matrix-js"; +import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/matrix-js"; export const M_POLL_START = "m.poll.start" as const; export const M_POLL_RESPONSE = "m.poll.response" as const; @@ -42,6 +42,11 @@ export type PollAnswer = { id: string; } & TextContent; +export type PollParsedAnswer = { + id: string; + text: string; +}; + export type PollStartSubtype = { question: TextContent; kind?: PollKind; @@ -72,6 +77,26 @@ export type PollSummary = { maxSelections: number; }; +export type ParsedPollStart = { + question: string; + answers: PollParsedAnswer[]; + kind: PollKind; + maxSelections: number; +}; + +export type PollResponseSubtype = { + answers: string[]; +}; + +export type PollResponseContent = { + [M_POLL_RESPONSE]?: PollResponseSubtype; + [ORG_POLL_RESPONSE]?: PollResponseSubtype; + "m.relates_to": { + rel_type: "m.reference"; + event_id: string; + }; +}; + export function isPollStartType(eventType: string): boolean { return (POLL_START_TYPES as readonly string[]).includes(eventType); } @@ -83,7 +108,7 @@ export function getTextContent(text?: TextContent): string { return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; } -export function parsePollStartContent(content: PollStartContent): PollSummary | null { +export function parsePollStart(content: PollStartContent): ParsedPollStart | null { const poll = (content as Record)[M_POLL_START] ?? (content as Record)[ORG_POLL_START] ?? @@ -92,24 +117,50 @@ export function parsePollStartContent(content: PollStartContent): PollSummary | return null; } - const question = getTextContent(poll.question); + const question = getTextContent(poll.question).trim(); if (!question) { return null; } const answers = poll.answers - .map((answer) => getTextContent(answer)) - .filter((a) => a.trim().length > 0); + .map((answer) => ({ + id: answer.id, + text: getTextContent(answer).trim(), + })) + .filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0); + if (answers.length === 0) { + return null; + } + + const maxSelectionsRaw = poll.max_selections; + const maxSelections = + typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) + ? Math.floor(maxSelectionsRaw) + : 1; + + return { + question, + answers, + kind: poll.kind ?? "m.poll.disclosed", + maxSelections: Math.min(Math.max(maxSelections, 1), answers.length), + }; +} + +export function parsePollStartContent(content: PollStartContent): PollSummary | null { + const parsed = parsePollStart(content); + if (!parsed) { + return null; + } return { eventId: "", roomId: "", sender: "", senderName: "", - question, - answers, - kind: poll.kind ?? "m.poll.disclosed", - maxSelections: poll.max_selections ?? 1, + question: parsed.question, + answers: parsed.answers.map((answer) => answer.text), + kind: parsed.kind, + maxSelections: parsed.maxSelections, }; } @@ -138,30 +189,44 @@ function buildPollFallbackText(question: string, answers: string[]): string { } export function buildPollStartContent(poll: PollInput): PollStartContent { - const question = poll.question.trim(); - const answers = poll.options - .map((option) => option.trim()) - .filter((option) => option.length > 0) - .map((option, idx) => ({ - id: `answer${idx + 1}`, - ...buildTextContent(option), - })); + const normalized = normalizePollInput(poll); + const answers = normalized.options.map((option, idx) => ({ + id: `answer${idx + 1}`, + ...buildTextContent(option), + })); - const isMultiple = (poll.maxSelections ?? 1) > 1; - const maxSelections = isMultiple ? Math.max(1, answers.length) : 1; + const isMultiple = normalized.maxSelections > 1; const fallbackText = buildPollFallbackText( - question, + normalized.question, answers.map((answer) => getTextContent(answer)), ); return { [M_POLL_START]: { - question: buildTextContent(question), + question: buildTextContent(normalized.question), kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed", - max_selections: maxSelections, + max_selections: normalized.maxSelections, answers, }, "m.text": fallbackText, "org.matrix.msc1767.text": fallbackText, }; } + +export function buildPollResponseContent( + pollEventId: string, + answerIds: string[], +): PollResponseContent { + return { + [M_POLL_RESPONSE]: { + answers: answerIds, + }, + [ORG_POLL_RESPONSE]: { + answers: answerIds, + }, + "m.relates_to": { + rel_type: "m.reference", + event_id: pollEventId, + }, + }; +} diff --git a/extensions/matrix-js/src/matrix/send.test.ts b/extensions/matrix-js/src/matrix/send.test.ts index d68cb35b088..b17e32b68a7 100644 --- a/extensions/matrix-js/src/matrix/send.test.ts +++ b/extensions/matrix-js/src/matrix/send.test.ts @@ -35,22 +35,28 @@ const runtimeStub = { } as unknown as PluginRuntime; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; +let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll; const makeClient = () => { const sendMessage = vi.fn().mockResolvedValue("evt1"); + const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote"); + const getEvent = vi.fn(); const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); const client = { sendMessage, + sendEvent, + getEvent, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), } as unknown as import("./sdk.js").MatrixClient; - return { client, sendMessage, uploadContent }; + return { client, sendMessage, sendEvent, getEvent, uploadContent }; }; describe("sendMessageMatrix media", () => { beforeAll(async () => { setMatrixRuntime(runtimeStub); ({ sendMessageMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); }); beforeEach(() => { @@ -196,6 +202,7 @@ describe("sendMessageMatrix threads", () => { beforeAll(async () => { setMatrixRuntime(runtimeStub); ({ sendMessageMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); }); beforeEach(() => { @@ -226,3 +233,114 @@ describe("sendMessageMatrix threads", () => { }); }); }); + +describe("voteMatrixPoll", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ voteMatrixPoll } = await import("./actions/polls.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("maps 1-based option indexes to Matrix poll answer ids", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + }); + + const result = await voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 2, + }); + + expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", { + "m.poll.response": { answers: ["a2"] }, + "org.matrix.msc3381.poll.response": { answers: ["a2"] }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + expect(result).toMatchObject({ + eventId: "evt-poll-vote", + roomId: "!room:example", + pollId: "$poll", + answerIds: ["a2"], + labels: ["Sushi"], + }); + }); + + it("rejects out-of-range option indexes", async () => { + const { client, getEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 2, + }), + ).rejects.toThrow("out of range"); + }); + + it("rejects votes that exceed the poll selection cap", async () => { + const { client, getEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndexes: [1, 2], + }), + ).rejects.toThrow("at most 1 selection"); + }); + + it("rejects non-poll events before sending a response", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.room.message", + content: { body: "hello" }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 1, + }), + ).rejects.toThrow("is not a Matrix poll start event"); + expect(sendEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix-js/src/tool-actions.test.ts b/extensions/matrix-js/src/tool-actions.test.ts new file mode 100644 index 00000000000..e509e70b505 --- /dev/null +++ b/extensions/matrix-js/src/tool-actions.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleMatrixAction } from "./tool-actions.js"; +import type { CoreConfig } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + voteMatrixPoll: vi.fn(), + reactMatrixMessage: vi.fn(), +})); + +vi.mock("./matrix/actions.js", async () => { + const actual = await vi.importActual("./matrix/actions.js"); + return { + ...actual, + voteMatrixPoll: mocks.voteMatrixPoll, + }; +}); + +vi.mock("./matrix/send.js", async () => { + const actual = await vi.importActual("./matrix/send.js"); + return { + ...actual, + reactMatrixMessage: mocks.reactMatrixMessage, + }; +}); + +describe("handleMatrixAction pollVote", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.voteMatrixPoll.mockResolvedValue({ + eventId: "evt-poll-vote", + roomId: "!room:example", + pollId: "$poll", + answerIds: ["a1", "a2"], + labels: ["Pizza", "Sushi"], + maxSelections: 2, + }); + }); + + it("parses snake_case vote params and forwards normalized selectors", async () => { + const result = await handleMatrixAction( + { + action: "pollVote", + account_id: "main", + room_id: "!room:example", + poll_id: "$poll", + poll_option_id: "a1", + poll_option_ids: ["a2", ""], + poll_option_index: "2", + poll_option_indexes: ["1", "bogus"], + }, + {} as CoreConfig, + ); + + expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", { + accountId: "main", + optionIds: ["a2", "a1"], + optionIndexes: [1, 2], + }); + expect(result.details).toMatchObject({ + ok: true, + result: { + eventId: "evt-poll-vote", + answerIds: ["a1", "a2"], + }, + }); + }); + + it("rejects missing poll ids", async () => { + await expect( + handleMatrixAction( + { + action: "pollVote", + roomId: "!room:example", + pollOptionIndex: 1, + }, + {} as CoreConfig, + ), + ).rejects.toThrow("pollId required"); + }); +}); diff --git a/extensions/matrix-js/src/tool-actions.ts b/extensions/matrix-js/src/tool-actions.ts index 88b584cee4e..bdf38f92513 100644 --- a/extensions/matrix-js/src/tool-actions.ts +++ b/extensions/matrix-js/src/tool-actions.ts @@ -2,8 +2,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { createActionGate, jsonResult, + readNumberArrayParam, readNumberParam, readReactionParams, + readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/matrix-js"; import { @@ -34,6 +36,7 @@ import { sendMatrixMessage, startMatrixVerification, unpinMatrixMessage, + voteMatrixPoll, verifyMatrixRecoveryKey, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; @@ -42,6 +45,7 @@ import type { CoreConfig } from "./types.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +const pollActions = new Set(["pollVote"]); const verificationActions = new Set([ "encryptionStatus", "verificationList", @@ -104,6 +108,27 @@ export async function handleMatrixAction( return jsonResult({ ok: true, reactions }); } + if (pollActions.has(action)) { + const roomId = readRoomId(params); + const pollId = readStringParam(params, "pollId", { required: true }); + const optionId = readStringParam(params, "pollOptionId"); + const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true }); + const optionIds = [ + ...(readStringArrayParam(params, "pollOptionIds") ?? []), + ...(optionId ? [optionId] : []), + ]; + const optionIndexes = [ + ...(readNumberArrayParam(params, "pollOptionIndexes", { integer: true }) ?? []), + ...(optionIndex !== undefined ? [optionIndex] : []), + ]; + const result = await voteMatrixPoll(roomId, pollId, { + accountId, + optionIds, + optionIndexes, + }); + return jsonResult({ ok: true, result }); + } + if (messageActions.has(action)) { if (!isActionEnabled("messages")) { throw new Error("Matrix messages are disabled."); diff --git a/src/plugin-sdk/matrix-js.ts b/src/plugin-sdk/matrix-js.ts index f5f3557ab0a..572e1500280 100644 --- a/src/plugin-sdk/matrix-js.ts +++ b/src/plugin-sdk/matrix-js.ts @@ -1,11 +1,105 @@ -// Matrix-js plugin-sdk surface. -// Reuse matrix exports to avoid drift between the two Matrix channel implementations. +// Narrow plugin-sdk surface for the bundled matrix-js plugin. +// Keep this list additive and scoped to symbols used under extensions/matrix-js. -export * from "./matrix.js"; - -export type { ChannelSetupInput } from "../channels/plugins/types.js"; -export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; +export { + createActionGate, + jsonResult, + readNumberArrayParam, + readNumberParam, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "../agents/tools/common.js"; +export type { ReplyPayload } from "../auto-reply/types.js"; +export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; +export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; +export { resolveControlCommandGate } from "../channels/command-gating.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export { formatLocationText, toLocationContext } from "../channels/location.js"; +export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; +export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; +export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; +export { + buildChannelKeyCandidates, + resolveChannelEntryMatch, +} from "../channels/plugins/channel-config.js"; +export { + deleteAccountFromConfigSection, + setAccountEnabledInConfigSection, +} from "../channels/plugins/config-helpers.js"; +export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; +export type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, +} from "../channels/plugins/onboarding-types.js"; +export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; +export { + addWildcardAllowFrom, + mergeAllowFromEntries, + promptSingleChannelSecretInput, +} from "../channels/plugins/onboarding/helpers.js"; +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; export { migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js"; -export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; -export { writeJsonFileAtomically } from "./json-store.js"; +export type { + BaseProbeResult, + ChannelDirectoryEntry, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelOutboundAdapter, + ChannelResolveKind, + ChannelResolveResult, + ChannelToolSend, +} from "../channels/plugins/types.js"; +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 type { OpenClawConfig } from "../config/config.js"; +export { + GROUP_POLICY_BLOCKED_LABEL, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +} from "../config/runtime-group-policy.js"; +export type { + DmPolicy, + GroupPolicy, + GroupToolPolicyConfig, + MarkdownTableMode, +} from "../config/types.js"; +export type { SecretInput } from "../config/types.secrets.js"; +export { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "../config/types.secrets.js"; +export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; +export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; +export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; +export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; +export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; +export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { PollInput } from "../polls.js"; +export { normalizePollInput } from "../polls.js"; +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export type { RuntimeEnv } from "../runtime.js"; +export { + readStoreAllowFromForDmPolicy, + resolveDmGroupAccessWithLists, +} from "../security/dm-policy-shared.js"; +export { formatDocsLink } from "../terminal/links.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; +export { createScopedPairingAccess } from "./pairing-access.js"; +export { writeJsonFileAtomically } from "./json-store.js"; +export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; +export { runPluginCommandWithTimeout } from "./run-command.js"; +export { createLoggerBackedRuntime } from "./runtime.js"; +export { buildProbeChannelStatusSummary } from "./status-helpers.js"; +export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; +export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 577a690a4cb..cba9b03eb9c 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -4,8 +4,10 @@ export { createActionGate, jsonResult, + readNumberArrayParam, readNumberParam, readReactionParams, + readStringArrayParam, readStringParam, } from "../agents/tools/common.js"; export type { ReplyPayload } from "../auto-reply/types.js"; @@ -89,6 +91,7 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PollInput } from "../polls.js"; +export { normalizePollInput } from "../polls.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js"; export {