From 8e5689a84dc1240e374789a9b87679c118ecdde9 Mon Sep 17 00:00:00 2001 From: Robby Date: Sat, 14 Feb 2026 18:34:30 +0100 Subject: [PATCH] feat(telegram): add sendPoll support (#16193) (#16209) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: b58492cfed34eebe4b32af5292928092a11ecfed Co-authored-by: robbyczgw-cla <239660374+robbyczgw-cla@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + docs/cli/message.md | 15 +- extensions/discord/src/channel.ts | 9 +- extensions/telegram/src/channel.ts | 15 +- src/channels/plugins/outbound/discord.ts | 3 +- src/channels/plugins/types.core.ts | 2 + src/cli/program/message/register.poll.ts | 11 +- src/discord/send.outbound.ts | 3 + src/gateway/protocol/schema/agent.ts | 8 ++ src/gateway/server-methods/send.ts | 12 ++ src/infra/outbound/message-action-runner.ts | 29 ++++ src/infra/outbound/message.ts | 14 ++ src/infra/outbound/outbound-send-service.ts | 8 ++ src/plugins/runtime/index.ts | 3 +- src/plugins/runtime/types.ts | 2 + src/polls.test.ts | 1 + src/polls.ts | 21 +++ src/telegram/index.ts | 2 +- src/telegram/send.poll.test.ts | 63 ++++++++ src/telegram/send.ts | 152 ++++++++++++++++++++ src/web/outbound.test.ts | 1 + 21 files changed, 364 insertions(+), 11 deletions(-) create mode 100644 src/telegram/send.poll.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aba22d18d22..9507d21ec25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak. - Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr. +- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla. ### Fixes diff --git a/docs/cli/message.md b/docs/cli/message.md index 5e5779dd641..a9ac8c7948b 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -64,10 +64,11 @@ Name lookup: - WhatsApp only: `--gif-playback` - `poll` - - Channels: WhatsApp/Discord/MS Teams + - Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams - Required: `--target`, `--poll-question`, `--poll-option` (repeat) - Optional: `--poll-multi` - - Discord only: `--poll-duration-hours`, `--message` + - Discord only: `--poll-duration-hours`, `--silent`, `--message` + - Telegram only: `--poll-duration-seconds` (5-600), `--silent`, `--poll-anonymous` / `--poll-public`, `--thread-id` - `react` - Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal @@ -200,6 +201,16 @@ openclaw message poll --channel discord \ --poll-multi --poll-duration-hours 48 ``` +Create a Telegram poll (auto-close in 2 minutes): + +``` +openclaw message poll --channel telegram \ + --target @mychat \ + --poll-question "Lunch?" \ + --poll-option Pizza --poll-option Sushi \ + --poll-duration-seconds 120 --silent +``` + Send a Teams proactive message: ``` diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 5d9e101f579..4119a95e815 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -285,28 +285,31 @@ export const discordPlugin: ChannelPlugin = { chunker: null, textChunkLimit: 2000, pollMaxOptions: 10, - sendText: async ({ to, text, accountId, deps, replyToId }) => { + sendText: async ({ to, text, accountId, deps, replyToId, silent }) => { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, + silent: silent ?? undefined, }); return { channel: "discord", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => { const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, mediaUrl, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, + silent: silent ?? undefined, }); return { channel: "discord", ...result }; }, - sendPoll: async ({ to, poll, accountId }) => + sendPoll: async ({ to, poll, accountId, silent }) => await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { accountId: accountId ?? undefined, + silent: silent ?? undefined, }), }, status: { diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c58cdd7e955..5e85db11134 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -96,6 +96,7 @@ export const telegramPlugin: ChannelPlugin getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { + pollMaxOptions: 10, + sendText: async ({ to, text, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); @@ -282,10 +284,11 @@ export const telegramPlugin: ChannelPlugin { + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, silent }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); @@ -295,9 +298,17 @@ export const telegramPlugin: ChannelPlugin + await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + accountId: accountId ?? undefined, + messageThreadId: parseThreadId(threadId), + silent: silent ?? undefined, + isAnonymous: isAnonymous ?? undefined, + }), }, status: { defaultRuntime: { diff --git a/src/channels/plugins/outbound/discord.ts b/src/channels/plugins/outbound/discord.ts index fbb1415326d..f6ee9d81c37 100644 --- a/src/channels/plugins/outbound/discord.ts +++ b/src/channels/plugins/outbound/discord.ts @@ -27,8 +27,9 @@ export const discordOutbound: ChannelOutboundAdapter = { }); return { channel: "discord", ...result }; }, - sendPoll: async ({ to, poll, accountId }) => + sendPoll: async ({ to, poll, accountId, silent }) => await sendPollDiscord(to, poll, { accountId: accountId ?? undefined, + silent: silent ?? undefined, }), }; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index abf30940ae0..a2195597e0c 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -344,4 +344,6 @@ export type ChannelPollContext = { poll: PollInput; accountId?: string | null; threadId?: string | null; + silent?: boolean; + isAnonymous?: boolean; }; diff --git a/src/cli/program/message/register.poll.ts b/src/cli/program/message/register.poll.ts index 522055823c1..e45fa3b86c9 100644 --- a/src/cli/program/message/register.poll.ts +++ b/src/cli/program/message/register.poll.ts @@ -15,8 +15,17 @@ export function registerMessagePollCommand(message: Command, helpers: MessageCli [] as string[], ) .option("--poll-multi", "Allow multiple selections", false) - .option("--poll-duration-hours ", "Poll duration (Discord)") + .option("--poll-duration-hours ", "Poll duration in hours (Discord)") + .option("--poll-duration-seconds ", "Poll duration in seconds (Telegram; 5-600)") + .option("--poll-anonymous", "Send an anonymous poll (Telegram)", false) + .option("--poll-public", "Send a non-anonymous poll (Telegram)", false) .option("-m, --message ", "Optional message body") + .option( + "--silent", + "Send poll silently without notification (Telegram + Discord where supported)", + false, + ) + .option("--thread-id ", "Thread id (Telegram forum topic / Slack thread ts)") .action(async (opts) => { await helpers.runMessageAction("poll", opts); }); diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 83dd23d0def..782abad147c 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -274,12 +274,15 @@ export async function sendPollDiscord( const { channelId } = await resolveChannelId(rest, recipient, request); const content = opts.content?.trim(); const payload = normalizeDiscordPollInput(poll); + // Discord message flag for silent/suppress notifications (matches send.shared.ts) + const flags = opts.silent ? 1 << 12 : undefined; const res = (await request( () => rest.post(Routes.channelMessages(channelId), { body: { content: content || undefined, poll: payload, + ...(flags ? { flags } : {}), }, }) as Promise<{ id: string; channel_id: string }>, "poll", diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index fbb34bee33c..f9f24f836d0 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -35,7 +35,15 @@ export const PollParamsSchema = Type.Object( question: NonEmptyString, options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }), maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), + /** Poll duration in seconds (channel-specific limits may apply). */ + durationSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 600 })), durationHours: Type.Optional(Type.Integer({ minimum: 1 })), + /** Send silently (no notification) where supported. */ + silent: Type.Optional(Type.Boolean()), + /** Poll anonymity where supported (e.g. Telegram polls default to anonymous). */ + isAnonymous: Type.Optional(Type.Boolean()), + /** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */ + threadId: Type.Optional(Type.String()), channel: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), idempotencyKey: NonEmptyString, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index c7d42f7ce30..674be40c04d 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -274,7 +274,11 @@ export const sendHandlers: GatewayRequestHandlers = { question: string; options: string[]; maxSelections?: number; + durationSeconds?: number; durationHours?: number; + silent?: boolean; + isAnonymous?: boolean; + threadId?: string; channel?: string; accountId?: string; idempotencyKey: string; @@ -303,8 +307,13 @@ export const sendHandlers: GatewayRequestHandlers = { question: request.question, options: request.options, maxSelections: request.maxSelections, + durationSeconds: request.durationSeconds, durationHours: request.durationHours, }; + const threadId = + typeof request.threadId === "string" && request.threadId.trim().length + ? request.threadId.trim() + : undefined; const accountId = typeof request.accountId === "string" && request.accountId.trim().length ? request.accountId.trim() @@ -340,6 +349,9 @@ export const sendHandlers: GatewayRequestHandlers = { to: resolved.to, poll: normalized, accountId, + threadId, + silent: request.silent, + isAnonymous: request.isAnonymous, }); const payload: Record = { runId: idem, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 17f24636353..dfa000bf67b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -568,11 +568,36 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2, + durationSeconds: undefined, durationHours: undefined, }); }); diff --git a/src/polls.ts b/src/polls.ts index 1fa8e22cebc..c61d499eaa6 100644 --- a/src/polls.ts +++ b/src/polls.ts @@ -2,6 +2,15 @@ export type PollInput = { question: string; options: string[]; maxSelections?: number; + /** + * Poll duration in seconds. + * Channel-specific limits apply (e.g. Telegram open_period is 5-600s). + */ + durationSeconds?: number; + /** + * Poll duration in hours. + * Used by channels that model duration in hours (e.g. Discord). + */ durationHours?: number; }; @@ -9,6 +18,7 @@ export type NormalizedPollInput = { question: string; options: string[]; maxSelections: number; + durationSeconds?: number; durationHours?: number; }; @@ -43,6 +53,16 @@ export function normalizePollInput( if (maxSelections > cleaned.length) { throw new Error("maxSelections cannot exceed option count"); } + + const durationSecondsRaw = input.durationSeconds; + const durationSeconds = + typeof durationSecondsRaw === "number" && Number.isFinite(durationSecondsRaw) + ? Math.floor(durationSecondsRaw) + : undefined; + if (durationSeconds !== undefined && durationSeconds < 1) { + throw new Error("durationSeconds must be at least 1"); + } + const durationRaw = input.durationHours; const durationHours = typeof durationRaw === "number" && Number.isFinite(durationRaw) @@ -55,6 +75,7 @@ export function normalizePollInput( question, options: cleaned, maxSelections, + durationSeconds, durationHours, }; } diff --git a/src/telegram/index.ts b/src/telegram/index.ts index a74d218212c..5ffb8dacaf6 100644 --- a/src/telegram/index.ts +++ b/src/telegram/index.ts @@ -1,4 +1,4 @@ export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; export { monitorTelegramProvider } from "./monitor.js"; -export { reactMessageTelegram, sendMessageTelegram } from "./send.js"; +export { reactMessageTelegram, sendMessageTelegram, sendPollTelegram } from "./send.js"; export { startTelegramWebhook } from "./webhook.js"; diff --git a/src/telegram/send.poll.test.ts b/src/telegram/send.poll.test.ts new file mode 100644 index 00000000000..31bc1dc909a --- /dev/null +++ b/src/telegram/send.poll.test.ts @@ -0,0 +1,63 @@ +import type { Bot } from "grammy"; +import { describe, expect, it, vi } from "vitest"; +import { sendPollTelegram } from "./send.js"; + +describe("sendPollTelegram", () => { + it("maps durationSeconds to open_period", async () => { + const api = { + sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })), + }; + + const res = await sendPollTelegram( + "123", + { question: " Q ", options: [" A ", "B "], durationSeconds: 60 }, + { token: "t", api: api as unknown as Bot["api"] }, + ); + + expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" }); + expect(api.sendPoll).toHaveBeenCalledTimes(1); + expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123"); + expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q"); + expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]); + expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ open_period: 60 }); + }); + + it("retries without message_thread_id on thread-not-found", async () => { + const api = { + sendPoll: vi.fn( + async (_chatId: string, _question: string, _options: string[], params: unknown) => { + const p = params as { message_thread_id?: unknown } | undefined; + if (p?.message_thread_id) { + throw new Error("400: Bad Request: message thread not found"); + } + return { message_id: 1, chat: { id: 2 }, poll: { id: "p2" } }; + }, + ), + }; + + const res = await sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"] }, + { token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 }, + ); + + expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" }); + expect(api.sendPoll).toHaveBeenCalledTimes(2); + expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ message_thread_id: 99 }); + expect(api.sendPoll.mock.calls[1]?.[3]?.message_thread_id).toBeUndefined(); + }); + + it("rejects durationHours for Telegram polls", async () => { + const api = { sendPoll: vi.fn() }; + + await expect( + sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"], durationHours: 1 }, + { token: "t", api: api as unknown as Bot["api"] }, + ), + ).rejects.toThrow(/durationHours is not supported/i); + + expect(api.sendPoll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index ead53ff90d1..c12b2e31b69 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { mediaKindFromMime } from "../media/constants.js"; import { isGifMedia } from "../media/mime.js"; +import { normalizePollInput, type PollInput } from "../polls.js"; import { loadWebMedia } from "../web/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -923,3 +924,154 @@ export async function sendStickerTelegram( return { messageId, chatId: resolvedChatId }; } + +type TelegramPollOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + /** Message ID to reply to (for threading) */ + replyToMessageId?: number; + /** Forum topic thread ID (for forum supergroups) */ + messageThreadId?: number; + /** Send message silently (no notification). Defaults to false. */ + silent?: boolean; + /** Whether votes are anonymous. Defaults to true (Telegram default). */ + isAnonymous?: boolean; +}; + +/** + * Send a poll to a Telegram chat. + * @param to - Chat ID or username (e.g., "123456789" or "@username") + * @param poll - Poll input with question, options, maxSelections, and optional durationHours + * @param opts - Optional configuration + */ +export async function sendPollTelegram( + to: string, + poll: PollInput, + opts: TelegramPollOpts = {}, +): Promise<{ messageId: string; chatId: string; pollId?: string }> { + const cfg = loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const target = parseTelegramTarget(to); + const chatId = normalizeChatId(target.chatId); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + + // Normalize the poll input (validates question, options, maxSelections) + const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); + + const messageThreadId = + opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId; + const threadSpec = + messageThreadId != null ? { id: messageThreadId, scope: "forum" as const } : undefined; + const threadIdParams = buildTelegramThreadParams(threadSpec); + + // Build poll options as simple strings (Grammy accepts string[] or InputPollOption[]) + const pollOptions = normalizedPoll.options; + + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + withTelegramApiErrorLogging({ + operation: label ?? "request", + fn: () => request(fn, label), + }).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + const wrapChatNotFound = (err: unknown) => { + if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err))) { + return err; + } + return new Error( + [ + `Telegram send failed: chat not found (chat_id=${chatId}).`, + "Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.", + `Input was: ${JSON.stringify(to)}.`, + ].join(" "), + ); + }; + + const sendWithThreadFallback = async ( + params: Record | undefined, + label: string, + attempt: ( + effectiveParams: Record | undefined, + effectiveLabel: string, + ) => Promise, + ): Promise => { + try { + return await attempt(params, label); + } catch (err) { + if (!hasMessageThreadIdParam(params) || !isTelegramThreadNotFoundError(err)) { + throw err; + } + if (opts.verbose) { + console.warn( + `telegram ${label} failed with message_thread_id, retrying without thread: ${formatErrorMessage(err)}`, + ); + } + const retriedParams = removeMessageThreadIdParam(params); + return await attempt(retriedParams, `${label}-threadless`); + } + }; + + const durationSeconds = normalizedPoll.durationSeconds; + if (durationSeconds === undefined && normalizedPoll.durationHours !== undefined) { + throw new Error( + "Telegram poll durationHours is not supported. Use durationSeconds (5-600) instead.", + ); + } + if (durationSeconds !== undefined && (durationSeconds < 5 || durationSeconds > 600)) { + throw new Error("Telegram poll durationSeconds must be between 5 and 600"); + } + + // Build poll parameters following Grammy's api.sendPoll signature + // sendPoll(chat_id, question, options, other?, signal?) + const pollParams = { + allows_multiple_answers: normalizedPoll.maxSelections > 1, + is_anonymous: opts.isAnonymous ?? true, + ...(durationSeconds !== undefined ? { open_period: durationSeconds } : {}), + ...(threadIdParams ? threadIdParams : {}), + ...(opts.replyToMessageId != null + ? { reply_to_message_id: Math.trunc(opts.replyToMessageId) } + : {}), + ...(opts.silent === true ? { disable_notification: true } : {}), + }; + + const result = await sendWithThreadFallback(pollParams, "poll", async (effectiveParams, label) => + requestWithDiag( + () => api.sendPoll(chatId, normalizedPoll.question, pollOptions, effectiveParams), + label, + ).catch((err) => { + throw wrapChatNotFound(err); + }), + ); + + const messageId = String(result?.message_id ?? "unknown"); + const resolvedChatId = String(result?.chat?.id ?? chatId); + const pollId = result?.poll?.id; + if (result?.message_id) { + recordSentMessage(chatId, result.message_id); + } + + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageId, chatId: resolvedChatId, pollId }; +} diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index 9f6fdd901b8..5f627b454ac 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -149,6 +149,7 @@ describe("web outbound", () => { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2, + durationSeconds: undefined, durationHours: undefined, }); });