Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 19:57:36 +00:00
parent 9738e10895
commit a34ff1b17b
6 changed files with 727 additions and 0 deletions

View file

@ -14,3 +14,15 @@ verify_jwt = true
[functions.publish-skill-version] [functions.publish-skill-version]
verify_jwt = true verify_jwt = true
[functions.validate-telegram-bot]
verify_jwt = true
[functions.configure-telegram-webhook]
verify_jwt = true
[functions.disconnect-telegram]
verify_jwt = true
[functions.telegram-webhook]
verify_jwt = false

View file

@ -0,0 +1,47 @@
// Helpers compartilhados das Edge Functions Telegram
const TELEGRAM_API = "https://api.telegram.org";
export type TelegramApiResult<T = unknown> = {
ok: boolean;
status: number;
description?: string;
error_code?: number;
result?: T;
parameters?: { retry_after?: number };
};
export async function telegramApi<T = unknown>(
token: string,
method: string,
body?: Record<string, unknown>,
): Promise<TelegramApiResult<T>> {
try {
const res = await fetch(`${TELEGRAM_API}/bot${token}/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
return {
ok: !!data.ok,
status: res.status,
description: data.description,
error_code: data.error_code,
result: data.result,
parameters: data.parameters,
};
} catch (err) {
return {
ok: false,
status: 0,
description: err instanceof Error ? err.message : String(err),
};
}
}
// Resposta padrão para o Telegram que NÃO deve gerar retry
// (tudo que vem do webhook deve responder 200, mesmo em falha interna)
export function telegramAck(): Response {
return new Response("ok", { status: 200 });
}

View file

@ -0,0 +1,124 @@
// configure-telegram-webhook
// Gera secret aleatório, configura webhook no Telegram e marca telegram_webhook_configured=true.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { telegramApi } from "../_shared/telegram.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const admin = createClient(supabaseUrl, serviceKey);
const { data: agent, error: agentErr } = await admin
.from("agent_instances")
.select("id, uuid_tenant, telegram_bot_token_vault_id, status")
.eq("user_id", userId)
.maybeSingle();
if (agentErr || !agent) {
return jsonResponse({ error: "Agente não encontrado." }, 404);
}
if (!agent.telegram_bot_token_vault_id) {
return jsonResponse(
{ error: "Conecte um bot antes de configurar o webhook." },
400,
);
}
const token = await getDecryptedSecret(admin, agent.telegram_bot_token_vault_id);
if (!token) {
return jsonResponse(
{ error: "Token corrompido. Desconecte e reconecte o bot." },
500,
);
}
const webhookSecret = randomHex(32);
const webhookUrl =
`${supabaseUrl}/functions/v1/telegram-webhook?token=${agent.uuid_tenant}`;
const setRes = await telegramApi(token, "setWebhook", {
url: webhookUrl,
allowed_updates: ["message"],
drop_pending_updates: true,
secret_token: webhookSecret,
});
if (!setRes.ok) {
console.error("setWebhook failed", setRes);
return jsonResponse(
{ error: "Falha ao configurar webhook no Telegram." },
500,
);
}
const { error: updErr } = await admin
.from("agent_instances")
.update({
telegram_webhook_secret: webhookSecret,
telegram_webhook_configured: true,
updated_at: new Date().toISOString(),
})
.eq("id", agent.id);
if (updErr) {
console.error("agent update error", updErr);
return jsonResponse({ error: "Webhook configurado, mas falha ao salvar." }, 500);
}
return jsonResponse({ success: true });
} catch (err) {
console.error("configure-telegram-webhook fatal", err);
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});

View file

@ -0,0 +1,111 @@
// disconnect-telegram
// Remove webhook no Telegram, deleta secret do Vault e limpa colunas em agent_instances.
// Preserva telegram_messages_log (auditoria) e telegram_onboarding_completed.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { telegramApi } from "../_shared/telegram.ts";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const admin = createClient(supabaseUrl, serviceKey);
const { data: agent, error: agentErr } = await admin
.from("agent_instances")
.select("id, telegram_bot_token_vault_id")
.eq("user_id", userId)
.maybeSingle();
if (agentErr || !agent) {
return jsonResponse({ error: "Agente não encontrado." }, 404);
}
// 1) Tenta deletar webhook no Telegram (silent em falha)
if (agent.telegram_bot_token_vault_id) {
const token = await getDecryptedSecret(admin, agent.telegram_bot_token_vault_id);
if (token) {
try {
await telegramApi(token, "deleteWebhook", { drop_pending_updates: true });
} catch (e) {
console.warn("deleteWebhook ignorado:", e);
}
}
// 2) Remove secret do Vault via RPC
try {
await admin.rpc("vault_delete_secret", {
secret_id: agent.telegram_bot_token_vault_id,
});
} catch (e) {
console.warn("vault_delete_secret ignorado:", e);
}
}
// 3) Limpa colunas no agent_instances
const { error: updErr } = await admin
.from("agent_instances")
.update({
telegram_bot_token_vault_id: null,
telegram_bot_username: null,
telegram_webhook_configured: false,
telegram_webhook_secret: null,
telegram_first_message_received_at: null,
telegram_connected_at: null,
telegram_token_invalid: false,
updated_at: new Date().toISOString(),
})
.eq("id", agent.id);
if (updErr) {
console.error("agent update error", updErr);
return jsonResponse({ error: "Falha ao desconectar." }, 500);
}
return jsonResponse({ success: true });
} catch (err) {
console.error("disconnect-telegram fatal", err);
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});

View file

@ -0,0 +1,212 @@
// telegram-webhook (PÚBLICA — verify_jwt = false)
// Recebe updates do Telegram, valida 3 camadas (uuid_tenant, secret, rate limit),
// registra mensagem em telegram_messages_log e responde com placeholder via sendMessage.
//
// TODO Fase 5: substituir resposta placeholder por proxy para container Hermes via SSH/API.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { telegramApi, telegramAck } from "../_shared/telegram.ts";
const RATE_LIMIT_MAX = 30;
const RATE_LIMIT_WINDOW_MS = 60_000;
function firstName(fullName: string | null | undefined): string {
if (!fullName) return "Mika";
const parts = fullName.trim().split(/\s+/);
return parts[0] || "Mika";
}
async function getDecryptedSecret(
admin: ReturnType<typeof createClient>,
secretId: string,
): Promise<string | null> {
const { data, error } = await admin
.rpc("vault_decrypt_secret", { secret_id: secretId })
.single();
if (!error && data) {
// deno-lint-ignore no-explicit-any
return (data as any).decrypted_secret ?? (data as unknown as string);
}
return null;
}
Deno.serve(async (req) => {
// Sempre responde 200 para o Telegram não retentar — ack cedo em qualquer falha.
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const admin = createClient(supabaseUrl, serviceKey);
const url = new URL(req.url);
const uuidTenant = url.searchParams.get("token");
if (!uuidTenant) return telegramAck();
const incomingSecret = req.headers.get("X-Telegram-Bot-Api-Secret-Token") ?? "";
// 1) Localiza agente + nome do usuário
const { data: agent, error: agentErr } = await admin
.from("agent_instances")
.select(
"id, user_id, status, telegram_webhook_secret, telegram_bot_token_vault_id, telegram_connected_at, profiles:profiles!agent_instances_user_id_fkey(full_name)",
)
.eq("uuid_tenant", uuidTenant)
.maybeSingle();
if (agentErr || !agent) return telegramAck();
if (agent.status === "suspended" || agent.status === "error") return telegramAck();
// 2) Valida secret_token (anti-spoofing)
if (
!agent.telegram_webhook_secret ||
incomingSecret !== agent.telegram_webhook_secret
) {
console.warn("telegram-webhook: secret mismatch", { agent_id: agent.id });
return new Response("unauthorized", { status: 401 });
}
// 3) Rate limit por agent_instance (30 req/min)
const now = new Date();
const { data: bucket } = await admin
.from("telegram_rate_limit_bucket")
.select("*")
.eq("agent_instance_id", agent.id)
.maybeSingle();
if (!bucket) {
await admin.from("telegram_rate_limit_bucket").insert({
agent_instance_id: agent.id,
request_count: 1,
window_start: now.toISOString(),
updated_at: now.toISOString(),
});
} else {
const windowStart = new Date(bucket.window_start as string);
const elapsed = now.getTime() - windowStart.getTime();
if (elapsed > RATE_LIMIT_WINDOW_MS) {
await admin
.from("telegram_rate_limit_bucket")
.update({
request_count: 1,
window_start: now.toISOString(),
updated_at: now.toISOString(),
})
.eq("agent_instance_id", agent.id);
} else {
const newCount = (bucket.request_count as number) + 1;
if (newCount > RATE_LIMIT_MAX) {
console.warn("telegram-webhook: rate limit hit", { agent_id: agent.id });
return telegramAck(); // throttle silencioso
}
await admin
.from("telegram_rate_limit_bucket")
.update({
request_count: newCount,
updated_at: now.toISOString(),
})
.eq("agent_instance_id", agent.id);
}
}
// 4) Parse do payload
const payload = await req.json().catch(() => null);
if (!payload || !payload.message) return telegramAck();
const message = payload.message;
const chatId = message.chat?.id as number | undefined;
if (!chatId) return telegramAck();
const fromId = message.from?.id as number | null | undefined;
const fromUsername = (message.from?.username as string | undefined) ?? null;
const text = (message.text as string | undefined) ?? null;
const entities = (message.entities as Array<{ type: string }> | undefined) ?? [];
let messageType: "text" | "command" | "other" = "other";
if (entities.some((e) => e.type === "bot_command")) messageType = "command";
else if (text) messageType = "text";
// 5) Detecta primeira mensagem da sessão atual
let isFirstMessage = false;
if (agent.telegram_connected_at) {
const { count } = await admin
.from("telegram_messages_log")
.select("id", { count: "exact", head: true })
.eq("agent_instance_id", agent.id)
.eq("direction", "incoming")
.gte("created_at", agent.telegram_connected_at as string);
if ((count ?? 0) === 0) {
isFirstMessage = true;
await admin
.from("agent_instances")
.update({ telegram_first_message_received_at: now.toISOString() })
.eq("id", agent.id);
}
}
// 6) Insert da mensagem incoming
await admin.from("telegram_messages_log").insert({
agent_instance_id: agent.id,
user_id: agent.user_id,
telegram_chat_id: chatId,
telegram_user_id: fromId ?? null,
telegram_username: fromUsername,
direction: "incoming",
message_text: text,
message_type: messageType,
is_first_message: isFirstMessage,
raw_payload: payload,
});
// 7) Resposta placeholder via sendMessage
// TODO Fase 5: substituir resposta placeholder por proxy para container Hermes via SSH/API.
if (!agent.telegram_bot_token_vault_id) return telegramAck();
const token = await getDecryptedSecret(admin, agent.telegram_bot_token_vault_id as string);
if (!token) return telegramAck();
// deno-lint-ignore no-explicit-any
const fullName = (agent as any).profiles?.full_name as string | null | undefined;
const replyText =
`Olá! Sou o Mika de ${firstName(fullName)}. Estou quase pronto para conversar com você de verdade — meu cérebro está sendo configurado pela DOMCO. Em breve vou responder de forma inteligente! Por enquanto, este é apenas um teste de conexão. ✨`;
let send = await telegramApi(token, "sendMessage", {
chat_id: chatId,
text: replyText,
});
if (!send.ok && send.status === 401) {
await admin
.from("agent_instances")
.update({ telegram_token_invalid: true, updated_at: new Date().toISOString() })
.eq("id", agent.id);
return telegramAck();
}
if (!send.ok && send.status === 429) {
const retryAfter = send.parameters?.retry_after ?? 1;
await new Promise((r) => setTimeout(r, Math.min(retryAfter, 5) * 1000));
send = await telegramApi(token, "sendMessage", {
chat_id: chatId,
text: replyText,
});
}
if (send.ok) {
await admin.from("telegram_messages_log").insert({
agent_instance_id: agent.id,
user_id: agent.user_id,
telegram_chat_id: chatId,
direction: "outgoing",
message_text: replyText,
message_type: "text",
is_first_message: false,
raw_payload: send.result ?? null,
});
} else {
console.error("sendMessage failed", send);
}
return telegramAck();
} catch (err) {
console.error("telegram-webhook fatal", err);
return telegramAck();
}
});

View file

@ -0,0 +1,221 @@
// validate-telegram-bot
// Recebe { token } do usuário autenticado, valida no Telegram via getMe,
// garante unicidade do bot, salva o token no Vault e atualiza agent_instances.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from "../_shared/cors.ts";
import { telegramApi } from "../_shared/telegram.ts";
interface GetMeResult {
id: number;
is_bot: boolean;
first_name: string;
username: string;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(supabaseUrl, anonKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData, error: userErr } = await userClient.auth.getUser();
if (userErr || !userData.user) {
return jsonResponse({ error: "Não autenticado" }, 401);
}
const userId = userData.user.id;
const body = await req.json().catch(() => ({}));
const token = (body?.token ?? "").toString().trim();
if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
return jsonResponse(
{ error: "Token inválido. Verifique se copiou corretamente do BotFather." },
400,
);
}
// 1) Valida no Telegram
const me = await telegramApi<GetMeResult>(token, "getMe");
if (!me.ok) {
if (me.status === 401 || me.status === 404) {
return jsonResponse(
{ error: "Token inválido. Verifique se copiou corretamente do BotFather." },
400,
);
}
if (me.status === 429) {
return jsonResponse(
{
error:
"O Telegram está limitando nossas requisições. Aguarde 1 minuto e tente novamente.",
},
429,
);
}
return jsonResponse(
{ error: me.description || "Falha ao validar token no Telegram." },
502,
);
}
const bot = me.result!;
const botUsername = bot.username;
const botName = bot.first_name;
const botId = bot.id;
// 2) Service-role client p/ banco e vault
const admin = createClient(supabaseUrl, serviceKey);
// 3) Garante que o usuário tem agent_instance
const { data: agent, error: agentErr } = await admin
.from("agent_instances")
.select("id, telegram_bot_token_vault_id, status")
.eq("user_id", userId)
.maybeSingle();
if (agentErr) {
console.error("agent lookup error", agentErr);
return jsonResponse({ error: "Falha ao localizar agente." }, 500);
}
if (!agent) {
return jsonResponse(
{ error: "Seu agente ainda não está pronto. Aguarde o provisionamento." },
409,
);
}
if (agent.status === "suspended" || agent.status === "error") {
return jsonResponse(
{ error: "Agente suspenso. Regularize sua assinatura antes de conectar o Telegram." },
403,
);
}
// 4) Verifica unicidade do bot username (em outro agent_instance)
const { data: conflict } = await admin
.from("agent_instances")
.select("id")
.eq("telegram_bot_username", botUsername)
.neq("id", agent.id)
.maybeSingle();
if (conflict) {
return jsonResponse(
{
error:
"Este bot já está conectado a outra conta Mika. Crie um novo bot no BotFather.",
},
409,
);
}
// 5) Se já houver um vault_id antigo, remove (reconexão)
if (agent.telegram_bot_token_vault_id) {
await admin.rpc("exec_sql_void", {}).catch(() => {});
// 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
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,
secret_name: secretName,
secret_description: `Telegram bot token for user ${userId}`,
})
.single();
let secretId: string | null = null;
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);
} 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.
const sqlRes = await fetch(`${supabaseUrl}/rest/v1/rpc/vault_create_secret`, {
method: "POST",
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
secret_value: token,
secret_name: secretName,
secret_description: `Telegram bot token for user ${userId}`,
}),
});
if (sqlRes.ok) {
const out = await sqlRes.json().catch(() => null);
secretId =
(Array.isArray(out) ? out[0]?.secret_id ?? out[0] : out?.secret_id ?? out) ?? null;
} else {
console.error("vault_create_secret RPC failed", await sqlRes.text());
}
}
if (!secretId) {
return jsonResponse(
{
error:
"Não foi possível salvar o token com segurança. Tente novamente em instantes.",
},
500,
);
}
// 7) Atualiza agent_instances
const { error: updErr } = await admin
.from("agent_instances")
.update({
telegram_bot_token_vault_id: secretId,
telegram_bot_username: botUsername,
telegram_connected_at: new Date().toISOString(),
telegram_token_invalid: false,
telegram_webhook_configured: false,
telegram_first_message_received_at: null,
updated_at: new Date().toISOString(),
})
.eq("id", agent.id);
if (updErr) {
console.error("agent update error", updErr);
return jsonResponse({ error: "Falha ao salvar dados do bot." }, 500);
}
return jsonResponse({
valid: true,
bot_username: botUsername,
bot_name: botName,
bot_id: botId,
});
} catch (err) {
console.error("validate-telegram-bot fatal", err);
return jsonResponse(
{ error: err instanceof Error ? err.message : "Erro inesperado" },
500,
);
}
});