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

@@ -141,6 +141,7 @@ Docs: https://docs.openclaw.ai
- Feishu/groupPolicy legacy alias compatibility: treat legacy `groupPolicy: "allowall"` as `open` in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when `groupAllowFrom` is empty. (from #36358) Thanks @Sid-Qin.
- Mattermost/plugin SDK import policy: replace remaining monolithic `openclaw/plugin-sdk` imports in Mattermost mention-gating paths/tests with scoped subpaths (`openclaw/plugin-sdk/compat` and `openclaw/plugin-sdk/mattermost`) so `pnpm check` passes `lint:plugins:no-monolithic-plugin-sdk-entry-imports` on baseline. (#36480) Thanks @Takhoffman.
- Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (`sendMessage` + `poll`). (#36547) thanks @gumadeiras.
- Agents/failover cooldown classification: stop treating generic `cooling down` text as provider `rate_limit` so healthy models no longer show false global cooldown/rate-limit warnings while explicit `model_cooldown` markers still trigger failover. (#32972) thanks @stakeswky.
- Agents/failover service-unavailable handling: stop treating bare proxy/CDN `service unavailable` errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.

View File

@@ -10,6 +10,7 @@ title: "Polls"
## Supported channels
- Telegram
- WhatsApp (web channel)
- Discord
- MS Teams (Adaptive Cards)
@@ -17,6 +18,13 @@ title: "Polls"
## CLI
```bash
# Telegram
openclaw message poll --channel telegram --target 123456789 \
--poll-question "Ship it?" --poll-option "Yes" --poll-option "No"
openclaw message poll --channel telegram --target -1001234567890:topic:42 \
--poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \
--poll-duration-seconds 300
# WhatsApp
openclaw message poll --target +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
@@ -36,9 +44,11 @@ openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv
Options:
- `--channel`: `whatsapp` (default), `discord`, or `msteams`
- `--channel`: `whatsapp` (default), `telegram`, `discord`, or `msteams`
- `--poll-multi`: allow selecting multiple options
- `--poll-duration-hours`: Discord-only (defaults to 24 when omitted)
- `--poll-duration-seconds`: Telegram-only (5-600 seconds)
- `--poll-anonymous` / `--poll-public`: Telegram-only poll visibility
## Gateway RPC
@@ -51,11 +61,14 @@ Params:
- `options` (string[], required)
- `maxSelections` (number, optional)
- `durationHours` (number, optional)
- `durationSeconds` (number, optional, Telegram-only)
- `isAnonymous` (boolean, optional, Telegram-only)
- `channel` (string, optional, default: `whatsapp`)
- `idempotencyKey` (string, required)
## Channel differences
- Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls.
- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`.
- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count.
- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored.
@@ -64,6 +77,10 @@ Params:
Use the `message` tool with `poll` action (`to`, `pollQuestion`, `pollOption`, optional `pollMulti`, `pollDurationHours`, `channel`).
For Telegram, the tool also accepts `pollDurationSeconds`, `pollAnonymous`, and `pollPublic`.
Use `action: "poll"` for poll creation. Poll fields passed with `action: "send"` are rejected.
Note: Discord has no “pick exactly N” mode; `pollMulti` maps to multi-select.
Teams polls are rendered as Adaptive Cards and require the gateway to stay online
to record votes in `~/.openclaw/msteams-polls.json`.

View File

@@ -732,6 +732,28 @@ openclaw message send --channel telegram --target 123456789 --message "hi"
openclaw message send --channel telegram --target @name --message "hi"
```
Telegram polls use `openclaw message poll` and support forum topics:
```bash
openclaw message poll --channel telegram --target 123456789 \
--poll-question "Ship it?" --poll-option "Yes" --poll-option "No"
openclaw message poll --channel telegram --target -1001234567890:topic:42 \
--poll-question "Pick a time" --poll-option "10am" --poll-option "2pm" \
--poll-duration-seconds 300 --poll-public
```
Telegram-only poll flags:
- `--poll-duration-seconds` (5-600)
- `--poll-anonymous`
- `--poll-public`
- `--thread-id` for forum topics (or use a `:topic:` target)
Action gating:
- `channels.telegram.actions.sendMessage=false` disables outbound Telegram messages, including polls
- `channels.telegram.actions.poll=false` disables Telegram poll creation while leaving regular sends enabled
</Accordion>
</AccordionGroup>
@@ -813,6 +835,7 @@ Primary reference:
- `channels.telegram.tokenFile`: read token from file path.
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.telegram.allowFrom`: DM allowlist (numeric Telegram user IDs). `allowlist` requires at least one sender ID. `open` requires `"*"`. `openclaw doctor --fix` can resolve legacy `@username` entries to IDs and can recover allowlist entries from pairing-store files in allowlist migration flows.
- `channels.telegram.actions.poll`: enable or disable Telegram poll creation (default: enabled; still requires `sendMessage`).
- `channels.telegram.defaultTo`: default Telegram target used by CLI `--deliver` when no explicit `--reply-to` is provided.
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).

View File

@@ -4,7 +4,11 @@ import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { defaultRuntime } from "../runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { __testing, listAllChannelSupportedActions } from "./channel-tools.js";
import {
__testing,
listAllChannelSupportedActions,
listChannelSupportedActions,
} from "./channel-tools.js";
describe("channel tools", () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
@@ -49,4 +53,35 @@ describe("channel tools", () => {
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it("does not infer poll actions from outbound adapters when action discovery omits them", () => {
const plugin: ChannelPlugin = {
id: "polltest",
meta: {
id: "polltest",
label: "Poll Test",
selectionLabel: "Poll Test",
docsPath: "/channels/polltest",
blurb: "poll plugin",
},
capabilities: { chatTypes: ["direct"], polls: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
actions: {
listActions: () => [],
},
outbound: {
deliveryMode: "gateway",
sendPoll: async () => ({ channel: "polltest", messageId: "poll-1" }),
},
};
setActivePluginRegistry(createTestRegistry([{ pluginId: "polltest", source: "test", plugin }]));
const cfg = {} as OpenClawConfig;
expect(listChannelSupportedActions({ cfg, channel: "polltest" })).toEqual([]);
expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
});
});

View File

@@ -48,6 +48,16 @@ describe("readNumberParam", () => {
expect(readNumberParam(params, "messageId")).toBe(42);
});
it("keeps partial parse behavior by default", () => {
const params = { messageId: "42abc" };
expect(readNumberParam(params, "messageId")).toBe(42);
});
it("rejects partial numeric strings when strict is enabled", () => {
const params = { messageId: "42abc" };
expect(readNumberParam(params, "messageId", { strict: true })).toBeUndefined();
});
it("truncates when integer is true", () => {
const params = { messageId: "42.9" };
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);

View File

@@ -129,9 +129,9 @@ export function readStringOrNumberParam(
export function readNumberParam(
params: Record<string, unknown>,
key: string,
options: { required?: boolean; label?: string; integer?: boolean } = {},
options: { required?: boolean; label?: string; integer?: boolean; strict?: boolean } = {},
): number | undefined {
const { required = false, label = key, integer = false } = options;
const { required = false, label = key, integer = false, strict = false } = options;
const raw = readParamRaw(params, key);
let value: number | undefined;
if (typeof raw === "number" && Number.isFinite(raw)) {
@@ -139,7 +139,7 @@ export function readNumberParam(
} else if (typeof raw === "string") {
const trimmed = raw.trim();
if (trimmed) {
const parsed = Number.parseFloat(trimmed);
const parsed = strict ? Number(trimmed) : Number.parseFloat(trimmed);
if (Number.isFinite(parsed)) {
value = parsed;
}

View File

@@ -26,11 +26,14 @@ import {
} from "../../discord/send.js";
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js";
import { resolveDiscordChannelId } from "../../discord/targets.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
@@ -126,9 +129,7 @@ export async function handleDiscordMessagingAction(
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const limit = readNumberParam(params, "limit");
const reactions = await fetchReactionsDiscord(channelId, messageId, {
...cfgOptions,
...(accountId ? { accountId } : {}),
@@ -166,13 +167,9 @@ export async function handleDiscordMessagingAction(
required: true,
label: "answers",
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean" ? allowMultiselectRaw : undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1;
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
const durationHours = readNumberParam(params, "durationHours");
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
await sendPollDiscord(
to,
{ question, options: answers, maxSelections, durationHours },
@@ -226,10 +223,7 @@ export async function handleDiscordMessagingAction(
}
const channelId = resolveChannelId();
const query = {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined,
limit: readNumberParam(params, "limit"),
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
@@ -372,11 +366,7 @@ export async function handleDiscordMessagingAction(
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "content");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" && Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMinutes");
const appliedTags = readStringArrayParam(params, "appliedTags");
const payload = {
name,
@@ -398,13 +388,9 @@ export async function handleDiscordMessagingAction(
required: true,
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean" ? params.includeArchived : undefined;
const includeArchived = readBooleanParam(params, "includeArchived");
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const limit = readNumberParam(params, "limit");
const threads = accountId
? await listThreadsDiscord(
{
@@ -498,10 +484,7 @@ export async function handleDiscordMessagingAction(
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const limit = readNumberParam(params, "limit");
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
const results = accountId

View File

@@ -61,6 +61,7 @@ const {
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendVoiceMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
@@ -166,6 +167,31 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/);
});
it("parses string booleans for poll options", async () => {
await handleDiscordMessagingAction(
"poll",
{
to: "channel:123",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
allowMultiselect: "true",
durationHours: "24",
},
enableAllActions,
);
expect(sendPollDiscord).toHaveBeenCalledWith(
"channel:123",
{
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationHours: 24,
},
expect.any(Object),
);
});
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },

View File

@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -45,7 +45,8 @@ function createChannelPlugin(params: {
label: string;
docsPath: string;
blurb: string;
actions: string[];
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
supportsButtons?: boolean;
messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin {
@@ -65,7 +66,11 @@ function createChannelPlugin(params: {
},
...(params.messaging ? { messaging: params.messaging } : {}),
actions: {
listActions: () => params.actions as never,
listActions:
params.listActions ??
(() => {
return (params.actions ?? []) as never;
}),
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
},
};
@@ -139,7 +144,7 @@ describe("message tool schema scoping", () => {
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send", "react"],
actions: ["send", "react", "poll"],
supportsButtons: true,
});
@@ -161,6 +166,7 @@ describe("message tool schema scoping", () => {
expectComponents: false,
expectButtons: true,
expectButtonStyle: true,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"],
},
{
@@ -168,11 +174,19 @@ describe("message tool schema scoping", () => {
expectComponents: true,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "poll", "poll-vote", "react"],
},
])(
"scopes schema fields for $provider",
({ provider, expectComponents, expectButtons, expectButtonStyle, expectedActions }) => {
({
provider,
expectComponents,
expectButtons,
expectButtonStyle,
expectTelegramPollExtras,
expectedActions,
}) => {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
@@ -209,11 +223,75 @@ describe("message tool schema scoping", () => {
for (const action of expectedActions) {
expect(actionEnum).toContain(action);
}
if (expectTelegramPollExtras) {
expect(properties.pollDurationSeconds).toBeDefined();
expect(properties.pollAnonymous).toBeDefined();
expect(properties.pollPublic).toBeDefined();
} else {
expect(properties.pollDurationSeconds).toBeUndefined();
expect(properties.pollAnonymous).toBeUndefined();
expect(properties.pollPublic).toBeUndefined();
}
expect(properties.pollId).toBeDefined();
expect(properties.pollOptionIndex).toBeDefined();
expect(properties.pollOptionId).toBeDefined();
},
);
it("includes poll in the action enum when the current channel supports poll actions", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: "telegram",
});
const actionEnum = getActionEnum(getToolProperties(tool));
expect(actionEnum).toContain("poll");
});
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
const telegramPluginWithConfig = createChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
listActions: ({ cfg }) => {
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
.channels?.telegram;
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
},
supportsButtons: true,
});
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPluginWithConfig },
]),
);
const tool = createMessageTool({
config: {
channels: {
telegram: {
actions: {
poll: false,
},
},
},
} as never,
currentChannelProvider: "telegram",
});
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
expect(actionEnum).not.toContain("poll");
expect(properties.pollDurationSeconds).toBeUndefined();
expect(properties.pollAnonymous).toBeUndefined();
expect(properties.pollPublic).toBeUndefined();
});
});
describe("message tool description", () => {

View File

@@ -17,6 +17,7 @@ import { loadConfig } from "../../config/config.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { POLL_CREATION_PARAM_DEFS, POLL_CREATION_PARAM_NAMES } from "../../poll-params.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -271,12 +272,8 @@ function buildFetchSchema() {
};
}
function buildPollSchema() {
return {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
function buildPollSchema(options?: { includeTelegramExtras?: boolean }) {
const props: Record<string, unknown> = {
pollId: Type.Optional(Type.String()),
pollOptionId: Type.Optional(
Type.String({
@@ -306,6 +303,27 @@ function buildPollSchema() {
),
),
};
for (const name of POLL_CREATION_PARAM_NAMES) {
const def = POLL_CREATION_PARAM_DEFS[name];
if (def.telegramOnly && !options?.includeTelegramExtras) {
continue;
}
switch (def.kind) {
case "string":
props[name] = Type.Optional(Type.String());
break;
case "stringArray":
props[name] = Type.Optional(Type.Array(Type.String()));
break;
case "number":
props[name] = Type.Optional(Type.Number());
break;
case "boolean":
props[name] = Type.Optional(Type.Boolean());
break;
}
}
return props;
}
function buildChannelTargetSchema() {
@@ -425,13 +443,14 @@ function buildMessageToolSchemaProps(options: {
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeTelegramPollExtras: boolean;
}) {
return {
...buildRoutingSchema(),
...buildSendSchema(options),
...buildReactionSchema(),
...buildFetchSchema(),
...buildPollSchema(),
...buildPollSchema({ includeTelegramExtras: options.includeTelegramPollExtras }),
...buildChannelTargetSchema(),
...buildStickerSchema(),
...buildThreadSchema(),
@@ -445,7 +464,12 @@ function buildMessageToolSchemaProps(options: {
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: { includeButtons: boolean; includeCards: boolean; includeComponents: boolean },
options: {
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeTelegramPollExtras: boolean;
},
) {
const props = buildMessageToolSchemaProps(options);
return Type.Object({
@@ -458,6 +482,7 @@ const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeButtons: true,
includeCards: true,
includeComponents: true,
includeTelegramPollExtras: true,
});
type MessageToolOptions = {
@@ -519,6 +544,16 @@ function resolveIncludeComponents(params: {
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
}
function resolveIncludeTelegramPollExtras(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return listChannelSupportedActions({
cfg: params.cfg,
channel: "telegram",
}).includes("poll");
}
function buildMessageToolSchema(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
@@ -533,10 +568,12 @@ function buildMessageToolSchema(params: {
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
: supportsChannelMessageCards(params.cfg);
const includeComponents = resolveIncludeComponents(params);
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeButtons,
includeCards,
includeComponents,
includeTelegramPollExtras,
});
}

View File

@@ -8,6 +8,11 @@ const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
chatId: "123",
}));
const sendPollTelegram = vi.fn(async () => ({
messageId: "790",
chatId: "123",
pollId: "poll-1",
}));
const sendStickerTelegram = vi.fn(async () => ({
messageId: "456",
chatId: "123",
@@ -20,6 +25,7 @@ vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram(...args),
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
sendMessageTelegram(...args),
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
@@ -81,6 +87,7 @@ describe("handleTelegramAction", () => {
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
sendPollTelegram.mockClear();
sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
@@ -291,6 +298,70 @@ describe("handleTelegramAction", () => {
});
});
it("sends a poll", async () => {
const result = await handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
durationSeconds: 60,
isAnonymous: false,
silent: true,
},
telegramConfig(),
);
expect(sendPollTelegram).toHaveBeenCalledWith(
"@testchannel",
{
question: "Ready?",
options: ["Yes", "No"],
maxSelections: 2,
durationSeconds: 60,
durationHours: undefined,
},
expect.objectContaining({
token: "tok",
isAnonymous: false,
silent: true,
}),
);
expect(result.details).toMatchObject({
ok: true,
messageId: "790",
chatId: "123",
pollId: "poll-1",
});
});
it("parses string booleans for poll flags", async () => {
await handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: "true",
isAnonymous: "false",
silent: "true",
},
telegramConfig(),
);
expect(sendPollTelegram).toHaveBeenCalledWith(
"@testchannel",
expect.objectContaining({
question: "Ready?",
options: ["Yes", "No"],
maxSelections: 2,
}),
expect.objectContaining({
isAnonymous: false,
silent: true,
}),
);
});
it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
await handleTelegramAction(
{
@@ -390,6 +461,25 @@ describe("handleTelegramAction", () => {
).rejects.toThrow(/Telegram sendMessage is disabled/);
});
it("respects poll gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { poll: false } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
},
cfg,
),
).rejects.toThrow(/Telegram polls are disabled/);
});
it("deletes a message", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },

View File

@@ -1,6 +1,11 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { createTelegramActionGate } from "../../telegram/accounts.js";
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
import { resolvePollMaxSelections } from "../../polls.js";
import {
createTelegramActionGate,
resolveTelegramPollActionGateState,
} from "../../telegram/accounts.js";
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js";
import {
resolveTelegramInlineButtonsScope,
@@ -13,6 +18,7 @@ import {
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendPollTelegram,
sendStickerTelegram,
} from "../../telegram/send.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
@@ -21,6 +27,7 @@ import {
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringOrNumberParam,
readStringParam,
} from "./common.js";
@@ -238,8 +245,8 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
quoteText: quoteText ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
silent: typeof params.silent === "boolean" ? params.silent : undefined,
asVoice: readBooleanParam(params, "asVoice"),
silent: readBooleanParam(params, "silent"),
});
return jsonResult({
ok: true,
@@ -248,6 +255,60 @@ export async function handleTelegramAction(
});
}
if (action === "poll") {
const pollActionState = resolveTelegramPollActionGateState(isActionEnabled);
if (!pollActionState.sendMessageEnabled) {
throw new Error("Telegram sendMessage is disabled.");
}
if (!pollActionState.pollEnabled) {
throw new Error("Telegram polls are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "question", { required: true });
const answers = readStringArrayParam(params, "answers", { required: true });
const allowMultiselect = readBooleanParam(params, "allowMultiselect") ?? false;
const durationSeconds = readNumberParam(params, "durationSeconds", { integer: true });
const durationHours = readNumberParam(params, "durationHours", { integer: true });
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
integer: true,
});
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const isAnonymous = readBooleanParam(params, "isAnonymous");
const silent = readBooleanParam(params, "silent");
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendPollTelegram(
to,
{
question,
options: answers,
maxSelections: resolvePollMaxSelections(answers.length, allowMultiselect),
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
},
{
token,
accountId: accountId ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
isAnonymous: isAnonymous ?? undefined,
silent: silent ?? undefined,
},
);
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
pollId: result.pollId,
});
}
if (action === "deleteMessage") {
if (!isActionEnabled("deleteMessage")) {
throw new Error("Telegram deleteMessage is disabled.");

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;

View File

@@ -166,6 +166,24 @@ const createTelegramSendPluginRegistration = () => ({
}),
});
const createTelegramPollPluginRegistration = () => ({
pluginId: "telegram",
source: "test",
plugin: createStubPlugin({
id: "telegram",
label: "Telegram",
actions: {
listActions: () => ["poll"],
handleAction: (async ({ action, params, cfg, accountId }: ChannelActionParams) => {
return await handleTelegramAction(
{ action, to: params.to, accountId: accountId ?? undefined },
cfg,
);
}) as unknown as NonNullable<ChannelPlugin["actions"]>["handleAction"],
},
}),
});
const { messageCommand } = await import("./message.js");
describe("messageCommand", () => {
@@ -468,4 +486,34 @@ describe("messageCommand", () => {
expect.any(Object),
);
});
it("routes telegram polls through message action", async () => {
await setRegistry(
createTestRegistry([
{
...createTelegramPollPluginRegistration(),
},
]),
);
const deps = makeDeps();
await messageCommand(
{
action: "poll",
channel: "telegram",
target: "123456789",
pollQuestion: "Ship it?",
pollOption: ["Yes", "No"],
pollDurationSeconds: 120,
},
deps,
runtime,
);
expect(handleTelegramAction).toHaveBeenCalledWith(
expect.objectContaining({
action: "poll",
to: "123456789",
}),
expect.any(Object),
);
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("telegram poll action config", () => {
it("accepts channels.telegram.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
actions: {
poll: false,
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts channels.telegram.accounts.<id>.actions.poll", () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
actions: {
poll: false,
},
},
},
},
},
});
expect(res.ok).toBe(true);
});
});

View File

@@ -14,6 +14,8 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ
export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
/** Enable poll creation. Requires sendMessage to also be enabled. */
poll?: boolean;
deleteMessage?: boolean;
editMessage?: boolean;
/** Enable sticker actions (send and search). */

View File

@@ -225,6 +225,7 @@ export const TelegramAccountSchemaBase = z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
poll: z.boolean().optional(),
deleteMessage: z.boolean().optional(),
sticker: z.boolean().optional(),
})

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") {

60
src/poll-params.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { hasPollCreationParams, resolveTelegramPollVisibility } from "./poll-params.js";
describe("poll params", () => {
it("does not treat explicit false booleans as poll creation params", () => {
expect(
hasPollCreationParams({
pollMulti: false,
pollAnonymous: false,
pollPublic: false,
}),
).toBe(false);
});
it.each([{ key: "pollMulti" }, { key: "pollAnonymous" }, { key: "pollPublic" }])(
"treats $key=true as poll creation intent",
({ key }) => {
expect(
hasPollCreationParams({
[key]: true,
}),
).toBe(true);
},
);
it("treats finite numeric poll params as poll creation intent", () => {
expect(hasPollCreationParams({ pollDurationHours: 0 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: 60 })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "60" })).toBe(true);
expect(hasPollCreationParams({ pollDurationSeconds: "1e3" })).toBe(true);
expect(hasPollCreationParams({ pollDurationHours: Number.NaN })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: Infinity })).toBe(false);
expect(hasPollCreationParams({ pollDurationSeconds: "60abc" })).toBe(false);
});
it("treats string-encoded boolean poll params as poll creation intent when true", () => {
expect(hasPollCreationParams({ pollPublic: "true" })).toBe(true);
expect(hasPollCreationParams({ pollAnonymous: "false" })).toBe(false);
});
it("treats string poll options as poll creation intent", () => {
expect(hasPollCreationParams({ pollOption: "Yes" })).toBe(true);
});
it("detects snake_case poll fields as poll creation intent", () => {
expect(hasPollCreationParams({ poll_question: "Lunch?" })).toBe(true);
expect(hasPollCreationParams({ poll_option: ["Pizza", "Sushi"] })).toBe(true);
expect(hasPollCreationParams({ poll_duration_seconds: "60" })).toBe(true);
expect(hasPollCreationParams({ poll_public: "true" })).toBe(true);
});
it("resolves telegram poll visibility flags", () => {
expect(resolveTelegramPollVisibility({ pollAnonymous: true })).toBe(true);
expect(resolveTelegramPollVisibility({ pollPublic: true })).toBe(false);
expect(resolveTelegramPollVisibility({})).toBeUndefined();
expect(() => resolveTelegramPollVisibility({ pollAnonymous: true, pollPublic: true })).toThrow(
/mutually exclusive/i,
);
});
});

89
src/poll-params.ts Normal file
View File

@@ -0,0 +1,89 @@
export type PollCreationParamKind = "string" | "stringArray" | "number" | "boolean";
export type PollCreationParamDef = {
kind: PollCreationParamKind;
telegramOnly?: boolean;
};
export const POLL_CREATION_PARAM_DEFS: Record<string, PollCreationParamDef> = {
pollQuestion: { kind: "string" },
pollOption: { kind: "stringArray" },
pollDurationHours: { kind: "number" },
pollMulti: { kind: "boolean" },
pollDurationSeconds: { kind: "number", telegramOnly: true },
pollAnonymous: { kind: "boolean", telegramOnly: true },
pollPublic: { kind: "boolean", telegramOnly: true },
};
export type PollCreationParamName = keyof typeof POLL_CREATION_PARAM_DEFS;
export const POLL_CREATION_PARAM_NAMES = Object.keys(POLL_CREATION_PARAM_DEFS);
function toSnakeCaseKey(key: string): string {
return key
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase();
}
function readPollParamRaw(params: Record<string, unknown>, key: string): unknown {
if (Object.hasOwn(params, key)) {
return params[key];
}
const snakeKey = toSnakeCaseKey(key);
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
return params[snakeKey];
}
return undefined;
}
export function resolveTelegramPollVisibility(params: {
pollAnonymous?: boolean;
pollPublic?: boolean;
}): boolean | undefined {
if (params.pollAnonymous && params.pollPublic) {
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
}
return params.pollAnonymous ? true : params.pollPublic ? false : undefined;
}
export function hasPollCreationParams(params: Record<string, unknown>): boolean {
for (const key of POLL_CREATION_PARAM_NAMES) {
const def = POLL_CREATION_PARAM_DEFS[key];
const value = readPollParamRaw(params, key);
if (def.kind === "string" && typeof value === "string" && value.trim().length > 0) {
return true;
}
if (def.kind === "stringArray") {
if (
Array.isArray(value) &&
value.some((entry) => typeof entry === "string" && entry.trim())
) {
return true;
}
if (typeof value === "string" && value.trim().length > 0) {
return true;
}
}
if (def.kind === "number") {
if (typeof value === "number" && Number.isFinite(value)) {
return true;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0 && Number.isFinite(Number(trimmed))) {
return true;
}
}
}
if (def.kind === "boolean") {
if (value === true) {
return true;
}
if (typeof value === "string" && value.trim().toLowerCase() === "true") {
return true;
}
}
}
return false;
}

View File

@@ -26,6 +26,13 @@ type NormalizePollOptions = {
maxOptions?: number;
};
export function resolvePollMaxSelections(
optionCount: number,
allowMultiselect: boolean | undefined,
): number {
return allowMultiselect ? Math.max(2, optionCount) : 1;
}
export function normalizePollInput(
input: PollInput,
options: NormalizePollOptions = {},

View File

@@ -4,6 +4,7 @@ import { withEnv } from "../test-utils/env.js";
import {
listTelegramAccountIds,
resetMissingDefaultWarnFlag,
resolveTelegramPollActionGateState,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "./accounts.js";
@@ -308,6 +309,26 @@ describe("resolveTelegramAccount allowFrom precedence", () => {
});
});
describe("resolveTelegramPollActionGateState", () => {
it("requires both sendMessage and poll actions", () => {
const state = resolveTelegramPollActionGateState((key) => key !== "poll");
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: false,
enabled: false,
});
});
it("returns enabled only when both actions are enabled", () => {
const state = resolveTelegramPollActionGateState(() => true);
expect(state).toEqual({
sendMessageEnabled: true,
pollEnabled: true,
enabled: true,
});
});
});
describe("resolveTelegramAccount groups inheritance (#30673)", () => {
const createMultiAccountGroupsConfig = (): OpenClawConfig => ({
channels: {

View File

@@ -142,6 +142,24 @@ export function createTelegramActionGate(params: {
});
}
export type TelegramPollActionGateState = {
sendMessageEnabled: boolean;
pollEnabled: boolean;
enabled: boolean;
};
export function resolveTelegramPollActionGateState(
isActionEnabled: (key: keyof TelegramActionConfig, defaultValue?: boolean) => boolean,
): TelegramPollActionGateState {
const sendMessageEnabled = isActionEnabled("sendMessage");
const pollEnabled = isActionEnabled("poll");
return {
sendMessageEnabled,
pollEnabled,
enabled: sendMessageEnabled && pollEnabled,
};
}
export function resolveTelegramAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;