mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 07:20:59 +00:00
Polls: defer shared parsing until plugin fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -170,6 +170,10 @@ export type ChannelOutboundAdapter = {
|
||||
) => Promise<OutboundDeliveryResult>;
|
||||
sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
|
||||
/**
|
||||
* 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<ChannelPollResult>;
|
||||
};
|
||||
|
||||
|
||||
@@ -521,6 +521,10 @@ export type ChannelMessageActionAdapter = {
|
||||
toolContext?: ChannelThreadingToolContext;
|
||||
}) => boolean;
|
||||
extractToolSend?: (params: { args: Record<string, unknown> }) => 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<AgentToolResult<unknown>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -408,6 +408,99 @@ describe("runMessageAction plugin dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin-owned poll semantics", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
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<string, unknown> }) =>
|
||||
jsonResult({
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
}
|
||||
| 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 () => {
|
||||
|
||||
@@ -591,34 +591,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
throwIfAborted(abortSignal);
|
||||
const action: ChannelMessageActionName = "poll";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
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 silent = readBooleanParam(params, "silent");
|
||||
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,
|
||||
});
|
||||
const maxSelections = resolvePollMaxSelections(options.length, allowMultiselect);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const resolvedThreadId = resolveAndApplyOutboundThreadId(params, {
|
||||
cfg,
|
||||
@@ -652,14 +625,45 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
dryRun,
|
||||
silent: silent ?? undefined,
|
||||
},
|
||||
to,
|
||||
question,
|
||||
options,
|
||||
maxSelections,
|
||||
durationSeconds: durationSeconds ?? undefined,
|
||||
durationHours: durationHours ?? undefined,
|
||||
threadId: resolvedThreadId ?? undefined,
|
||||
isAnonymous,
|
||||
resolveCorePoll: () => {
|
||||
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user