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
21 changed files with 364 additions and 11 deletions

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