From 08da8e00bf1fe95f0cd29ff355bcc5c0c312e0c6 Mon Sep 17 00:00:00 2001 From: "Robby (AI-assisted)" Date: Sat, 14 Feb 2026 16:17:09 +0000 Subject: [PATCH] feat(telegram): add sendPoll support --- extensions/telegram/src/channel.ts | 6 ++ src/plugins/runtime/index.ts | 3 +- src/plugins/runtime/types.ts | 2 + src/telegram/index.ts | 2 +- src/telegram/send.ts | 120 +++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index c58cdd7e955..0815b95154b 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, + pollMaxOptions: 10, sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); @@ -298,6 +300,10 @@ export const telegramPlugin: ChannelPlugin + await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + accountId: accountId ?? undefined, + }), }, status: { defaultRuntime: { diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index be557a6f063..d5abe656004 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -126,7 +126,7 @@ import { } from "../../telegram/audit.js"; import { monitorTelegramProvider } from "../../telegram/monitor.js"; import { probeTelegram } from "../../telegram/probe.js"; -import { sendMessageTelegram } from "../../telegram/send.js"; +import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; import { getActiveWebListener } from "../../web/active-listener.js"; @@ -363,6 +363,7 @@ export function createPluginRuntime(): PluginRuntime { probeTelegram, resolveTelegramToken, sendMessageTelegram, + sendPollTelegram, monitorTelegramProvider, messageActions: telegramMessageActions, }, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 447f031489e..71b85d6f12a 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -120,6 +120,7 @@ type CollectTelegramUnmentionedGroupIds = type ProbeTelegram = typeof import("../../telegram/probe.js").probeTelegram; type ResolveTelegramToken = typeof import("../../telegram/token.js").resolveTelegramToken; type SendMessageTelegram = typeof import("../../telegram/send.js").sendMessageTelegram; +type SendPollTelegram = typeof import("../../telegram/send.js").sendPollTelegram; type MonitorTelegramProvider = typeof import("../../telegram/monitor.js").monitorTelegramProvider; type TelegramMessageActions = typeof import("../../channels/plugins/actions/telegram.js").telegramMessageActions; @@ -301,6 +302,7 @@ export type PluginRuntime = { probeTelegram: ProbeTelegram; resolveTelegramToken: ResolveTelegramToken; sendMessageTelegram: SendMessageTelegram; + sendPollTelegram: SendPollTelegram; monitorTelegramProvider: MonitorTelegramProvider; messageActions: TelegramMessageActions; }; 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.ts b/src/telegram/send.ts index ead53ff90d1..56d2febd347 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,122 @@ 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(" "), + ); + }; + + // Telegram currently supports poll duration between 5 and 600 seconds. + // Convert generic durationHours into seconds and clamp to Telegram limits. + const openPeriodSeconds = + normalizedPoll.durationHours !== undefined + ? Math.max(5, Math.min(normalizedPoll.durationHours * 3600, 600)) + : undefined; + + // 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, + ...(openPeriodSeconds !== undefined ? { open_period: openPeriodSeconds } : {}), + ...(threadIdParams ? threadIdParams : {}), + ...(opts.replyToMessageId != null + ? { reply_to_message_id: Math.trunc(opts.replyToMessageId) } + : {}), + ...(opts.silent === true ? { disable_notification: true } : {}), + }; + + const result = await requestWithDiag( + () => api.sendPoll(chatId, normalizedPoll.question, pollOptions, pollParams), + "poll", + ).catch((err) => { + throw wrapChatNotFound(err); + }); + + const messageId = String(result?.message_id ?? "unknown"); + const resolvedChatId = String(result?.chat?.id ?? chatId); + const pollId = result?.poll?.id; + + recordChannelActivity({ + channel: "telegram", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageId, chatId: resolvedChatId, pollId }; +}