mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
make matrix-js atomic and add poll voting support
This commit is contained in:
69
extensions/matrix-js/src/actions.test.ts
Normal file
69
extensions/matrix-js/src/actions.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ChannelMessageActionName>([
|
||||
"send",
|
||||
"poll-vote",
|
||||
"react",
|
||||
"reactions",
|
||||
"read",
|
||||
"edit",
|
||||
"delete",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"permissions",
|
||||
]);
|
||||
|
||||
function createMatrixJsExposedActions() {
|
||||
return new Set<ChannelMessageActionName>(["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<ChannelMessageActionName>(["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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
111
extensions/matrix-js/src/matrix/actions/polls.ts
Normal file
111
extensions/matrix-js/src/matrix/actions/polls.ts
Normal file
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
||||
(content as Record<string, PollStartSubtype | undefined>)[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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
80
extensions/matrix-js/src/tool-actions.test.ts
Normal file
80
extensions/matrix-js/src/tool-actions.test.ts
Normal file
@@ -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<typeof import("./matrix/actions.js")>("./matrix/actions.js");
|
||||
return {
|
||||
...actual,
|
||||
voteMatrixPoll: mocks.voteMatrixPoll,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./matrix/send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./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");
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user