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