diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5bfcdc420..786eccfabb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/automation/poll.md b/docs/automation/poll.md index fab0b0e0738..acf03aa2903 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -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`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index d3fdeff31ea..58fbe8b9023 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -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 + @@ -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+`). diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts index c9e125ab3ca..26552f81f9f 100644 --- a/src/agents/channel-tools.test.ts +++ b/src/agents/channel-tools.test.ts @@ -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([]); + }); }); diff --git a/src/agents/tools/common.params.test.ts b/src/agents/tools/common.params.test.ts index d93038cd606..32eb63d036e 100644 --- a/src/agents/tools/common.params.test.ts +++ b/src/agents/tools/common.params.test.ts @@ -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); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index d4b3bc9fc3b..19cca2d7927 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -129,9 +129,9 @@ export function readStringOrNumberParam( export function readNumberParam( params: Record, 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; } diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 2846e0879f8..7349e65a3e6 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -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 diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts index cbadb77f564..95f6c7ec4f2 100644 --- a/src/agents/tools/discord-actions.test.ts +++ b/src/agents/tools/discord-actions.test.ts @@ -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" }, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 84e25fd30d2..930f8d95a25 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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["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", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 27f72868cdf..96b2702f065 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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 = { 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, }); } diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 6b4f2314a6b..eeeb7bbf35b 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -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) => sendMessageTelegram(...args), + sendPollTelegram: (...args: Parameters) => sendPollTelegram(...args), sendStickerTelegram: (...args: Parameters) => sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => @@ -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" } }, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 4a9de90725d..30c07530159 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -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."); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index eda720dfc93..a6e1e89fc2e 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -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, diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 6f0a701b6b2..5b11246210a 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -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", diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 4f0f1a85c2d..6e55349698b 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -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) { 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(["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); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1ef0db815e3..379c6b8c89e 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index f5a23298b1a..658eb9fd614 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -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["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), + ); + }); }); diff --git a/src/config/telegram-actions-poll.test.ts b/src/config/telegram-actions-poll.test.ts new file mode 100644 index 00000000000..0193cab9a69 --- /dev/null +++ b/src/config/telegram-actions-poll.test.ts @@ -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..actions.poll", () => { + const res = validateConfigObject({ + channels: { + telegram: { + accounts: { + ops: { + actions: { + poll: false, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index a6afe675f83..3867544784e 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 55a98c5f827..55fdc2b06a9 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), }) diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index d2db2a60b2d..cc7d68df9d3 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -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 }) => + 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 }) => jsonResult({ diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index d8ec9419018..c703cd34d24 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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 { + 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, + ); + }); +}); diff --git a/src/poll-params.ts b/src/poll-params.ts new file mode 100644 index 00000000000..88dc6336d32 --- /dev/null +++ b/src/poll-params.ts @@ -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 = { + 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, 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): 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; +} diff --git a/src/polls.ts b/src/polls.ts index 7fe3f800e28..c10afd22b64 100644 --- a/src/polls.ts +++ b/src/polls.ts @@ -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 = {}, diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index 1c0807aaa1a..b77f01e0d67 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -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: { diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 81de42cd1f1..e3d86ec84b4 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -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;