feat: add Telegram as remote questions channel (#645) (#655)

Add Telegram Bot API as a third remote questions channel alongside
Discord and Slack. Implements the ChannelAdapter interface with inline
keyboard buttons, callback query handling, text reply polling, and
supergroup message URL generation.

Closes #645

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-16 11:09:39 -06:00 committed by GitHub
parent 17fbf7d925
commit 011ed1df71
10 changed files with 537 additions and 12 deletions

View file

@ -670,7 +670,8 @@ async function runRemoteQuestionsStep(
// Check existing config
const hasDiscord = authStorage.has('discord_bot') && !!(authStorage.get('discord_bot') as any)?.key
const hasSlack = authStorage.has('slack_bot') && !!(authStorage.get('slack_bot') as any)?.key
const existingChannel = hasDiscord ? 'Discord' : hasSlack ? 'Slack' : null
const hasTelegram = authStorage.has('telegram_bot') && !!(authStorage.get('telegram_bot') as any)?.key
const existingChannel = hasDiscord ? 'Discord' : hasSlack ? 'Slack' : hasTelegram ? 'Telegram' : null
type RemoteOption = { value: string; label: string; hint?: string }
const options: RemoteOption[] = []
@ -682,6 +683,7 @@ async function runRemoteQuestionsStep(
options.push(
{ value: 'discord', label: 'Discord', hint: 'receive questions in a Discord channel' },
{ value: 'slack', label: 'Slack', hint: 'receive questions in a Slack channel' },
{ value: 'telegram', label: 'Telegram', hint: 'receive questions via Telegram bot' },
{ value: 'skip', label: 'Skip for now', hint: 'use /gsd remote inside GSD later' },
)
@ -756,6 +758,75 @@ async function runRemoteQuestionsStep(
return 'Slack'
}
if (choice === 'telegram') {
const token = await p.password({
message: 'Paste your Telegram bot token (from @BotFather):',
mask: '●',
})
if (p.isCancel(token) || !(token as string)?.trim()) return null
const trimmed = (token as string).trim()
if (!/^\d+:[A-Za-z0-9_-]+$/.test(trimmed)) {
p.log.warn('Invalid token format — Telegram bot tokens look like 123456789:ABCdefGHI...')
return null
}
// Validate
const s = p.spinner()
s.start('Validating Telegram bot token...')
try {
const res = await fetch(`https://api.telegram.org/bot${trimmed}/getMe`, {
signal: AbortSignal.timeout(15_000),
})
const data = await res.json() as any
if (!data?.ok || !data?.result?.id) {
s.stop('Telegram token validation failed')
return null
}
s.stop(`Telegram bot: ${pc.green(data.result.first_name ?? data.result.username ?? 'bot')}`)
} catch {
s.stop('Could not reach Telegram API')
return null
}
authStorage.set('telegram_bot', { type: 'api_key', key: trimmed })
process.env.TELEGRAM_BOT_TOKEN = trimmed
const chatId = await p.text({
message: 'Paste the Telegram chat ID (e.g. -1001234567890):',
validate: (val) => {
if (!val || !/^-?\d{5,20}$/.test(val.trim())) return 'Expected a numeric chat ID (can be negative for groups)'
},
})
if (p.isCancel(chatId) || !chatId) return null
const trimmedChatId = (chatId as string).trim()
// Test send
const ts = p.spinner()
ts.start('Testing message delivery...')
try {
const res = await fetch(`https://api.telegram.org/bot${trimmed}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: trimmedChatId, text: 'GSD remote questions connected.' }),
signal: AbortSignal.timeout(15_000),
})
const data = await res.json() as any
if (!data?.ok) {
ts.stop(`Could not send to chat: ${data?.description ?? 'unknown error'}`)
return null
}
ts.stop('Test message sent')
} catch {
ts.stop('Could not reach Telegram API')
return null
}
const { saveRemoteQuestionsConfig } = await import('./remote-questions-config.js')
saveRemoteQuestionsConfig('telegram', trimmedChatId)
p.log.success(`Telegram chat: ${pc.green(trimmedChatId)}`)
return 'Telegram'
}
return null
}
@ -877,6 +948,7 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void {
['jina', 'JINA_API_KEY'],
['slack_bot', 'SLACK_BOT_TOKEN'],
['discord_bot', 'DISCORD_BOT_TOKEN'],
['telegram_bot', 'TELEGRAM_BOT_TOKEN'],
['groq', 'GROQ_API_KEY'],
['ollama-cloud', 'OLLAMA_API_KEY'],
['custom-openai', 'CUSTOM_OPENAI_API_KEY'],

View file

@ -12,7 +12,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getGlobalGSDPreferencesPath } from "./resources/extensions/gsd/preferences.js";
export function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
export function saveRemoteQuestionsConfig(channel: "slack" | "discord" | "telegram", channelId: string): void {
const prefsPath = getGlobalGSDPreferencesPath();
const block = [
"remote_questions:",

View file

@ -139,7 +139,7 @@ export interface AutoSupervisorConfig {
}
export interface RemoteQuestionsConfig {
channel: "slack" | "discord";
channel: "slack" | "discord" | "telegram";
channel_id: string | number;
timeout_minutes?: number; // clamped to 1-30
poll_interval_seconds?: number; // clamped to 2-30

View file

@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parseSlackReply, parseDiscordResponse, formatForDiscord, formatForSlack, parseSlackReactionResponse } from "../../remote-questions/format.ts";
import { parseSlackReply, parseDiscordResponse, formatForDiscord, formatForSlack, parseSlackReactionResponse, formatForTelegram, parseTelegramResponse } from "../../remote-questions/format.ts";
import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts";
import { sanitizeError } from "../../remote-questions/manager.ts";
@ -464,6 +464,172 @@ test("DiscordAdapter source-level: resolves guild ID for message URLs", () => {
);
});
// ═══════════════════════════════════════════════════════════════════════════
// Telegram Tests
// ═══════════════════════════════════════════════════════════════════════════
test("formatForTelegram single-question produces inline keyboard", () => {
const prompt = {
id: "tg-1",
channel: "telegram" as const,
createdAt: Date.now(),
timeoutAt: Date.now() + 60000,
pollIntervalMs: 5000,
questions: [{
id: "q1",
header: "Confirm",
question: "Proceed?",
options: [
{ label: "Yes", description: "Continue" },
{ label: "No", description: "Stop" },
],
allowMultiple: false,
}],
};
const msg = formatForTelegram(prompt);
assert.equal(msg.parse_mode, "HTML");
assert.ok(msg.text.includes("<b>GSD needs your input</b>"));
assert.ok(msg.text.includes("<b>Confirm</b>"));
assert.ok(msg.reply_markup, "single-question should have inline keyboard");
assert.equal(msg.reply_markup!.inline_keyboard.length, 2, "should have 2 button rows");
assert.equal(msg.reply_markup!.inline_keyboard[0][0].callback_data, "tg-1:0");
assert.equal(msg.reply_markup!.inline_keyboard[1][0].callback_data, "tg-1:1");
});
test("formatForTelegram multi-question omits inline keyboard", () => {
const prompt = {
id: "tg-2",
channel: "telegram" as const,
createdAt: Date.now(),
timeoutAt: Date.now() + 60000,
pollIntervalMs: 5000,
questions: [
{
id: "q1",
header: "First",
question: "Pick",
options: [{ label: "A", description: "a" }],
allowMultiple: false,
},
{
id: "q2",
header: "Second",
question: "Pick",
options: [{ label: "B", description: "b" }],
allowMultiple: false,
},
],
};
const msg = formatForTelegram(prompt);
assert.equal(msg.reply_markup, undefined, "multi-question should not have inline keyboard");
assert.ok(msg.text.includes("1/2"), "should show question position");
assert.ok(msg.text.includes("2/2"), "should show question position");
});
test("formatForTelegram escapes HTML in user content", () => {
const prompt = {
id: "tg-3",
channel: "telegram" as const,
createdAt: Date.now(),
timeoutAt: Date.now() + 60000,
pollIntervalMs: 5000,
questions: [{
id: "q1",
header: "Test <script>",
question: "Is 5 > 3 & 2 < 4?",
options: [{ label: "<b>Yes</b>", description: "it's true" }],
allowMultiple: false,
}],
};
const msg = formatForTelegram(prompt);
assert.ok(msg.text.includes("&lt;script&gt;"), "should escape < > in header");
assert.ok(msg.text.includes("5 &gt; 3 &amp; 2 &lt; 4"), "should escape in question");
assert.ok(msg.text.includes("&lt;b&gt;Yes&lt;/b&gt;"), "should escape in option label");
});
test("parseTelegramResponse handles callback_data button press", () => {
const questions = [{
id: "choice",
header: "Pick",
question: "Choose",
allowMultiple: false,
options: [
{ label: "Alpha", description: "A" },
{ label: "Beta", description: "B" },
],
}];
const result = parseTelegramResponse("prompt-123:1", null, questions, "prompt-123");
assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } });
});
test("parseTelegramResponse handles text reply delegation", () => {
const questions = [{
id: "choice",
header: "Pick",
question: "Choose",
allowMultiple: false,
options: [
{ label: "Alpha", description: "A" },
{ label: "Beta", description: "B" },
],
}];
const result = parseTelegramResponse(null, "1", questions, "prompt-123");
assert.deepEqual(result, { answers: { choice: { answers: ["Alpha"] } } });
});
test("parseTelegramResponse handles multi-question semicolons", () => {
const questions = [
{
id: "first",
header: "First",
question: "Pick",
allowMultiple: false,
options: [
{ label: "Alpha", description: "A" },
{ label: "Beta", description: "B" },
],
},
{
id: "second",
header: "Second",
question: "Pick",
allowMultiple: false,
options: [
{ label: "Gamma", description: "G" },
{ label: "Delta", description: "D" },
],
},
];
const result = parseTelegramResponse(null, "2;1", questions, "prompt-123");
assert.deepEqual(result.answers.first.answers, ["Beta"]);
assert.deepEqual(result.answers.second.answers, ["Gamma"]);
});
test("isValidChannelId validates Telegram chat IDs", () => {
// Valid positive ID
assert.equal(isValidChannelId("telegram", "12345"), true);
// Valid negative group ID
assert.equal(isValidChannelId("telegram", "-1001234567890"), true);
// Too short
assert.equal(isValidChannelId("telegram", "1234"), false);
// Non-numeric
assert.equal(isValidChannelId("telegram", "abc12345"), false);
// URL injection
assert.equal(isValidChannelId("telegram", "https://evil.com"), false);
});
test("sanitizeError strips Telegram bot token patterns", () => {
const fakeToken = "1234567890:ABCdefGHIjklMNOpqrSTUvwxyz12345678";
const result = sanitizeError(`Token: ${fakeToken}`);
assert.ok(!result.includes("1234567890:ABC"), "should strip Telegram bot token");
});
test("DiscordAdapter source-level: sendPrompt sets threadUrl in ref", () => {
const adapterSrc = readFileSync(
join(__dirname, "..", "..", "remote-questions", "discord-adapter.ts"),

View file

@ -16,12 +16,14 @@ export interface ResolvedConfig {
const ENV_KEYS: Record<RemoteChannel, string> = {
slack: "SLACK_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
telegram: "TELEGRAM_BOT_TOKEN",
};
// Channel ID format validation — prevents SSRF if preferences are attacker-controlled
const CHANNEL_ID_PATTERNS: Record<RemoteChannel, RegExp> = {
slack: /^[A-Z0-9]{9,12}$/,
discord: /^\d{17,20}$/,
telegram: /^-?\d{5,20}$/,
};
const DEFAULT_TIMEOUT_MINUTES = 5;
@ -35,7 +37,7 @@ export function resolveRemoteConfig(): ResolvedConfig | null {
const prefs = loadEffectiveGSDPreferences();
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
if (!rq || !rq.channel || !rq.channel_id) return null;
if (rq.channel !== "slack" && rq.channel !== "discord") return null;
if (rq.channel !== "slack" && rq.channel !== "discord" && rq.channel !== "telegram") return null;
const channelId = String(rq.channel_id);
if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return null;
@ -59,7 +61,7 @@ export function getRemoteConfigStatus(): string {
const prefs = loadEffectiveGSDPreferences();
const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions;
if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured";
if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`;
if (rq.channel !== "slack" && rq.channel !== "discord" && rq.channel !== "telegram") return `Remote questions: unknown channel type \"${rq.channel}\"`;
const channelId = String(rq.channel_id);
if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return `Remote questions: invalid ${rq.channel} channel ID format`;
const envVar = ENV_KEYS[rq.channel];

View file

@ -196,6 +196,97 @@ export function parseSlackReactionResponse(
return { answers };
}
export interface TelegramInlineButton {
text: string;
callback_data: string;
}
export interface TelegramInlineKeyboardMarkup {
inline_keyboard: TelegramInlineButton[][];
}
export interface TelegramMessage {
text: string;
parse_mode: "HTML";
reply_markup?: TelegramInlineKeyboardMarkup;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function formatForTelegram(prompt: RemotePrompt): TelegramMessage {
const lines: string[] = ["<b>GSD needs your input</b>", ""];
for (let qi = 0; qi < prompt.questions.length; qi++) {
const q = prompt.questions[qi];
lines.push(`<b>${escapeHtml(q.header)}</b>`);
lines.push(escapeHtml(q.question));
lines.push("");
for (let i = 0; i < q.options.length; i++) {
lines.push(`${i + 1}. <b>${escapeHtml(q.options[i].label)}</b> — ${escapeHtml(q.options[i].description)}`);
}
lines.push("");
if (prompt.questions.length === 1) {
lines.push(q.allowMultiple
? "Reply with comma-separated numbers (1,3) or free text."
: "Reply with a number or tap a button below.");
} else {
lines.push(`Question ${qi + 1}/${prompt.questions.length} — reply with one line per question or use semicolons.`);
}
if (qi < prompt.questions.length - 1) lines.push("");
}
const result: TelegramMessage = {
text: lines.join("\n"),
parse_mode: "HTML",
};
// Inline keyboard for single-question with <=5 options
const isSingle = prompt.questions.length === 1;
if (isSingle && prompt.questions[0].options.length <= 5) {
result.reply_markup = {
inline_keyboard: prompt.questions[0].options.map((opt, i) => [{
text: `${i + 1}. ${opt.label}`,
callback_data: `${prompt.id}:${i}`,
}]),
};
}
return result;
}
export function parseTelegramResponse(
callbackData: string | null,
replyText: string | null,
questions: RemoteQuestion[],
promptId: string,
): RemoteAnswer {
// Handle callback_data from inline keyboard button press
if (callbackData) {
const match = callbackData.match(new RegExp(`^${promptId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:(\\d+)$`));
if (match && questions.length === 1) {
const idx = parseInt(match[1], 10);
const q = questions[0];
if (idx >= 0 && idx < q.options.length) {
return { answers: { [q.id]: { answers: [q.options[idx].label] } } };
}
}
}
// Handle text reply — delegate to parseSlackReply (text parsing is format-agnostic)
if (replyText) return parseSlackReply(replyText, questions);
const answers: RemoteAnswer["answers"] = {};
for (const q of questions) {
answers[q.id] = { answers: [], user_note: "No response provided" };
}
return { answers };
}
function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } {
if (!text) return { answers: [], user_note: "No response provided" };

View file

@ -7,6 +7,7 @@ import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from
import { resolveRemoteConfig, type ResolvedConfig } from "./config.js";
import { DiscordAdapter } from "./discord-adapter.js";
import { SlackAdapter } from "./slack-adapter.js";
import { TelegramAdapter } from "./telegram-adapter.js";
import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js";
interface ToolResult {
@ -119,9 +120,9 @@ function createPrompt(questions: QuestionInput[], config: ResolvedConfig): Remot
}
function createAdapter(config: ResolvedConfig): ChannelAdapter {
return config.channel === "slack"
? new SlackAdapter(config.token, config.channelId)
: new DiscordAdapter(config.token, config.channelId);
if (config.channel === "slack") return new SlackAdapter(config.token, config.channelId);
if (config.channel === "telegram") return new TelegramAdapter(config.token, config.channelId);
return new DiscordAdapter(config.token, config.channelId);
}
async function pollUntilDone(
@ -181,6 +182,7 @@ const TOKEN_PATTERNS = [
/xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
/xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
/xoxa-[A-Za-z0-9\-]+/g, // Slack app tokens
/\d{8,10}:[A-Za-z0-9_-]{35}/g, // Telegram bot tokens
/[A-Za-z0-9_\-.]{20,}/g, // Long opaque secrets (Discord tokens, etc.)
];

View file

@ -21,6 +21,7 @@ export async function handleRemote(
if (trimmed === "slack") return handleSetupSlack(ctx);
if (trimmed === "discord") return handleSetupDiscord(ctx);
if (trimmed === "telegram") return handleSetupTelegram(ctx);
if (trimmed === "status") return handleRemoteStatus(ctx);
if (trimmed === "disconnect") return handleDisconnect(ctx);
@ -155,6 +156,32 @@ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info");
}
async function handleSetupTelegram(ctx: ExtensionCommandContext): Promise<void> {
const token = await promptMaskedInput(ctx, "Telegram Bot Token", "Paste your bot token from @BotFather");
if (!token) return void ctx.ui.notify("Telegram setup cancelled.", "info");
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) return void ctx.ui.notify("Invalid token format — Telegram bot tokens look like 123456789:ABCdefGHI...", "warning");
ctx.ui.notify("Validating token...", "info");
const auth = await fetchJson(`https://api.telegram.org/bot${token}/getMe`);
if (!auth?.ok || !auth?.result?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error");
const chatId = await promptInput(ctx, "Chat ID", "Paste the Telegram chat ID (e.g. -1001234567890)");
if (!chatId) return void ctx.ui.notify("Telegram setup cancelled.", "info");
if (!isValidChannelId("telegram", chatId)) return void ctx.ui.notify("Invalid Telegram chat ID format — expected a numeric ID (can be negative for groups).", "error");
const send = await fetchJson(`https://api.telegram.org/bot${token}/sendMessage`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text: "GSD remote questions connected." }),
});
if (!send?.ok) return void ctx.ui.notify(`Could not send to chat: ${send?.description ?? "unknown error"}`, "error");
saveProviderToken("telegram_bot", token);
process.env.TELEGRAM_BOT_TOKEN = token;
saveRemoteQuestionsConfig("telegram", chatId);
ctx.ui.notify(`Telegram connected — remote questions enabled for chat ${chatId}.`, "info");
}
async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise<void> {
const status = getRemoteConfigStatus();
const config = resolveRemoteConfig();
@ -180,9 +207,11 @@ async function handleDisconnect(ctx: ExtensionCommandContext): Promise<void> {
if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info");
removeRemoteQuestionsConfig();
removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot");
const providerMap: Record<string, string> = { slack: "slack_bot", discord: "discord_bot", telegram: "telegram_bot" };
removeProviderToken(providerMap[channel] ?? channel);
if (channel === "slack") delete process.env.SLACK_BOT_TOKEN;
if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN;
if (channel === "telegram") delete process.env.TELEGRAM_BOT_TOKEN;
ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info");
}
@ -200,6 +229,7 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
" /gsd remote disconnect",
" /gsd remote slack",
" /gsd remote discord",
" /gsd remote telegram",
]
: [
"No remote question channel configured.",
@ -207,6 +237,7 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise<void> {
"Commands:",
" /gsd remote slack",
" /gsd remote discord",
" /gsd remote telegram",
" /gsd remote status",
];
@ -284,7 +315,7 @@ function removeProviderToken(provider: string): void {
auth.set(provider, { type: "api_key", key: "" });
}
export function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void {
export function saveRemoteQuestionsConfig(channel: "slack" | "discord" | "telegram", channelId: string): void {
const prefsPath = getGlobalGSDPreferencesPath();
const block = [
"remote_questions:",

View file

@ -0,0 +1,161 @@
/**
* Remote Questions Telegram adapter
*/
import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js";
import { formatForTelegram, parseTelegramResponse } from "./format.js";
const TELEGRAM_API = "https://api.telegram.org";
const PER_REQUEST_TIMEOUT_MS = 15_000;
export class TelegramAdapter implements ChannelAdapter {
readonly name = "telegram" as const;
private botUserId: number | null = null;
private lastUpdateId = 0;
private lastSentText = "";
private readonly token: string;
private readonly chatId: string;
constructor(token: string, chatId: string) {
this.token = token;
this.chatId = chatId;
}
async validate(): Promise<void> {
const res = await this.telegramApi("getMe");
if (!res.ok || !res.result?.id) throw new Error("Telegram auth failed: invalid bot token");
this.botUserId = res.result.id;
}
async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
const payload = formatForTelegram(prompt);
this.lastSentText = payload.text;
const params: Record<string, unknown> = {
chat_id: this.chatId,
text: payload.text,
parse_mode: payload.parse_mode,
};
if (payload.reply_markup) {
params.reply_markup = payload.reply_markup;
}
const res = await this.telegramApi("sendMessage", params);
if (!res.ok || !res.result?.message_id) {
throw new Error(`Telegram sendMessage failed: ${JSON.stringify(res)}`);
}
const messageId = String(res.result.message_id);
const messageUrl = this.buildMessageUrl(this.chatId, messageId);
return {
ref: {
id: prompt.id,
channel: "telegram",
messageId,
channelId: this.chatId,
threadUrl: messageUrl,
},
};
}
async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
if (!this.botUserId) await this.validate();
const res = await this.telegramApi("getUpdates", {
offset: this.lastUpdateId + 1,
timeout: 0,
allowed_updates: ["message", "callback_query"],
});
if (!res.ok || !Array.isArray(res.result)) return null;
for (const update of res.result) {
// Advance offset for all updates to prevent reprocessing
if (update.update_id > this.lastUpdateId) {
this.lastUpdateId = update.update_id;
}
// Handle callback_query (inline keyboard button press)
if (update.callback_query) {
const cq = update.callback_query;
const msg = cq.message;
if (
msg &&
String(msg.chat?.id) === ref.channelId &&
String(msg.message_id) === ref.messageId &&
cq.from?.id !== this.botUserId
) {
// Dismiss the loading spinner on the button
try {
await this.telegramApi("answerCallbackQuery", { callback_query_id: cq.id });
} catch { /* best-effort */ }
return parseTelegramResponse(cq.data ?? null, null, prompt.questions, prompt.id);
}
}
// Handle text reply (reply_to_message)
if (update.message) {
const msg = update.message;
if (
String(msg.chat?.id) === ref.channelId &&
msg.reply_to_message &&
String(msg.reply_to_message.message_id) === ref.messageId &&
msg.from?.id !== this.botUserId &&
msg.text
) {
return parseTelegramResponse(null, msg.text, prompt.questions, prompt.id);
}
}
}
return null;
}
/**
* Acknowledge receipt by editing the original message to append a checkmark.
* Best-effort failures are silently ignored.
*/
async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
try {
await this.telegramApi("editMessageText", {
chat_id: ref.channelId,
message_id: parseInt(ref.messageId, 10),
text: this.lastSentText + "\n\n✅ Answered",
parse_mode: "HTML",
});
} catch {
// Best-effort — don't let acknowledgement failures affect the flow
}
}
private buildMessageUrl(chatId: string, messageId: string): string | undefined {
// Supergroups have chat IDs starting with -100
if (chatId.startsWith("-100")) {
return `https://t.me/c/${chatId.slice(4)}/${messageId}`;
}
return undefined;
}
private async telegramApi(method: string, params?: Record<string, unknown>): Promise<any> {
const url = `${TELEGRAM_API}/bot${this.token}/${method}`;
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS),
};
if (params) {
init.body = JSON.stringify(params);
}
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text().catch(() => "");
const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text;
throw new Error(`Telegram API HTTP ${response.status}: ${safeText}`);
}
return response.json();
}
}

View file

@ -2,7 +2,7 @@
* Remote Questions shared types
*/
export type RemoteChannel = "slack" | "discord";
export type RemoteChannel = "slack" | "discord" | "telegram";
export interface RemoteQuestionOption {
label: string;