feat(telegram): add sendPoll support (#16193) (#16209)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: b58492cfed
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
This commit is contained in:
Robby 2026-02-14 18:34:30 +01:00 committed by GitHub
parent fc5d147d1b
commit 8e5689a84d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 364 additions and 11 deletions

View File

@ -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

View File

@ -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:
```

View File

@ -285,28 +285,31 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
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: {

View File

@ -96,6 +96,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
reactions: true,
threads: true,
media: true,
polls: true,
nativeCommands: true,
blockStreaming: true,
},
@ -273,7 +274,8 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
chunker: (text, limit) => 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<ResolvedTelegramAccount, TelegramProb
messageThreadId,
replyToMessageId,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "telegram", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
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<ResolvedTelegramAccount, TelegramProb
messageThreadId,
replyToMessageId,
accountId: accountId ?? undefined,
silent: silent ?? undefined,
});
return { channel: "telegram", ...result };
},
sendPoll: async ({ to, poll, accountId, threadId, silent, isAnonymous }) =>
await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, {
accountId: accountId ?? undefined,
messageThreadId: parseThreadId(threadId),
silent: silent ?? undefined,
isAnonymous: isAnonymous ?? undefined,
}),
},
status: {
defaultRuntime: {

View File

@ -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,
}),
};

View File

@ -344,4 +344,6 @@ export type ChannelPollContext = {
poll: PollInput;
accountId?: string | null;
threadId?: string | null;
silent?: boolean;
isAnonymous?: boolean;
};

View File

@ -15,8 +15,17 @@ export function registerMessagePollCommand(message: Command, helpers: MessageCli
[] as string[],
)
.option("--poll-multi", "Allow multiple selections", false)
.option("--poll-duration-hours <n>", "Poll duration (Discord)")
.option("--poll-duration-hours <n>", "Poll duration in hours (Discord)")
.option("--poll-duration-seconds <n>", "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 <text>", "Optional message body")
.option(
"--silent",
"Send poll silently without notification (Telegram + Discord where supported)",
false,
)
.option("--thread-id <id>", "Thread id (Telegram forum topic / Slack thread ts)")
.action(async (opts) => {
await helpers.runMessageAction("poll", opts);
});

View File

@ -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",

View File

@ -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,

View File

@ -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<string, unknown> = {
runId: idem,

View File

@ -568,11 +568,36 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
if (options.length < 2) {
throw new Error("pollOption requires at least two values");
}
const silent = readBooleanParam(params, "silent");
const allowMultiselect = readBooleanParam(params, "pollMulti") ?? false;
const pollAnonymous = readBooleanParam(params, "pollAnonymous");
const pollPublic = readBooleanParam(params, "pollPublic");
if (pollAnonymous && pollPublic) {
throw new Error("pollAnonymous and pollPublic are mutually exclusive");
}
const isAnonymous = pollAnonymous ? true : pollPublic ? false : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
const durationSeconds = readNumberParam(params, "pollDurationSeconds", {
integer: true,
});
const maxSelections = allowMultiselect ? Math.max(2, options.length) : 1;
const threadId = readStringParam(params, "threadId");
const slackAutoThreadId =
channel === "slack" && !threadId
? resolveSlackAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const telegramAutoThreadId =
channel === "telegram" && !threadId
? resolveTelegramAutoThreadId({ to, toolContext: input.toolContext })
: undefined;
const resolvedThreadId = threadId ?? slackAutoThreadId ?? telegramAutoThreadId;
if (resolvedThreadId && !params.threadId) {
params.threadId = resolvedThreadId;
}
const base = typeof params.message === "string" ? params.message : "";
await maybeApplyCrossContextMarker({
cfg,
@ -595,12 +620,16 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
gateway,
toolContext: input.toolContext,
dryRun,
silent: silent ?? undefined,
},
to,
question,
options,
maxSelections,
durationSeconds: durationSeconds ?? undefined,
durationHours: durationHours ?? undefined,
threadId: resolvedThreadId ?? undefined,
isAnonymous,
});
return {

View File

@ -69,8 +69,13 @@ type MessagePollParams = {
question: string;
options: string[];
maxSelections?: number;
durationSeconds?: number;
durationHours?: number;
channel?: string;
accountId?: string;
threadId?: string;
silent?: boolean;
isAnonymous?: boolean;
dryRun?: boolean;
cfg?: OpenClawConfig;
gateway?: MessageGatewayOptions;
@ -83,6 +88,7 @@ export type MessagePollResult = {
question: string;
options: string[];
maxSelections: number;
durationSeconds: number | null;
durationHours: number | null;
via: "gateway";
result?: {
@ -239,6 +245,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
question: params.question,
options: params.options,
maxSelections: params.maxSelections,
durationSeconds: params.durationSeconds,
durationHours: params.durationHours,
};
const plugin = getChannelPlugin(channel);
@ -257,6 +264,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationSeconds: normalized.durationSeconds ?? null,
durationHours: normalized.durationHours ?? null,
via: "gateway",
dryRun: true,
@ -279,8 +287,13 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationSeconds: normalized.durationSeconds,
durationHours: normalized.durationHours,
threadId: params.threadId,
silent: params.silent,
isAnonymous: params.isAnonymous,
channel,
accountId: params.accountId,
idempotencyKey: params.idempotencyKey ?? randomIdempotencyKey(),
},
timeoutMs: gateway.timeoutMs,
@ -295,6 +308,7 @@ export async function sendPoll(params: MessagePollParams): Promise<MessagePollRe
question: normalized.question,
options: normalized.options,
maxSelections: normalized.maxSelections,
durationSeconds: normalized.durationSeconds ?? null,
durationHours: normalized.durationHours ?? null,
via: "gateway",
result,

View File

@ -145,7 +145,10 @@ export async function executePollAction(params: {
question: string;
options: string[];
maxSelections: number;
durationSeconds?: number;
durationHours?: number;
threadId?: string;
isAnonymous?: boolean;
}): Promise<{
handledBy: "plugin" | "core";
payload: unknown;
@ -178,8 +181,13 @@ export async function executePollAction(params: {
question: params.question,
options: params.options,
maxSelections: params.maxSelections,
durationSeconds: params.durationSeconds ?? undefined,
durationHours: params.durationHours ?? undefined,
channel: params.ctx.channel,
accountId: params.ctx.accountId ?? undefined,
threadId: params.threadId ?? undefined,
silent: params.ctx.silent ?? undefined,
isAnonymous: params.isAnonymous ?? undefined,
dryRun: params.ctx.dryRun,
gateway: params.ctx.gateway,
});

View File

@ -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,
},

View File

@ -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;
};

View File

@ -13,6 +13,7 @@ describe("polls", () => {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationSeconds: undefined,
durationHours: undefined,
});
});

View File

@ -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,
};
}

View File

@ -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";

View File

@ -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();
});
});

View File

@ -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 = <T>(fn: () => Promise<T>, 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 <T>(
params: Record<string, unknown> | undefined,
label: string,
attempt: (
effectiveParams: Record<string, unknown> | undefined,
effectiveLabel: string,
) => Promise<T>,
): Promise<T> => {
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 };
}

View File

@ -149,6 +149,7 @@ describe("web outbound", () => {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationSeconds: undefined,
durationHours: undefined,
});
});