fix: harden telegram bot reconnection

This commit is contained in:
Felipe Domingues 2026-05-28 19:43:09 -03:00
parent e3d9ae7857
commit 723c52bad6
3 changed files with 207 additions and 19 deletions

View file

@ -21,7 +21,7 @@ import {
suggestBotUsername, suggestBotUsername,
} from "@/lib/telegram-username"; } from "@/lib/telegram-username";
const TOKEN_REGEX = /^\d+:[A-Za-z0-9_-]{35}$/; const TOKEN_REGEX = /^\d+:[A-Za-z0-9_-]+$/;
interface Props { interface Props {
agentName: string; agentName: string;

View file

@ -41,6 +41,7 @@ type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
interface RequestBody { interface RequestBody {
agent_instance_id: string; agent_instance_id: string;
mode?: "telegram_reconnect";
agent_name?: string; agent_name?: string;
soul_content?: string; soul_content?: string;
model?: 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.`; 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<void> { async function notifyAdmin(message: string): Promise<void> {
if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return;
try { try {
@ -181,6 +188,17 @@ Deno.serve(async (req) => {
return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message }); 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") { if (agent.status !== "provisioning") {
console.log(`[provision-agent] status atual=${agent.status}, abortando`); console.log(`[provision-agent] status atual=${agent.status}, abortando`);
return jsonResponse(409, { return jsonResponse(409, {
@ -512,6 +530,133 @@ async function scheduleRetry(
return false; 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<Response> {
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<string, string> = {
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 tem railway_service_id. * Fluxo de re-provisionamento: agent_instance tem railway_service_id.
* Em vez de criar novo serviço (que erro "service already exists"), * Em vez de criar novo serviço (que erro "service already exists"),
@ -604,6 +749,16 @@ async function handleUpdateExistingService(
HERMES_TTS_PROVIDER: ttsProvider, 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 // 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 // (importante para corrigir agentes que foram provisionados sem chat_id e tinham
// que pedir pairing manual). // que pedir pairing manual).

View file

@ -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<void> {
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) => { Deno.serve(async (req) => {
if (req.method === "OPTIONS") { if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders }); 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) const oldSecretId = agent.telegram_bot_token_vault_id as string | null;
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);
}
}
// 6) Cria secret no Vault via RPC SQL // 5) Cria secret novo no Vault antes de remover o antigo. Assim uma falha
const secretName = `telegram_bot_token_${userId}_${Math.floor(Date.now() / 1000)}`; // 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 const { data: vaultData, error: vaultErr } = await admin
.rpc("vault_create_secret", { .rpc("vault_create_secret", {
secret_value: token, secret_value: token,
@ -151,7 +164,8 @@ Deno.serve(async (req) => {
if (!vaultErr && vaultData) { if (!vaultErr && vaultData) {
// RPC pode retornar { secret_id } ou string // RPC pode retornar { secret_id } ou string
// deno-lint-ignore no-explicit-any // 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 { } else {
// Fallback: usa SQL direto via PostgREST (requer função vault.create_secret exposta). // 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. // 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. // garante que o provision-agent re-execute do zero.
const nextStatus = agent.status === "error" ? "provisioning" : agent.status; const nextStatus = agent.status === "error" ? "provisioning" : agent.status;
const { error: updErr } = await admin const { error: updErr } = await admin
@ -206,16 +220,35 @@ Deno.serve(async (req) => {
if (updErr) { if (updErr) {
console.error("agent update error", 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); 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 // o container seja criado/atualizado no Railway. Fire-and-forget — o wizard
// não bloqueia esperando o deploy completar. // não bloqueia esperando o deploy completar.
if (nextStatus === "provisioning" || agent.status === "active") { if (nextStatus === "provisioning" || agent.status === "active") {
try { try {
const provisionUrl = `${supabaseUrl}/functions/v1/provision-agent`; const provisionUrl = `${supabaseUrl}/functions/v1/provision-agent`;
console.log(`auto-provision: disparando para agent ${agent.id}`); console.log(`auto-provision: disparando para agent ${agent.id}`);
const provisionBody: Record<string, unknown> = {
agent_instance_id: agent.id,
};
if (agent.status === "active") {
provisionBody.mode = "telegram_reconnect";
}
fetch(provisionUrl, { fetch(provisionUrl, {
method: "POST", method: "POST",
headers: { headers: {
@ -223,7 +256,7 @@ Deno.serve(async (req) => {
Authorization: `Bearer ${serviceKey}`, Authorization: `Bearer ${serviceKey}`,
"X-Internal-Secret": Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "", "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 fetch error:", err));
} catch (err) { } catch (err) {
console.error("auto-provision trigger error:", err); console.error("auto-provision trigger error:", err);