diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7d49323892d..b50797537a6 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -235,6 +235,17 @@ We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled plugins should import their own local runtime code directly from their extension-owned modules. +For polls specifically, there are two execution paths: + +- `outbound.sendPoll` is the shared baseline for channels that fit the common + poll model +- `actions.handleAction("poll")` is the preferred path for channel-specific + poll semantics or extra poll parameters + +Core now defers shared poll parsing until after plugin poll dispatch declines +the action, so plugin-owned poll handlers can accept channel-specific poll +fields without being blocked by the generic poll parser first. + ## Capability ownership model OpenClaw treats a native plugin as the ownership boundary for a **company** or a diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c31d6057223..7274d612c7c 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -170,6 +170,10 @@ export type ChannelOutboundAdapter = { ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; + /** + * Shared outbound poll adapter for channels that fit the common poll model. + * Channels with extra poll semantics should prefer `actions.handleAction("poll")`. + */ sendPoll?: (ctx: ChannelPollContext) => Promise; }; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1699b8024a5..24c5c96708e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -521,6 +521,10 @@ export type ChannelMessageActionAdapter = { toolContext?: ChannelThreadingToolContext; }) => boolean; extractToolSend?: (params: { args: Record }) => ChannelToolSend | null; + /** + * Prefer this for channel-specific poll semantics or extra poll parameters. + * Core only parses the shared poll model when falling back to `outbound.sendPoll`. + */ handleAction?: (ctx: ChannelMessageActionContext) => Promise>; }; diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index f875bb40487..55290b8d9d1 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -408,6 +408,99 @@ describe("runMessageAction plugin dispatch", () => { }); }); + describe("plugin-owned poll semantics", () => { + const handleAction = vi.fn(async ({ params }: { params: Record }) => + jsonResult({ + ok: true, + forwarded: { + to: params.to ?? null, + pollQuestion: params.pollQuestion ?? null, + pollOption: params.pollOption ?? null, + pollDurationSeconds: params.pollDurationSeconds ?? null, + pollPublic: params.pollPublic ?? null, + }, + }), + ); + + const discordPollPlugin: ChannelPlugin = { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "Discord plugin-owned poll test plugin.", + }, + capabilities: { chatTypes: ["direct"] }, + config: createAlwaysConfiguredPluginConfig(), + messaging: { + targetResolver: { + looksLikeId: () => true, + }, + }, + actions: { + supportsAction: ({ action }) => action === "poll", + handleAction, + }, + }; + + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: discordPollPlugin, + }, + ]), + ); + handleAction.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + vi.clearAllMocks(); + }); + + it("lets non-telegram plugins own extra poll fields", async () => { + const result = await runMessageAction({ + cfg: { + channels: { + discord: { + token: "tok", + }, + }, + } as OpenClawConfig, + action: "poll", + params: { + channel: "discord", + target: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }, + dryRun: false, + }); + + expect(result.kind).toBe("poll"); + expect(result.handledBy).toBe("plugin"); + expect(handleAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "poll", + channel: "discord", + params: expect.objectContaining({ + to: "channel:123", + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 120, + pollPublic: true, + }), + }), + ); + }); + }); + describe("components parsing", () => { const handleAction = vi.fn(async ({ params }: { params: Record }) => jsonResult({ diff --git a/src/infra/outbound/message-action-runner.poll.test.ts b/src/infra/outbound/message-action-runner.poll.test.ts index ed1beb91f5d..a46e66dd872 100644 --- a/src/infra/outbound/message-action-runner.poll.test.ts +++ b/src/infra/outbound/message-action-runner.poll.test.ts @@ -34,15 +34,24 @@ async function runPollAction(params: { params: params.actionParams as never, toolContext: params.toolContext as never, }); - return mocks.executePollAction.mock.calls[0]?.[0] as + const call = mocks.executePollAction.mock.calls[0]?.[0] as | { - durationSeconds?: number; - maxSelections?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll?: () => { + durationSeconds?: number; + maxSelections?: number; + threadId?: string; + isAnonymous?: boolean; + }; ctx?: { params?: Record }; } | undefined; + if (!call) { + return undefined; + } + return { + ...call.resolveCorePoll?.(), + ctx: call.ctx, + }; } describe("runMessageAction poll handling", () => { beforeEach(async () => { @@ -55,11 +64,11 @@ describe("runMessageAction poll handling", () => { telegramConfig, } = await import("./message-action-runner.test-helpers.js")); installMessageActionRunnerTestRegistry(); - mocks.executePollAction.mockResolvedValue({ + mocks.executePollAction.mockImplementation(async (input) => ({ handledBy: "core", - payload: { ok: true }, + payload: { ok: true, corePoll: input.resolveCorePoll() }, pollResult: { ok: true }, - }); + })); }); afterEach(() => { @@ -105,7 +114,7 @@ describe("runMessageAction poll handling", () => { }, ])("$name", async ({ getCfg, actionParams, message }) => { await expect(runPollAction({ cfg: getCfg(), actionParams })).rejects.toThrow(message); - expect(mocks.executePollAction).not.toHaveBeenCalled(); + expect(mocks.executePollAction).toHaveBeenCalledTimes(1); }); it("passes Telegram durationSeconds, visibility, and auto threadId to executePollAction", async () => { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 70646a288a2..1777fbb32e3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -591,34 +591,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const options = readStringArrayParam(params, "pollOption", { required: true }); + if (options.length < 2) { + throw new Error("pollOption requires at least two values"); + } + const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false; + const pollAnonymous = readBooleanParam(params, "pollAnonymous"); + const pollPublic = readBooleanParam(params, "pollPublic"); + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + strict: true, + }); + + if (durationSeconds !== undefined && channel !== "telegram") { + throw new Error("pollDurationSeconds is only supported for Telegram polls"); + } + if (isAnonymous !== undefined && channel !== "telegram") { + throw new Error("pollAnonymous/pollPublic are only supported for Telegram polls"); + } + + return { + to, + question, + options, + maxSelections: resolvePollMaxSelections(options.length, allowMultiselect), + durationSeconds: durationSeconds ?? undefined, + durationHours: durationHours ?? undefined, + threadId: resolvedThreadId ?? undefined, + isAnonymous, + }; + }, }); return { diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 3f3fd0f2fcc..6f0cf32e6e5 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -143,16 +143,44 @@ describe("executeSendAction", () => { params: {}, dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + }), }); expect(result.handledBy).toBe("plugin"); expect(mocks.sendPoll).not.toHaveBeenCalled(); }); + it("does not invoke shared poll parsing before plugin poll dispatch", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin")); + const resolveCorePoll = vi.fn(() => { + throw new Error("shared poll fallback should not run"); + }); + + const result = await executePollAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { + pollQuestion: "Lunch?", + pollOption: ["Pizza", "Sushi"], + pollDurationSeconds: 90, + pollPublic: true, + }, + dryRun: false, + }, + resolveCorePoll, + }); + + expect(result.handledBy).toBe("plugin"); + expect(resolveCorePoll).not.toHaveBeenCalled(); + expect(mocks.sendPoll).not.toHaveBeenCalled(); + }); + it("passes agent-scoped media local roots to plugin dispatch", async () => { mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); @@ -270,13 +298,15 @@ describe("executeSendAction", () => { accountId: "acc-1", dryRun: false, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationSeconds: 300, - threadId: "thread-1", - isAnonymous: true, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationSeconds: 300, + threadId: "thread-1", + isAnonymous: true, + }), }); expect(mocks.sendPoll).toHaveBeenCalledWith( @@ -321,11 +351,13 @@ describe("executeSendAction", () => { mode: GATEWAY_CLIENT_MODES.BACKEND, }, }, - to: "channel:123", - question: "Lunch?", - options: ["Pizza", "Sushi"], - maxSelections: 1, - durationHours: 6, + resolveCorePoll: () => ({ + to: "channel:123", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + durationHours: 6, + }), }); expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 5d518798afa..b56fade5923 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -152,14 +152,16 @@ export async function executeSendAction(params: { export async function executePollAction(params: { ctx: OutboundSendContext; - to: string; - question: string; - options: string[]; - maxSelections: number; - durationSeconds?: number; - durationHours?: number; - threadId?: string; - isAnonymous?: boolean; + resolveCorePoll: () => { + to: string; + question: string; + options: string[]; + maxSelections: number; + durationSeconds?: number; + durationHours?: number; + threadId?: string; + isAnonymous?: boolean; + }; }): Promise<{ handledBy: "plugin" | "core"; payload: unknown; @@ -174,19 +176,20 @@ export async function executePollAction(params: { return pluginHandled; } + const corePoll = params.resolveCorePoll(); const result: MessagePollResult = await sendPoll({ cfg: params.ctx.cfg, - to: params.to, - question: params.question, - options: params.options, - maxSelections: params.maxSelections, - durationSeconds: params.durationSeconds ?? undefined, - durationHours: params.durationHours ?? undefined, + to: corePoll.to, + question: corePoll.question, + options: corePoll.options, + maxSelections: corePoll.maxSelections, + durationSeconds: corePoll.durationSeconds ?? undefined, + durationHours: corePoll.durationHours ?? undefined, channel: params.ctx.channel, accountId: params.ctx.accountId ?? undefined, - threadId: params.threadId ?? undefined, + threadId: corePoll.threadId ?? undefined, silent: params.ctx.silent ?? undefined, - isAnonymous: params.isAnonymous ?? undefined, + isAnonymous: corePoll.isAnonymous ?? undefined, dryRun: params.ctx.dryRun, gateway: params.ctx.gateway, });