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

@@ -329,6 +329,44 @@ describe("handleDiscordMessageAction", () => {
answers: ["Yes", "No"],
},
},
{
name: "parses string booleans for discord poll adapter params",
input: {
action: "poll" as const,
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollMulti: "true",
},
},
expected: {
action: "poll",
to: "channel:123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
},
},
{
name: "rejects partially numeric poll duration for discord poll adapter params",
input: {
action: "poll" as const,
params: {
to: "channel:123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollDurationHours: "24h",
},
},
expected: {
action: "poll",
to: "channel:123",
question: "Ready?",
answers: ["Yes", "No"],
durationHours: undefined,
},
},
{
name: "forwards accountId for thread replies",
input: {
@@ -496,6 +534,71 @@ describe("handleDiscordMessageAction", () => {
});
describe("telegramMessageActions", () => {
it("lists poll when telegram is configured", () => {
const actions = telegramMessageActions.listActions?.({ cfg: telegramCfg() }) ?? [];
expect(actions).toContain("poll");
});
it("omits poll when sendMessage is disabled", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
actions: { sendMessage: false },
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("poll");
});
it("omits poll when poll actions are disabled", () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
actions: { poll: false },
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("poll");
});
it("omits poll when sendMessage and poll are split across accounts", () => {
const cfg = {
channels: {
telegram: {
accounts: {
senderOnly: {
botToken: "tok-send",
actions: {
sendMessage: true,
poll: false,
},
},
pollOnly: {
botToken: "tok-poll",
actions: {
sendMessage: false,
poll: true,
},
},
},
},
},
} as OpenClawConfig;
const actions = telegramMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("poll");
});
it("lists sticker actions only when enabled by config", () => {
const cases = [
{
@@ -595,6 +698,85 @@ describe("telegramMessageActions", () => {
accountId: undefined,
},
},
{
name: "poll maps to telegram poll action",
action: "poll" as const,
params: {
to: "123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollMulti: true,
pollDurationSeconds: 60,
pollPublic: true,
replyTo: 55,
threadId: 77,
silent: true,
},
expectedPayload: {
action: "poll",
to: "123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
durationHours: undefined,
durationSeconds: 60,
replyToMessageId: 55,
messageThreadId: 77,
isAnonymous: false,
silent: true,
accountId: undefined,
},
},
{
name: "poll parses string booleans before telegram action handoff",
action: "poll" as const,
params: {
to: "123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollMulti: "true",
pollPublic: "true",
silent: "true",
},
expectedPayload: {
action: "poll",
to: "123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
durationHours: undefined,
durationSeconds: undefined,
replyToMessageId: undefined,
messageThreadId: undefined,
isAnonymous: false,
silent: true,
accountId: undefined,
},
},
{
name: "poll rejects partially numeric duration strings before telegram action handoff",
action: "poll" as const,
params: {
to: "123",
pollQuestion: "Ready?",
pollOption: ["Yes", "No"],
pollDurationSeconds: "60s",
},
expectedPayload: {
action: "poll",
to: "123",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: undefined,
durationHours: undefined,
durationSeconds: undefined,
replyToMessageId: undefined,
messageThreadId: undefined,
isAnonymous: undefined,
silent: undefined,
accountId: undefined,
},
},
{
name: "topic-create maps to createForumTopic",
action: "topic-create" as const,

View File

@@ -7,6 +7,7 @@ import {
import { readDiscordParentIdParam } from "../../../../agents/tools/discord-actions-shared.js";
import { handleDiscordAction } from "../../../../agents/tools/discord-actions.js";
import { resolveDiscordChannelId } from "../../../../discord/targets.js";
import { readBooleanParam } from "../../../../plugin-sdk/boolean-param.js";
import type { ChannelMessageActionContext } from "../../types.js";
import { resolveReactionMessageId } from "../reaction-message-id.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
@@ -38,7 +39,7 @@ export async function handleDiscordMessageAction(
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const asVoice = params.asVoice === true;
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents = params.components;
const hasComponents =
Boolean(rawComponents) &&
@@ -57,7 +58,7 @@ export async function handleDiscordMessageAction(
const replyTo = readStringParam(params, "replyTo");
const rawEmbeds = params.embeds;
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
const silent = params.silent === true;
const silent = readBooleanParam(params, "silent") === true;
const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId");
return await handleDiscordAction(
@@ -86,10 +87,11 @@ export async function handleDiscordMessageAction(
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const answers = readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const answers = readStringArrayParam(params, "pollOption", { required: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
return await handleDiscordAction(
{
@@ -116,7 +118,7 @@ export async function handleDiscordMessageAction(
);
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const remove = readBooleanParam(params, "remove");
return await handleDiscordAction(
{
action: "react",

View File

@@ -6,10 +6,13 @@ import {
} from "../../../agents/tools/common.js";
import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js";
import type { TelegramActionConfig } from "../../../config/types.telegram.js";
import { readBooleanParam } from "../../../plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../poll-params.js";
import {
createTelegramActionGate,
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "../../../telegram/accounts.js";
import { isTelegramInlineButtonsEnabled } from "../../../telegram/inline-buttons.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
@@ -27,8 +30,8 @@ function readTelegramSendParams(params: Record<string, unknown>) {
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
const silent = typeof params.silent === "boolean" ? params.silent : undefined;
const asVoice = readBooleanParam(params, "asVoice");
const silent = readBooleanParam(params, "silent");
const quoteText = readStringParam(params, "quoteText");
return {
to,
@@ -78,6 +81,16 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) =>
gate(key, defaultValue);
const actions = new Set<ChannelMessageActionName>(["send"]);
const pollEnabledForAnyAccount = accounts.some((account) => {
const accountGate = createTelegramActionGate({
cfg,
accountId: account.accountId,
});
return resolveTelegramPollActionGateState(accountGate).enabled;
});
if (pollEnabledForAnyAccount) {
actions.add("poll");
}
if (isEnabled("reactions")) {
actions.add("react");
}
@@ -125,7 +138,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
if (action === "react") {
const messageId = resolveReactionMessageId({ args: params, toolContext });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const remove = readBooleanParam(params, "remove");
return await handleTelegramAction(
{
action: "react",
@@ -140,6 +153,45 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", { required: true });
const answers = readStringArrayParam(params, "pollOption", { required: true });
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
strict: true,
});
const replyToMessageId = readNumberParam(params, "replyTo", { integer: true });
const messageThreadId = readNumberParam(params, "threadId", { integer: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic });
const silent = readBooleanParam(params, "silent");
return await handleTelegramAction(
{
action: "poll",
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
durationSeconds: durationSeconds ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
isAnonymous,
silent,
accountId: accountId ?? undefined,
},
cfg,
{ mediaLocalRoots },
);
}
if (action === "delete") {
const chatId = readTelegramChatIdParam(params);
const messageId = readTelegramMessageIdParam(params);

View File

@@ -336,6 +336,12 @@ export type ChannelToolSend = {
};
export type ChannelMessageActionAdapter = {
/**
* Advertise agent-discoverable actions for this channel.
* Keep this aligned with any gated capability checks. Poll discovery is
* not inferred from `outbound.sendPoll`, so channels that want agents to
* create polls should include `"poll"` here when enabled.
*/
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean;