mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Corrigiu autenticação do agent
X-Lovable-Edit-ID: edt-f0fa05d9-1883-40bd-898f-f6ec1ef7872e Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
26dab520ff
4 changed files with 656 additions and 14 deletions
|
|
@ -1,14 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
MessageCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
@ -30,16 +30,24 @@ interface Props {
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Phase = "configure" | "awaiting_start" | "captured";
|
||||||
|
|
||||||
export function BotFatherWizard({ agentName, fullName, onActivated, onSkip }: Props) {
|
export function BotFatherWizard({ agentName, fullName, onActivated, onSkip }: Props) {
|
||||||
|
const [phase, setPhase] = useState<Phase>("configure");
|
||||||
const [step1Done, setStep1Done] = useState(false);
|
const [step1Done, setStep1Done] = useState(false);
|
||||||
const [step2Done, setStep2Done] = useState(false);
|
const [step2Done, setStep2Done] = useState(false);
|
||||||
const [step3Done, setStep3Done] = useState(false);
|
const [step3Done, setStep3Done] = useState(false);
|
||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [validatedBot, setValidatedBot] = useState<{
|
||||||
|
bot_username: string;
|
||||||
|
bot_name: string;
|
||||||
|
bot_id: number;
|
||||||
|
} | null>(null);
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const suggestedUsername = useMemo(() => {
|
const suggestedUsername = useMemo(() => {
|
||||||
// base do agent_name; cai pro firstName se não der
|
|
||||||
const base = sanitizeForUsername(agentName).replace(/^mikade/, "mika");
|
const base = sanitizeForUsername(agentName).replace(/^mikade/, "mika");
|
||||||
if (base.length >= 5) {
|
if (base.length >= 5) {
|
||||||
const trimmed = base.slice(0, 28);
|
const trimmed = base.slice(0, 28);
|
||||||
|
|
@ -55,16 +63,6 @@ export function BotFatherWizard({ agentName, fullName, onActivated, onSkip }: Pr
|
||||||
setStep1Done(true);
|
setStep1Done(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToClipboard(value: string, onDone: () => void) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
toast.success("Copiado!");
|
|
||||||
onDone();
|
|
||||||
} catch {
|
|
||||||
toast.error("Não foi possível copiar.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleActivate() {
|
async function handleActivate() {
|
||||||
if (!tokenValid) return;
|
if (!tokenValid) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
@ -82,7 +80,62 @@ export function BotFatherWizard({ agentName, fullName, onActivated, onSkip }: Pr
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onActivated(data);
|
setSubmitting(false);
|
||||||
|
setValidatedBot(data);
|
||||||
|
setPhase("awaiting_start");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenMyBot() {
|
||||||
|
if (!validatedBot?.bot_username) return;
|
||||||
|
window.open(
|
||||||
|
`https://t.me/${validatedBot.bot_username}`,
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling: enquanto phase === awaiting_start, chama capture-telegram-owner a cada 2.5s
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== "awaiting_start") return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (cancelled) return;
|
||||||
|
const { data, error } = await invokeFunction<{
|
||||||
|
found: boolean;
|
||||||
|
chat_id?: number;
|
||||||
|
first_name?: string;
|
||||||
|
bot_username?: string;
|
||||||
|
}>("capture-telegram-owner", {});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (error) {
|
||||||
|
console.warn("capture-telegram-owner error", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data?.found && validatedBot) {
|
||||||
|
setPhase("captured");
|
||||||
|
setTimeout(() => onActivated(validatedBot), 1400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
pollRef.current = window.setInterval(tick, 2500);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (pollRef.current) window.clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [phase, validatedBot]);
|
||||||
|
|
||||||
|
if (phase === "awaiting_start" || phase === "captured") {
|
||||||
|
return (
|
||||||
|
<AwaitingStartPanel
|
||||||
|
botUsername={validatedBot?.bot_username ?? ""}
|
||||||
|
botName={validatedBot?.bot_name ?? agentName}
|
||||||
|
captured={phase === "captured"}
|
||||||
|
onOpenBot={handleOpenMyBot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -447,3 +500,95 @@ function ChatPreview({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AwaitingStartPanel({
|
||||||
|
botUsername,
|
||||||
|
botName,
|
||||||
|
captured,
|
||||||
|
onOpenBot,
|
||||||
|
}: {
|
||||||
|
botUsername: string;
|
||||||
|
botName: string;
|
||||||
|
captured: boolean;
|
||||||
|
onOpenBot: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 p-6 sm:p-8 text-center"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{captured ? (
|
||||||
|
<motion.div
|
||||||
|
key="captured"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||||
|
<Check className="h-8 w-8 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
Conectado! 🎉
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
Identificamos você no Telegram. Estamos finalizando a ativação
|
||||||
|
do seu agente — em alguns instantes ele começa a responder.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="awaiting"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-primary/20 flex items-center justify-center">
|
||||||
|
<MessageCircle className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
Última etapa: diga "oi" pro {botName}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-white/70">
|
||||||
|
Abra seu bot no Telegram e envie qualquer mensagem (pode
|
||||||
|
ser <span className="font-mono text-white">/start</span>).
|
||||||
|
Assim a gente sabe que é você e libera o acesso exclusivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onOpenBot}
|
||||||
|
className="w-full sm:w-auto sm:min-w-64"
|
||||||
|
>
|
||||||
|
Abrir @{botUsername}
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-white/50">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Aguardando sua primeira mensagem…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-white/10 bg-black/20 p-3 text-left">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-white/40">
|
||||||
|
Por que isso?
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/70">
|
||||||
|
Seu agente responde só pra você. Ao enviar a primeira
|
||||||
|
mensagem, capturamos seu ID do Telegram e bloqueamos o bot
|
||||||
|
para qualquer outra pessoa — segurança total.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
241
supabase/functions/admin-recover-telegram-owner/index.ts
Normal file
241
supabase/functions/admin-recover-telegram-owner/index.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
// admin-recover-telegram-owner
|
||||||
|
// Operação ADMIN one-shot para corrigir agentes provisionados ANTES do
|
||||||
|
// fluxo de captura automática. Faz:
|
||||||
|
// 1. Suspende o container Hermes (libera getUpdates do bot)
|
||||||
|
// 2. Long-polling getUpdates por até 90s buscando chat privado humano
|
||||||
|
// 3. Persiste telegram_user_chat_id em agent_instances
|
||||||
|
// 4. Atualiza Railway TELEGRAM_ALLOWED_USERS / TELEGRAM_HOME_CHANNEL
|
||||||
|
// 5. Retoma o container (resume) → o redeploy aplica as novas env vars
|
||||||
|
//
|
||||||
|
// O admin chama esta função e simultaneamente pede ao cliente para enviar
|
||||||
|
// /start no Telegram ao bot dele. A primeira mensagem privada captura o id.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { telegramApi } from "../_shared/telegram.ts";
|
||||||
|
import {
|
||||||
|
deployRailwayService,
|
||||||
|
getServiceContext,
|
||||||
|
setHermesSuspended,
|
||||||
|
upsertRailwayVariableCollection,
|
||||||
|
} from "../_shared/railway.ts";
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||||
|
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
|
||||||
|
|
||||||
|
interface TelegramUpdate {
|
||||||
|
update_id: number;
|
||||||
|
message?: {
|
||||||
|
chat?: { id: number; type?: string };
|
||||||
|
from?: { id: number; is_bot?: boolean; username?: string; first_name?: 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 });
|
||||||
|
|
||||||
|
if (!RAILWAY_API_TOKEN) {
|
||||||
|
return jsonResponse({ error: "RAILWAY_API_TOKEN não configurado" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth admin
|
||||||
|
const authHeader = req.headers.get("Authorization") ?? "";
|
||||||
|
const jwt = authHeader.replace(/^Bearer\s+/i, "");
|
||||||
|
if (!jwt) return jsonResponse({ error: "missing authorization" }, 401);
|
||||||
|
|
||||||
|
const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||||
|
global: { headers: { Authorization: `Bearer ${jwt}` } },
|
||||||
|
});
|
||||||
|
const { data: userData, error: userErr } = await userClient.auth.getUser();
|
||||||
|
if (userErr || !userData?.user) {
|
||||||
|
return jsonResponse({ error: "invalid token" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||||
|
const { data: isAdmin } = await admin.rpc("has_role", {
|
||||||
|
_user_id: userData.user.id,
|
||||||
|
_role: "admin",
|
||||||
|
});
|
||||||
|
if (!isAdmin) return jsonResponse({ error: "admin role required" }, 403);
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const agentId = (body?.agent_instance_id ?? "").toString();
|
||||||
|
if (!agentId) return jsonResponse({ error: "agent_instance_id required" }, 400);
|
||||||
|
|
||||||
|
// Carrega agent
|
||||||
|
const { data: agent } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.select(
|
||||||
|
"id, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, vps_pool_id",
|
||||||
|
)
|
||||||
|
.eq("id", agentId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!agent) return jsonResponse({ error: "agent não encontrado" }, 404);
|
||||||
|
if (!agent.railway_service_id) {
|
||||||
|
return jsonResponse({ error: "agente sem railway_service_id" }, 409);
|
||||||
|
}
|
||||||
|
if (!agent.telegram_bot_token_vault_id) {
|
||||||
|
return jsonResponse({ error: "agente sem telegram bot token" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve project/environment
|
||||||
|
let projectId: string | null = null;
|
||||||
|
let environmentId: string | null = null;
|
||||||
|
if (agent.vps_pool_id) {
|
||||||
|
const { data: pool } = await admin
|
||||||
|
.from("vps_pool")
|
||||||
|
.select("railway_project_id, railway_environment_id")
|
||||||
|
.eq("id", agent.vps_pool_id)
|
||||||
|
.maybeSingle();
|
||||||
|
projectId = pool?.railway_project_id ?? null;
|
||||||
|
environmentId = pool?.railway_environment_id ?? null;
|
||||||
|
}
|
||||||
|
if (!projectId || !environmentId) {
|
||||||
|
const ctx = await getServiceContext({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
});
|
||||||
|
projectId = projectId ?? ctx.projectId;
|
||||||
|
environmentId = environmentId ?? ctx.environmentId;
|
||||||
|
}
|
||||||
|
if (!projectId || !environmentId) {
|
||||||
|
return jsonResponse({ error: "não foi possível resolver railway project/env" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decifra token
|
||||||
|
const { data: secret } = await admin.rpc("vault_decrypt_secret", {
|
||||||
|
secret_id: agent.telegram_bot_token_vault_id,
|
||||||
|
});
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const token: string = (secret?.[0] as any)?.decrypted_secret ?? "";
|
||||||
|
if (!token) return jsonResponse({ error: "falha ao decifrar token" }, 500);
|
||||||
|
|
||||||
|
// 1) Suspende Hermes (libera getUpdates) — best effort
|
||||||
|
try {
|
||||||
|
await setHermesSuspended({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
projectId,
|
||||||
|
suspend: true,
|
||||||
|
});
|
||||||
|
console.log(`[recover] Hermes suspenso, aguardando 25s para descer`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[recover] suspendHermes falhou:", e);
|
||||||
|
}
|
||||||
|
// Espera o redeploy de suspend efetivar (Hermes para de consumir updates)
|
||||||
|
await new Promise((r) => setTimeout(r, 25_000));
|
||||||
|
|
||||||
|
// Deleta webhook e dropa pending para garantir polling fresco
|
||||||
|
await telegramApi(token, "deleteWebhook", { drop_pending_updates: false });
|
||||||
|
|
||||||
|
// 2) Long-poll getUpdates — total até ~75s
|
||||||
|
let ownerChatId: number | null = null;
|
||||||
|
let ownerFirstName: string | null = null;
|
||||||
|
let lastOffset = 0;
|
||||||
|
const deadline = Date.now() + 75_000;
|
||||||
|
|
||||||
|
while (Date.now() < deadline && !ownerChatId) {
|
||||||
|
const remainingSec = Math.max(2, Math.floor((deadline - Date.now()) / 1000));
|
||||||
|
const timeout = Math.min(20, remainingSec);
|
||||||
|
const upd = await telegramApi<TelegramUpdate[]>(token, "getUpdates", {
|
||||||
|
timeout,
|
||||||
|
offset: lastOffset,
|
||||||
|
allowed_updates: ["message"],
|
||||||
|
});
|
||||||
|
if (!upd.ok) {
|
||||||
|
console.warn("[recover] getUpdates falhou:", upd);
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const updates = upd.result ?? [];
|
||||||
|
for (const u of updates) {
|
||||||
|
if (u.update_id >= lastOffset) lastOffset = u.update_id + 1;
|
||||||
|
const m = u.message;
|
||||||
|
if (!m?.chat || !m.from || m.from.is_bot) continue;
|
||||||
|
if (m.chat.type && m.chat.type !== "private") continue;
|
||||||
|
ownerChatId = m.chat.id;
|
||||||
|
ownerFirstName = m.from.first_name ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ownerChatId) {
|
||||||
|
// Retoma Hermes mesmo sem capturar
|
||||||
|
try {
|
||||||
|
await setHermesSuspended({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
projectId,
|
||||||
|
suspend: false,
|
||||||
|
});
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return jsonResponse({
|
||||||
|
found: false,
|
||||||
|
hint: "Cliente não enviou mensagem na janela de 75s. Tente novamente.",
|
||||||
|
}, 408);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Persiste chat_id no DB
|
||||||
|
await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({
|
||||||
|
telegram_user_chat_id: ownerChatId,
|
||||||
|
telegram_first_message_received_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", agent.id);
|
||||||
|
|
||||||
|
// 4) Mensagem de confirmação ao cliente
|
||||||
|
await telegramApi(token, "sendMessage", {
|
||||||
|
chat_id: ownerChatId,
|
||||||
|
text:
|
||||||
|
`✅ Tudo certo${ownerFirstName ? `, ${ownerFirstName}` : ""}! Estou finalizando minha ativação. Em alguns instantes começo a conversar com você de verdade. ✨`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5) Atualiza Railway env vars TELEGRAM_ALLOWED_USERS / HOME_CHANNEL + retira suspend
|
||||||
|
const chatIdStr = String(ownerChatId);
|
||||||
|
await upsertRailwayVariableCollection({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
projectId,
|
||||||
|
variables: {
|
||||||
|
TELEGRAM_ALLOWED_USERS: chatIdStr,
|
||||||
|
TELEGRAM_HOME_CHANNEL: chatIdStr,
|
||||||
|
HERMES_SUSPENDED: "",
|
||||||
|
},
|
||||||
|
skipDeploys: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deployRailwayService({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Volta status para active se estava suspended
|
||||||
|
await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({ status: "active", updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", agent.id)
|
||||||
|
.in("status", ["suspended", "active", "provisioning"]);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
found: true,
|
||||||
|
chat_id: ownerChatId,
|
||||||
|
first_name: ownerFirstName,
|
||||||
|
redeployed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
247
supabase/functions/capture-telegram-owner/index.ts
Normal file
247
supabase/functions/capture-telegram-owner/index.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
// capture-telegram-owner
|
||||||
|
// Após o cliente conectar o bot (validate-telegram-bot), pedimos a ele que
|
||||||
|
// envie /start ao próprio bot. Esta função faz polling no getUpdates do
|
||||||
|
// bot do cliente para descobrir o chat_id do dono. Quando encontra:
|
||||||
|
// 1) grava telegram_user_chat_id em agent_instances
|
||||||
|
// 2) envia mensagem de confirmação ao usuário
|
||||||
|
// 3) limpa o offset (markAsRead) chamando getUpdates com offset alto
|
||||||
|
// 4) se o agente já está provisionado no Railway, atualiza as env vars
|
||||||
|
// TELEGRAM_ALLOWED_USERS / TELEGRAM_HOME_CHANNEL e dispara redeploy
|
||||||
|
//
|
||||||
|
// É chamada repetidamente pelo frontend (poll a cada 2s) até retornar
|
||||||
|
// { found: true } ou o usuário desistir.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
import { telegramApi } from "../_shared/telegram.ts";
|
||||||
|
import {
|
||||||
|
deployRailwayService,
|
||||||
|
getServiceContext,
|
||||||
|
upsertRailwayVariableCollection,
|
||||||
|
} from "../_shared/railway.ts";
|
||||||
|
|
||||||
|
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN");
|
||||||
|
|
||||||
|
interface TelegramUpdate {
|
||||||
|
update_id: number;
|
||||||
|
message?: {
|
||||||
|
chat?: { id: number; type?: string };
|
||||||
|
from?: { id: number; is_bot?: boolean; username?: string; first_name?: string };
|
||||||
|
text?: 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 admin = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
// 1) Carrega agent + token do Vault
|
||||||
|
const { data: agent, error: agentErr } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.select(
|
||||||
|
"id, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, vps_pool_id",
|
||||||
|
)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (agentErr || !agent) {
|
||||||
|
return jsonResponse({ error: "Agente não encontrado." }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Já capturado anteriormente — short-circuit
|
||||||
|
if (agent.telegram_user_chat_id) {
|
||||||
|
return jsonResponse({
|
||||||
|
found: true,
|
||||||
|
chat_id: Number(agent.telegram_user_chat_id),
|
||||||
|
bot_username: agent.telegram_bot_username,
|
||||||
|
already_captured: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.telegram_bot_token_vault_id) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: "Bot ainda não conectado. Volte e cole o token do BotFather." },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: secret } = await admin.rpc("vault_decrypt_secret", {
|
||||||
|
secret_id: agent.telegram_bot_token_vault_id,
|
||||||
|
});
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const token: string = (secret?.[0] as any)?.decrypted_secret ?? "";
|
||||||
|
if (!token) {
|
||||||
|
return jsonResponse({ error: "Falha ao decifrar token." }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Garante que o webhook está deletado (senão getUpdates falha)
|
||||||
|
await telegramApi(token, "deleteWebhook", { drop_pending_updates: false });
|
||||||
|
|
||||||
|
// 3) Faz getUpdates com timeout curto (long polling 8s) — pega TODAS as mensagens recentes
|
||||||
|
const updRes = await telegramApi<TelegramUpdate[]>(token, "getUpdates", {
|
||||||
|
timeout: 8,
|
||||||
|
allowed_updates: ["message"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updRes.ok) {
|
||||||
|
console.error("getUpdates failed", updRes);
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: updRes.description || "Falha ao consultar Telegram." },
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = updRes.result ?? [];
|
||||||
|
|
||||||
|
// 4) Procura a primeira mensagem privada de um humano
|
||||||
|
let ownerChatId: number | null = null;
|
||||||
|
let ownerUsername: string | null = null;
|
||||||
|
let ownerFirstName: string | null = null;
|
||||||
|
let highestUpdateId = 0;
|
||||||
|
|
||||||
|
for (const u of updates) {
|
||||||
|
if (u.update_id > highestUpdateId) highestUpdateId = u.update_id;
|
||||||
|
const msg = u.message;
|
||||||
|
if (!msg) continue;
|
||||||
|
const chat = msg.chat;
|
||||||
|
const from = msg.from;
|
||||||
|
if (!chat || !from || from.is_bot) continue;
|
||||||
|
// Só aceita chat privado (chat.id === from.id em DMs)
|
||||||
|
if (chat.type && chat.type !== "private") continue;
|
||||||
|
|
||||||
|
ownerChatId = chat.id;
|
||||||
|
ownerUsername = from.username ?? null;
|
||||||
|
ownerFirstName = from.first_name ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Avança o offset para "consumir" os updates lidos (mesmo se não achou,
|
||||||
|
// limpa lixo antigo). Usar offset = highest+1.
|
||||||
|
if (highestUpdateId > 0) {
|
||||||
|
await telegramApi(token, "getUpdates", {
|
||||||
|
offset: highestUpdateId + 1,
|
||||||
|
timeout: 0,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ownerChatId) {
|
||||||
|
return jsonResponse({
|
||||||
|
found: false,
|
||||||
|
bot_username: agent.telegram_bot_username,
|
||||||
|
hint: "Envie /start no Telegram ao seu bot para identificá-lo.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Persiste chat_id
|
||||||
|
await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({
|
||||||
|
telegram_user_chat_id: ownerChatId,
|
||||||
|
telegram_first_message_received_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", agent.id);
|
||||||
|
|
||||||
|
// 7) Mensagem de confirmação imediata (é o último "ping" antes do Hermes assumir)
|
||||||
|
await telegramApi(token, "sendMessage", {
|
||||||
|
chat_id: ownerChatId,
|
||||||
|
text:
|
||||||
|
`✅ Tudo certo${ownerFirstName ? `, ${ownerFirstName}` : ""}! Seu agente está sendo ativado e em alguns instantes começa a conversar com você por aqui. ✨`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8) Se já existe serviço Railway, atualiza TELEGRAM_ALLOWED_USERS / HOME_CHANNEL e redeploy
|
||||||
|
let redeployed = false;
|
||||||
|
if (agent.railway_service_id && RAILWAY_API_TOKEN) {
|
||||||
|
try {
|
||||||
|
let projectId: string | null = null;
|
||||||
|
let environmentId: string | null = null;
|
||||||
|
if (agent.vps_pool_id) {
|
||||||
|
const { data: pool } = await admin
|
||||||
|
.from("vps_pool")
|
||||||
|
.select("railway_project_id, railway_environment_id")
|
||||||
|
.eq("id", agent.vps_pool_id)
|
||||||
|
.maybeSingle();
|
||||||
|
projectId = pool?.railway_project_id ?? null;
|
||||||
|
environmentId = pool?.railway_environment_id ?? null;
|
||||||
|
}
|
||||||
|
if (!projectId || !environmentId) {
|
||||||
|
const ctx = await getServiceContext({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
});
|
||||||
|
projectId = projectId ?? ctx.projectId;
|
||||||
|
environmentId = environmentId ?? ctx.environmentId;
|
||||||
|
}
|
||||||
|
if (projectId && environmentId) {
|
||||||
|
const chatIdStr = String(ownerChatId);
|
||||||
|
await upsertRailwayVariableCollection({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
projectId,
|
||||||
|
variables: {
|
||||||
|
TELEGRAM_ALLOWED_USERS: chatIdStr,
|
||||||
|
TELEGRAM_HOME_CHANNEL: chatIdStr,
|
||||||
|
},
|
||||||
|
skipDeploys: true,
|
||||||
|
});
|
||||||
|
await deployRailwayService({
|
||||||
|
token: RAILWAY_API_TOKEN,
|
||||||
|
serviceId: agent.railway_service_id,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
|
redeployed = true;
|
||||||
|
console.log(
|
||||||
|
`[capture-telegram-owner] Railway redeploy disparado para serviço ${agent.railway_service_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"[capture-telegram-owner] falha ao atualizar Railway:",
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
found: true,
|
||||||
|
chat_id: ownerChatId,
|
||||||
|
username: ownerUsername,
|
||||||
|
first_name: ownerFirstName,
|
||||||
|
bot_username: agent.telegram_bot_username,
|
||||||
|
redeployed,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("capture-telegram-owner fatal", err);
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: err instanceof Error ? err.message : "Erro inesperado" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -480,6 +480,15 @@ async function handleUpdateExistingService(
|
||||||
HERMES_TTS_PROVIDER: ttsProvider,
|
HERMES_TTS_PROVIDER: ttsProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
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 {
|
try {
|
||||||
await upsertRailwayVariableCollection({
|
await upsertRailwayVariableCollection({
|
||||||
token: RAILWAY_API_TOKEN!,
|
token: RAILWAY_API_TOKEN!,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue