From 011ed1df715e848700a8827f6ffd27c0b010527b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 16 Mar 2026 11:09:39 -0600 Subject: [PATCH] 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) --- src/onboarding.ts | 74 +++++++- src/remote-questions-config.ts | 2 +- src/resources/extensions/gsd/preferences.ts | 2 +- .../gsd/tests/remote-questions.test.ts | 168 +++++++++++++++++- .../extensions/remote-questions/config.ts | 6 +- .../extensions/remote-questions/format.ts | 91 ++++++++++ .../extensions/remote-questions/manager.ts | 8 +- .../remote-questions/remote-command.ts | 35 +++- .../remote-questions/telegram-adapter.ts | 161 +++++++++++++++++ .../extensions/remote-questions/types.ts | 2 +- 10 files changed, 537 insertions(+), 12 deletions(-) create mode 100644 src/resources/extensions/remote-questions/telegram-adapter.ts diff --git a/src/onboarding.ts b/src/onboarding.ts index d3668326b..7c649530d 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -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'], diff --git a/src/remote-questions-config.ts b/src/remote-questions-config.ts index 39293b4dc..27e98b380 100644 --- a/src/remote-questions-config.ts +++ b/src/remote-questions-config.ts @@ -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:", diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index c129b7f60..97e278681 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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 diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 4c30c81a2..d4b8ec734 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -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("GSD needs your input")); + assert.ok(msg.text.includes("Confirm")); + 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