From 47f3883419792f20743d9f76f55ef8bf115426dc Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 4 Mar 2026 12:36:12 -0500 Subject: [PATCH] fix: restore telegram poll actions --- docs/automation/poll.md | 19 ++- docs/channels/telegram.md | 23 ++++ src/agents/channel-tools.test.ts | 37 +++++- src/agents/tools/discord-actions-messaging.ts | 3 +- src/agents/tools/message-tool.test.ts | 36 ++++- src/agents/tools/message-tool.ts | 54 ++++++-- src/agents/tools/telegram-actions.test.ts | 63 +++++++++ src/agents/tools/telegram-actions.ts | 57 ++++++++ src/channels/plugins/actions/actions.test.ts | 65 +++++++++ src/channels/plugins/actions/telegram.ts | 42 ++++++ src/channels/plugins/types.core.ts | 6 + src/commands/message.test.ts | 48 +++++++ src/config/types.telegram.ts | 2 + .../outbound/message-action-runner.test.ts | 124 ++++++++++++++++++ src/infra/outbound/message-action-runner.ts | 14 +- src/poll-params.ts | 51 +++++++ src/polls.ts | 7 + 17 files changed, 633 insertions(+), 18 deletions(-) create mode 100644 src/poll-params.ts 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/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 2846e0879f8..272521cd761 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -26,6 +26,7 @@ import { } from "../../discord/send.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js"; import { resolveDiscordChannelId } from "../../discord/targets.js"; +import { resolvePollMaxSelections } from "../../polls.js"; import { withNormalizedTimestamp } from "../date-time.js"; import { assertMediaNotDataUrl } from "../sandbox-paths.js"; import { @@ -172,7 +173,7 @@ export async function handleDiscordMessagingAction( const durationRaw = params.durationHours; const durationHours = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; - const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; + const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect); await sendPollDiscord( to, { question, options: answers, maxSelections, durationHours }, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 84e25fd30d2..1902d13bd00 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -139,7 +139,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 +161,7 @@ describe("message tool schema scoping", () => { expectComponents: false, expectButtons: true, expectButtonStyle: true, + expectTelegramPollExtras: true, expectedActions: ["send", "react", "poll", "poll-vote"], }, { @@ -168,11 +169,19 @@ describe("message tool schema scoping", () => { expectComponents: true, expectButtons: false, expectButtonStyle: false, + expectTelegramPollExtras: false, 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 +218,34 @@ 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"); + }); }); describe("message tool description", () => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 27f72868cdf..6a531e49457 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,17 @@ function resolveIncludeComponents(params: { return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0; } +function resolveIncludeTelegramPollExtras(params: { + cfg: OpenClawConfig; + currentChannelProvider?: string; +}): boolean { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + return currentChannel === "telegram"; + } + return listChannelSupportedActions({ cfg: params.cfg, channel: "telegram" }).includes("poll"); +} + function buildMessageToolSchema(params: { cfg: OpenClawConfig; currentChannelProvider?: string; @@ -533,10 +569,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..556b29a8731 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,43 @@ 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("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => { await handleTelegramAction( { @@ -390,6 +434,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..0ad9421ef67 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolvePollMaxSelections } from "../../polls.js"; import { createTelegramActionGate } from "../../telegram/accounts.js"; import type { TelegramButtonStyle, TelegramInlineButtons } from "../../telegram/button-types.js"; import { @@ -13,6 +14,7 @@ import { editMessageTelegram, reactMessageTelegram, sendMessageTelegram, + sendPollTelegram, sendStickerTelegram, } from "../../telegram/send.js"; import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js"; @@ -21,6 +23,7 @@ import { jsonResult, readNumberParam, readReactionParams, + readStringArrayParam, readStringOrNumberParam, readStringParam, } from "./common.js"; @@ -248,6 +251,60 @@ export async function handleTelegramAction( }); } + if (action === "poll") { + if (!isActionEnabled("sendMessage")) { + throw new Error("Telegram sendMessage is disabled."); + } + if (!isActionEnabled("poll")) { + 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 = + typeof params.allowMultiselect === "boolean" ? 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 = typeof params.isAnonymous === "boolean" ? params.isAnonymous : undefined; + const silent = typeof params.silent === "boolean" ? params.silent : undefined; + 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..db745fa0d34 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -496,6 +496,42 @@ 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("lists sticker actions only when enabled by config", () => { const cases = [ { @@ -595,6 +631,35 @@ 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: "topic-create maps to createForumTopic", action: "topic-create" as const, diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 4f0f1a85c2d..53a222755a7 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -7,6 +7,7 @@ import { import { handleTelegramAction } from "../../../agents/tools/telegram-actions.js"; import type { TelegramActionConfig } from "../../../config/types.telegram.js"; import { extractToolSend } from "../../../plugin-sdk/tool-send.js"; +import { resolveTelegramPollVisibility } from "../../../poll-params.js"; import { createTelegramActionGate, listEnabledTelegramAccounts, @@ -78,6 +79,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const isEnabled = (key: keyof TelegramActionConfig, defaultValue = true) => gate(key, defaultValue); const actions = new Set(["send"]); + if (isEnabled("sendMessage") && isEnabled("poll")) { + actions.add("poll"); + } if (isEnabled("reactions")) { actions.add("react"); } @@ -140,6 +144,44 @@ 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, + }); + const durationSeconds = readNumberParam(params, "pollDurationSeconds", { + integer: true, + }); + const replyToMessageId = readNumberParam(params, "replyTo", { integer: true }); + const messageThreadId = readNumberParam(params, "threadId", { integer: true }); + const allowMultiselect = typeof params.pollMulti === "boolean" ? params.pollMulti : undefined; + const pollAnonymous = + typeof params.pollAnonymous === "boolean" ? params.pollAnonymous : undefined; + const pollPublic = typeof params.pollPublic === "boolean" ? params.pollPublic : undefined; + const isAnonymous = resolveTelegramPollVisibility({ pollAnonymous, pollPublic }); + const silent = typeof params.silent === "boolean" ? params.silent : undefined; + 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/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/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index d2db2a60b2d..0746a34ecb9 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -236,6 +236,22 @@ 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("blocks send when target differs from current channel", async () => { const result = await runDrySend({ cfg: slackConfig, @@ -902,6 +918,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..d3dbc22968b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,6 +14,9 @@ import type { } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { hasPollCreationParams } from "../../poll-params.js"; +import { 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"; @@ -579,17 +582,14 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise = { + 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); + +export const TELEGRAM_POLL_CREATION_PARAM_NAMES = POLL_CREATION_PARAM_NAMES.filter( + (name) => POLL_CREATION_PARAM_DEFS[name].telegramOnly === true, +); + +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 = params[key]; + if (def.kind === "string" && typeof value === "string" && value.trim().length > 0) { + return true; + } + if (def.kind === "stringArray" && Array.isArray(value) && value.length > 0) { + return true; + } + if ((def.kind === "number" || def.kind === "boolean") && value !== undefined) { + 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 = {},