Harden Telegram poll gating and schema consistency (#36547)

Merged via squash.

Prepared head SHA: f77824419e
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-05 19:24:43 -05:00
committed by GitHub
parent f771ba8de9
commit 6dfd39c32f
27 changed files with 1129 additions and 65 deletions

View File

@@ -236,6 +236,72 @@ describe("runMessageAction context isolation", () => {
).rejects.toThrow(/message required/i);
});
it("rejects send actions that include poll creation params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/use action "poll" instead of "send"/i);
});
it("rejects send actions that include string-encoded poll params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollDurationSeconds: "60",
pollPublic: "true",
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/use action "poll" instead of "send"/i);
});
it("rejects send actions that include snake_case poll params", async () => {
await expect(
runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
poll_question: "Ready?",
poll_option: ["Yes", "No"],
poll_public: "true",
},
toolContext: { currentChannelId: "C12345678" },
}),
).rejects.toThrow(/use action "poll" instead of "send"/i);
});
it("allows send when poll booleans are explicitly false", async () => {
const result = await runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
message: "hi",
pollMulti: false,
pollAnonymous: false,
pollPublic: false,
},
toolContext: { currentChannelId: "C12345678" },
});
expect(result.kind).toBe("send");
});
it("blocks send when target differs from current channel", async () => {
const result = await runDrySend({
cfg: slackConfig,
@@ -902,6 +968,114 @@ describe("runMessageAction card-only send behavior", () => {
});
});
describe("runMessageAction telegram plugin poll forwarding", () => {
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,
threadId: params.threadId ?? null,
},
}),
);
const telegramPollPlugin: ChannelPlugin = {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram poll forwarding test plugin.",
},
capabilities: { chatTypes: ["direct"] },
config: createAlwaysConfiguredPluginConfig(),
messaging: {
targetResolver: {
looksLikeId: () => true,
},
},
actions: {
listActions: () => ["poll"],
supportsAction: ({ action }) => action === "poll",
handleAction,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: telegramPollPlugin,
},
]),
);
handleAction.mockClear();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("forwards telegram poll params through plugin dispatch", async () => {
const result = await runMessageAction({
cfg: {
channels: {
telegram: {
botToken: "tok",
},
},
} as OpenClawConfig,
action: "poll",
params: {
channel: "telegram",
target: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
dryRun: false,
});
expect(result.kind).toBe("poll");
expect(result.handledBy).toBe("plugin");
expect(handleAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
channel: "telegram",
params: expect.objectContaining({
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
}),
}),
);
expect(result.payload).toMatchObject({
ok: true,
forwarded: {
to: "telegram:123",
pollQuestion: "Lunch?",
pollOption: ["Pizza", "Sushi"],
pollDurationSeconds: 120,
pollPublic: true,
threadId: "42",
},
});
});
});
describe("runMessageAction components parsing", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({

View File

@@ -14,6 +14,8 @@ import type {
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { type GatewayClientMode, type GatewayClientName } from "../../utils/message-channel.js";
@@ -307,7 +309,7 @@ async function handleBroadcastAction(
if (!broadcastEnabled) {
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
}
const rawTargets = readStringArrayParam(params, "targets", { required: true }) ?? [];
const rawTargets = readStringArrayParam(params, "targets", { required: true });
if (rawTargets.length === 0) {
throw new Error("Broadcast requires at least one target in --targets.");
}
@@ -571,7 +573,7 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const options = readStringArrayParam(params, "pollOption", { required: true });
if (options.length < 2) {
throw new Error("pollOption requires at least two values");
}
@@ -579,17 +581,16 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
if (pollAnonymous && pollPublic) {
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
}
const isAnonymous = pollAnonymous ? true : pollPublic ? false : undefined;
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 = allowMultiselect ? Math.max(2, options.length) : 1;
const maxSelections = resolvePollMaxSelections(options.length, allowMultiselect);
if (durationSeconds !== undefined && channel !== "telegram") {
throw new Error("pollDurationSeconds is only supported for Telegram polls");
@@ -766,6 +767,10 @@ export async function runMessageAction(
cfg,
});
if (action === "send" && hasPollCreationParams(params)) {
throw new Error('Poll fields require action "poll"; use action "poll" instead of "send".');
}
const gateway = resolveGateway(input);
if (action === "send") {