From 723c52bad6df819137cebbdf439b0a0c7e18e406 Mon Sep 17 00:00:00 2001 From: Felipe Domingues Date: Thu, 28 May 2026 19:43:09 -0300 Subject: [PATCH] fix: harden telegram bot reconnection --- .../mika/telegram/BotFatherWizard.tsx | 2 +- supabase/functions/provision-agent/index.ts | 155 ++++++++++++++++++ .../functions/validate-telegram-bot/index.ts | 69 ++++++-- 3 files changed, 207 insertions(+), 19 deletions(-) diff --git a/src/components/mika/telegram/BotFatherWizard.tsx b/src/components/mika/telegram/BotFatherWizard.tsx index 28dff5c..3a8bfec 100644 --- a/src/components/mika/telegram/BotFatherWizard.tsx +++ b/src/components/mika/telegram/BotFatherWizard.tsx @@ -21,7 +21,7 @@ import { suggestBotUsername, } from "@/lib/telegram-username"; -const TOKEN_REGEX = /^\d+:[A-Za-z0-9_-]{35}$/; +const TOKEN_REGEX = /^\d+:[A-Za-z0-9_-]+$/; interface Props { agentName: string; diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index d7e2786..4515922 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -41,6 +41,7 @@ type SupabaseAdminClient = ReturnType>; interface RequestBody { agent_instance_id: string; + mode?: "telegram_reconnect"; agent_name?: string; soul_content?: string; model?: string; @@ -119,6 +120,12 @@ Quando o usuário pedir para agendar, lembrar, programar, criar rotina, criar au Quando o usuário pedir para criar, salvar, ensinar ou transformar instruções em uma skill reutilizável, use obrigatoriamente a tool skill_create passando a frase original dele em natural_language_input.`; } +function readVaultSecretValue(data: unknown): string { + const row = Array.isArray(data) ? data[0] : data; + return (row as { decrypted_secret?: string | null } | null)?.decrypted_secret ?? + ""; +} + async function notifyAdmin(message: string): Promise { if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; try { @@ -181,6 +188,17 @@ Deno.serve(async (req) => { return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message }); } + if ( + body.mode === "telegram_reconnect" && + agent.status === "active" && + agent.railway_service_id + ) { + console.log( + `[provision-agent] active telegram reconnect for agent=${agent.id}`, + ); + return await handleTelegramReconnect(supabase, agent); + } + if (agent.status !== "provisioning") { console.log(`[provision-agent] status atual=${agent.status}, abortando`); return jsonResponse(409, { @@ -512,6 +530,133 @@ async function scheduleRetry( return false; } +async function resolveRailwayContextForAgent( + supabase: SupabaseAdminClient, + agent: AgentInstanceRow, + railwayServiceId: string, +): Promise<{ projectId: string | null; environmentId: string | null }> { + let projectId: string | null = null; + let environmentId: string | null = null; + + if (agent.vps_pool_id) { + const { data: poolData } = await supabase + .from("vps_pool") + .select("railway_project_id, railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + const pool = poolData as { + railway_project_id?: string | null; + railway_environment_id?: string | null; + } | null; + projectId = pool?.railway_project_id ?? null; + environmentId = pool?.railway_environment_id ?? null; + } + + if (!projectId || !environmentId) { + const ctx = await getServiceContext({ + token: RAILWAY_API_TOKEN!, + serviceId: railwayServiceId, + }); + projectId = projectId ?? ctx.projectId; + environmentId = environmentId ?? ctx.environmentId; + } + + return { projectId, environmentId }; +} + +async function handleTelegramReconnect( + supabase: SupabaseAdminClient, + agent: AgentInstanceRow, +): Promise { + const railwayServiceId = agent.railway_service_id; + if (!railwayServiceId) { + return jsonResponse(400, { + error: "railway_service_id required for telegram reconnect", + }); + } + + if (!agent.telegram_bot_token_vault_id) { + return jsonResponse(409, { error: "telegram bot token is not connected" }); + } + + const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + const telegramBotToken = readVaultSecretValue(secret); + if (!telegramBotToken) { + return jsonResponse(500, { error: "failed to decrypt telegram bot token" }); + } + + try { + await deleteTelegramWebhook(telegramBotToken); + } catch (e) { + console.warn( + "[provision-agent:telegram_reconnect] deleteWebhook failed:", + String(e), + ); + } + + const { projectId, environmentId } = await resolveRailwayContextForAgent( + supabase, + agent, + railwayServiceId, + ); + + if (!projectId || !environmentId) { + console.error( + `[provision-agent:telegram_reconnect] não foi possível resolver railway project/environment`, + ); + return jsonResponse(500, { + error: "failed to resolve railway project/environment", + }); + } + + const variables: Record = { + TELEGRAM_BOT_TOKEN: telegramBotToken, + }; + + if (agent.telegram_user_chat_id) { + const chatIdStr = String(agent.telegram_user_chat_id); + variables.TELEGRAM_ALLOWED_USERS = chatIdStr; + variables.TELEGRAM_HOME_CHANNEL = chatIdStr; + } + + try { + await upsertRailwayVariableCollection({ + token: RAILWAY_API_TOKEN!, + serviceId: railwayServiceId, + environmentId, + projectId, + variables, + skipDeploys: true, + }); + await deployRailwayService({ + token: RAILWAY_API_TOKEN!, + serviceId: railwayServiceId, + environmentId, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[provision-agent:telegram_reconnect] falha:", msg); + return jsonResponse(500, { + error: "telegram reconnect railway update failed", + detail: msg, + }); + } + + await supabase + .from("agent_instances") + .update({ updated_at: new Date().toISOString() }) + .eq("id", agent.id); + + return jsonResponse(200, { + success: true, + mode: "telegram_reconnect", + agent_instance_id: agent.id, + railway_service_id: railwayServiceId, + }); +} + /** * Fluxo de re-provisionamento: agent_instance já tem railway_service_id. * Em vez de criar novo serviço (que dá erro "service already exists"), @@ -604,6 +749,16 @@ async function handleUpdateExistingService( HERMES_TTS_PROVIDER: ttsProvider, }; + if (agent.telegram_bot_token_vault_id) { + const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + const telegramBotToken = readVaultSecretValue(secret); + if (telegramBotToken) { + variables.TELEGRAM_BOT_TOKEN = telegramBotToken; + } + } + // Re-aplica TELEGRAM_ALLOWED_USERS / HOME_CHANNEL se já capturamos chat_id do dono // (importante para corrigir agentes que foram provisionados sem chat_id e tinham // que pedir pairing manual). diff --git a/supabase/functions/validate-telegram-bot/index.ts b/supabase/functions/validate-telegram-bot/index.ts index b940bbe..bcc0543 100644 --- a/supabase/functions/validate-telegram-bot/index.ts +++ b/supabase/functions/validate-telegram-bot/index.ts @@ -20,6 +20,27 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +// deno-lint-ignore no-explicit-any +async function deleteVaultSecret( + admin: any, + secretId: string, + context: string, +): Promise { + try { + const { error } = await admin.rpc("vault_delete_secret", { + secret_id: secretId, + }); + if (error) { + console.warn(`vault_delete_secret ignored (${context}):`, error.message); + } + } catch (e) { + console.warn( + `vault_delete_secret ignored (${context}):`, + e instanceof Error ? e.message : "unknown", + ); + } +} + Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); @@ -124,21 +145,13 @@ Deno.serve(async (req) => { ); } - // 5) Se já houver um vault_id antigo, remove (reconexão) - if (agent.telegram_bot_token_vault_id) { - try { await admin.rpc("exec_sql_void", {} as never); } catch { /* ignore */ } - // tenta remover diretamente; ignora falha - const { error: delErr } = await admin - .from("vault.secrets" as unknown as never) - .delete() - .eq("id", agent.telegram_bot_token_vault_id); - if (delErr) { - console.warn("Vault old secret delete (ignorável):", delErr.message); - } - } + const oldSecretId = agent.telegram_bot_token_vault_id as string | null; - // 6) Cria secret no Vault via RPC SQL - const secretName = `telegram_bot_token_${userId}_${Math.floor(Date.now() / 1000)}`; + // 5) Cria secret novo no Vault antes de remover o antigo. Assim uma falha + // de Vault não quebra um bot que já estava conectado. + const secretName = `telegram_bot_token_${userId}_${Math.floor( + Date.now() / 1000, + )}`; const { data: vaultData, error: vaultErr } = await admin .rpc("vault_create_secret", { secret_value: token, @@ -151,7 +164,8 @@ Deno.serve(async (req) => { if (!vaultErr && vaultData) { // RPC pode retornar { secret_id } ou string // deno-lint-ignore no-explicit-any - secretId = (vaultData as any).secret_id ?? (vaultData as unknown as string); + secretId = + (vaultData as any).secret_id ?? (vaultData as unknown as string); } else { // Fallback: usa SQL direto via PostgREST (requer função vault.create_secret exposta). // Caso não exista a RPC, criamos via PostgREST raw SQL exec. @@ -187,7 +201,7 @@ Deno.serve(async (req) => { ); } - // 7) Atualiza agent_instances — reseta para 'provisioning' se estava em erro, + // 6) Atualiza agent_instances — reseta para 'provisioning' se estava em erro, // garante que o provision-agent re-execute do zero. const nextStatus = agent.status === "error" ? "provisioning" : agent.status; const { error: updErr } = await admin @@ -206,16 +220,35 @@ Deno.serve(async (req) => { if (updErr) { console.error("agent update error", updErr); + await deleteVaultSecret( + admin, + secretId, + "new secret after agent update failure", + ); return jsonResponse({ error: "Falha ao salvar dados do bot." }, 500); } - // 8) Auto-provisionamento: dispara provision-agent assincronamente para que + if (oldSecretId && oldSecretId !== secretId) { + await deleteVaultSecret( + admin, + oldSecretId, + "old telegram token after reconnect", + ); + } + + // 7) Auto-provisionamento: dispara provision-agent assincronamente para que // o container seja criado/atualizado no Railway. Fire-and-forget — o wizard // não bloqueia esperando o deploy completar. if (nextStatus === "provisioning" || agent.status === "active") { try { const provisionUrl = `${supabaseUrl}/functions/v1/provision-agent`; console.log(`auto-provision: disparando para agent ${agent.id}`); + const provisionBody: Record = { + agent_instance_id: agent.id, + }; + if (agent.status === "active") { + provisionBody.mode = "telegram_reconnect"; + } fetch(provisionUrl, { method: "POST", headers: { @@ -223,7 +256,7 @@ Deno.serve(async (req) => { Authorization: `Bearer ${serviceKey}`, "X-Internal-Secret": Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "", }, - body: JSON.stringify({ agent_instance_id: agent.id }), + body: JSON.stringify(provisionBody), }).catch((err) => console.error("auto-provision fetch error:", err)); } catch (err) { console.error("auto-provision trigger error:", err);