import { z } from "zod"; import { invalidateConfigCache, loadRawConfig, saveRawConfig, setNestedValue, } from "../../config/loader.js"; import { hasWebhookRoutingConfigured } from "../../config/webhook-routing.js"; import { registerCallbackRoute } from "../../inbound/platform-callback-registration.js"; import { ensureManualTokenConnection, removeManualTokenConnection, syncManualTokenConnection, } from "../../oauth/manual-token-connection.js"; import { getConnectionByProvider } from "../../oauth/oauth-store.js"; import { credentialKey } from "../../security/credential-key.js"; import { deleteSecureKeyAsync, getSecureKeyAsync, setSecureKeyAsync, } from "../../security/secure-keys.js"; import { getTelegramBotId, getTelegramBotUsername, } from "../../telegram/bot-username.js"; import { deleteCredentialMetadata, upsertCredentialMetadata, } from "../../tools/credentials/metadata-store.js"; import { log } from "./shared.js"; const TELEGRAM_BOT_TOKEN_IN_URL_PATTERN = /\/bot\d{8,10}:[A-Za-z0-9_-]{30,120}\//g; const TELEGRAM_BOT_TOKEN_PATTERN = /(? 0) { parts.push(`path=${path}`); } const code = (err as { code?: unknown })?.code; if (typeof code === "string" && code.length > 0) { parts.push(`code=${code}`); } return redactTelegramBotTokens(parts.join(" ")); } // -- Transport-agnostic result type (omits the `type` discriminant) -- export const TelegramConfigResultSchema = z.object({ success: z.boolean(), hasBotToken: z.boolean(), botId: z.string().optional(), botUsername: z.string().optional(), connected: z.boolean(), hasWebhookSecret: z.boolean(), lastError: z.string().optional(), error: z.string().optional(), commandsRegistered: z.array(z.string()).optional(), warning: z.string().optional(), }); export type TelegramConfigResult = z.infer; // -- Extracted business logic functions -- export async function getTelegramConfig(): Promise { const botUsername = getTelegramBotUsername(); await syncManualTokenConnection( "telegram", botUsername ? `@${botUsername}` : undefined, ); const hasBotToken = !!(await getSecureKeyAsync( credentialKey("telegram", "bot_token"), )); const hasWebhookSecret = !!(await getSecureKeyAsync( credentialKey("telegram", "webhook_secret"), )); const conn = getConnectionByProvider("telegram"); const connected = !!(conn && conn.status === "active"); const botId = getTelegramBotId(); return { success: true, hasBotToken, botId, botUsername, connected: connected && hasBotToken && hasWebhookSecret, hasWebhookSecret, }; } export async function setTelegramConfig( botToken?: string, ): Promise { // Resolve token: prefer explicit botToken, fall back to secure storage. // Track provenance so we only rollback tokens that were freshly provided. const isNewToken = !!botToken; const resolvedToken = botToken || (await getSecureKeyAsync(credentialKey("telegram", "bot_token"))); if (!resolvedToken) { return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: "botToken is required for set action", }; } // Validate token via Telegram getMe API let botUsername: string; let botId: string; try { const res = await fetch( `https://api.telegram.org/bot${resolvedToken}/getMe`, ); if (!res.ok) { const body = await res.text(); return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: `Telegram API validation failed: ${body}`, }; } const data = (await res.json()) as { ok: boolean; result?: { id?: number; username?: string }; }; if (!data.ok || !data.result?.username) { return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: "Telegram API returned unexpected response", }; } botUsername = data.result.username; botId = data.result.id != null ? String(data.result.id) : ""; } catch (err) { const message = summarizeTelegramError(err); return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: `Failed to validate bot token: ${message}`, }; } // Store bot token securely (async — writes broker + encrypted store) const stored = await setSecureKeyAsync( credentialKey("telegram", "bot_token"), resolvedToken, ); if (!stored) { return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: "Failed to store bot token in secure storage", }; } // Store credential metadata record for policy checks upsertCredentialMetadata("telegram", "bot_token", {}); // Persist bot username and bot ID to config for the config-based path const raw = loadRawConfig(); setNestedValue(raw, "telegram.botId", botId); setNestedValue(raw, "telegram.botUsername", botUsername); saveRawConfig(raw); invalidateConfigCache(); // Ensure webhook secret exists (generate if missing) let hasWebhookSecret = !!(await getSecureKeyAsync( credentialKey("telegram", "webhook_secret"), )); if (!hasWebhookSecret) { const { randomUUID } = await import("node:crypto"); const webhookSecret = randomUUID(); const secretStored = await setSecureKeyAsync( credentialKey("telegram", "webhook_secret"), webhookSecret, ); if (secretStored) { upsertCredentialMetadata("telegram", "webhook_secret", {}); hasWebhookSecret = true; } else { // Only roll back the bot token if it was freshly provided. // When the token came from secure storage it was already valid // configuration; deleting it would destroy working state. if (isNewToken) { await deleteSecureKeyAsync(credentialKey("telegram", "bot_token")); deleteCredentialMetadata("telegram", "bot_token"); } // Always revert the config write — the botId and botUsername were written // optimistically before webhook secret provisioning. const rawRollback = loadRawConfig(); setNestedValue(rawRollback, "telegram.botId", ""); setNestedValue(rawRollback, "telegram.botUsername", ""); saveRawConfig(rawRollback); invalidateConfigCache(); return { success: false, hasBotToken: !isNewToken, connected: false, hasWebhookSecret: false, error: "Failed to store webhook secret", }; } } else { // Self-heal: ensure metadata exists even when the secret was // already present (covers previously lost/corrupted metadata). upsertCredentialMetadata("telegram", "webhook_secret", {}); } // Sync oauth_connection record so getConnectionByProvider("telegram") // reflects the current credential state. await ensureManualTokenConnection( "telegram", botUsername ? `@${botUsername}` : undefined, ); const result: TelegramConfigResult = { success: true, hasBotToken: true, botId, botUsername, connected: true, hasWebhookSecret, }; // Register the Telegram callback route so the platform knows how to // forward Telegram webhooks. This applies whenever webhooks are delivered // via managed callbacks: platform pods, and platform-connected local // assistants with no public ingress. For both, platform callbacks are the // only way to receive Telegram webhooks. `hasWebhookRoutingConfigured` // encodes the resolution order (a configured ingress wins; an explicit // `ingress.enabled: false` blocks the platform fallback) and is the same // derivation the Telegram status checks read, so registration and reported // status stay in agreement. const { usesManagedCallbacks } = await hasWebhookRoutingConfigured(true); if (usesManagedCallbacks) { registerCallbackRoute("webhooks/telegram", "telegram").catch((err) => { log.warn({ err }, "Failed to register Telegram platform callback route"); }); } return result; } export async function clearTelegramConfig(): Promise { // Deregister the Telegram webhook before deleting credentials. // The gateway reconcile short-circuits when credentials are absent, // so we must call the Telegram API directly while the token is still // available. const botToken = await getSecureKeyAsync( credentialKey("telegram", "bot_token"), ); if (botToken) { try { await fetch(`https://api.telegram.org/bot${botToken}/deleteWebhook`); } catch (err) { log.warn( { error: summarizeTelegramError(err) }, "Failed to deregister Telegram webhook (proceeding with credential cleanup)", ); } } const r1 = await deleteSecureKeyAsync(credentialKey("telegram", "bot_token")); const r2 = await deleteSecureKeyAsync( credentialKey("telegram", "webhook_secret"), ); if (r1 === "error" || r2 === "error") { // Check each key individually so partial deletions report accurate status. const hasBotToken = !!(await getSecureKeyAsync( credentialKey("telegram", "bot_token"), )); const hasWebhookSecret = !!(await getSecureKeyAsync( credentialKey("telegram", "webhook_secret"), )); return { success: false, hasBotToken, connected: hasBotToken && hasWebhookSecret, hasWebhookSecret, error: "Failed to delete Telegram credentials from secure storage", }; } deleteCredentialMetadata("telegram", "bot_token"); deleteCredentialMetadata("telegram", "webhook_secret"); // Remove the oauth_connection row so getConnectionByProvider returns undefined. removeManualTokenConnection("telegram"); // Clear bot ID and username from config so getTelegramBotId() and // getTelegramBotUsername() don't return stale values after disconnect. const raw = loadRawConfig(); setNestedValue(raw, "telegram.botId", ""); setNestedValue(raw, "telegram.botUsername", ""); saveRawConfig(raw); invalidateConfigCache(); return { success: true, hasBotToken: false, connected: false, hasWebhookSecret: false, }; } export async function setTelegramCommands( commands?: Array<{ command: string; description: string }>, ): Promise { const storedToken = await getSecureKeyAsync( credentialKey("telegram", "bot_token"), ); if (!storedToken) { return { success: false, hasBotToken: false, connected: false, hasWebhookSecret: false, error: "Bot token not configured. Run set action first.", }; } const resolvedCommands = commands ?? [ { command: "new", description: "Start a new conversation" }, { command: "help", description: "Show available commands" }, ]; try { const res = await fetch( `https://api.telegram.org/bot${storedToken}/setMyCommands`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commands: resolvedCommands }), }, ); if (!res.ok) { const body = await res.text(); const cmdConn = getConnectionByProvider("telegram"); const cmdConnected = !!(cmdConn && cmdConn.status === "active"); return { success: false, hasBotToken: true, connected: cmdConnected, hasWebhookSecret: cmdConnected, error: `Failed to set bot commands: ${body}`, }; } } catch (err) { const message = summarizeTelegramError(err); const cmdConn = getConnectionByProvider("telegram"); const cmdConnected = !!(cmdConn && cmdConn.status === "active"); return { success: false, hasBotToken: true, connected: cmdConnected, hasWebhookSecret: cmdConnected, error: `Failed to set bot commands: ${message}`, }; } const cmdConn = getConnectionByProvider("telegram"); const cmdConnected = !!(cmdConn && cmdConn.status === "active"); return { success: true, hasBotToken: true, connected: cmdConnected, hasWebhookSecret: cmdConnected, commandsRegistered: resolvedCommands.map((c) => c.command), }; } /** * Composite operation: configure the bot token (set) then register commands. * If set succeeds but set_commands fails, returns success with a warning * rather than rolling back the token configuration. */ export async function setupTelegram( commands?: Array<{ command: string; description: string }>, botToken?: string, ): Promise { const setResult = await setTelegramConfig(botToken); if (!setResult.success) { return setResult; } const commandsResult = await setTelegramCommands(commands); if (!commandsResult.success) { // Token was configured successfully but commands failed — return // the set result with a warning instead of failing entirely. return { ...setResult, warning: commandsResult.error ?? "Failed to register bot commands", }; } return { ...commandsResult, botId: setResult.botId, botUsername: setResult.botUsername, }; } // -- Message handler (thin wrapper over extracted functions) --