mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 13:56:51 +00:00
fix: harden telegram bot reconnection
This commit is contained in:
parent
e3d9ae7857
commit
723c52bad6
3 changed files with 207 additions and 19 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 já tem railway_service_id.
|
* Fluxo de re-provisionamento: agent_instance já tem railway_service_id.
|
||||||
* Em vez de criar novo serviço (que dá erro "service already exists"),
|
* Em vez de criar novo serviço (que dá 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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue