mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
Implementou Managed Bots (exp.)
X-Lovable-Edit-ID: edt-139dd5d7-ecaa-433e-9aba-db966181da41 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
da4d0f7a61
8 changed files with 532 additions and 54 deletions
15
README.md
15
README.md
|
|
@ -26,6 +26,21 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud):
|
||||||
- `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes.
|
- `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes.
|
||||||
- `OLLAMA_API_KEY` — chave do Ollama Cloud, injetada como env var no container Hermes (imagem custom `ghcr.io/domfelipe/hermes-agent-custom:latest`).
|
- `OLLAMA_API_KEY` — chave do Ollama Cloud, injetada como env var no container Hermes (imagem custom `ghcr.io/domfelipe/hermes-agent-custom:latest`).
|
||||||
- `HERMES_API_SERVER_KEY` — token usado pelo API server interno do Hermes (`API_SERVER_KEY`). Valor atual: `HermesRailwayKey2026SecureToken123456`.
|
- `HERMES_API_SERVER_KEY` — token usado pelo API server interno do Hermes (`API_SERVER_KEY`). Valor atual: `HermesRailwayKey2026SecureToken123456`.
|
||||||
|
- `TELEGRAM_MANAGER_BOT_TOKEN` — token do bot manager (`@mika_managerbot`) usado para criar bots dos clientes em 1 toque via Bot Management Mode do BotFather.
|
||||||
|
- `TELEGRAM_MANAGER_BOT_USERNAME` — username do bot manager, padrão `mika_managerbot` (sem `@`).
|
||||||
|
|
||||||
|
### Setup do Managed Bot (one-tap onboarding)
|
||||||
|
|
||||||
|
> ⚠️ **Experimental**: o "Bot Management Mode" do BotFather (endpoint `getManagedBotToken` e deep-link `t.me/newbot/<manager>/<username>`) **não faz parte da Bot API pública oficial do Telegram**. Funcionalidade pode mudar/quebrar sem aviso. Mantemos o fluxo manual via `/newbot` no BotFather como fallback na página `/bem-vindo`.
|
||||||
|
|
||||||
|
1. **Criar o bot manager** no [@BotFather](https://t.me/BotFather): `/newbot` → nome: `Mika Manager` → username: `mika_managerbot`.
|
||||||
|
2. **Habilitar Bot Management Mode**: abrir [https://t.me/Botfather?startapp](https://t.me/Botfather?startapp) → selecionar o bot `Mika Manager` → **Bot Settings → Bot Management Mode → Enable**.
|
||||||
|
3. **Adicionar secrets** no Lovable Cloud: `TELEGRAM_MANAGER_BOT_TOKEN` (token recebido do BotFather) e `TELEGRAM_MANAGER_BOT_USERNAME=mika_managerbot`.
|
||||||
|
4. **Configurar webhook** uma vez após o deploy:
|
||||||
|
```
|
||||||
|
GET https://<SUPABASE_URL>/functions/v1/managed-bot-webhook?setup=true
|
||||||
|
```
|
||||||
|
Resposta esperada: `{ "webhook_set": "...", "telegram_response": { "ok": true } }`.
|
||||||
|
|
||||||
> **Imagem Docker custom**: o container roda `ghcr.io/domfelipe/hermes-agent-custom:latest`, que já contém o `SOUL.md` embutido. Por isso não passamos mais `HERMES_SOUL_OVERRIDE` via env var, e o Dockerfile define o `CMD` (sem `startCommand` no Railway). Secrets removidos do projeto: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HERMES_SOUL_OVERRIDE`.
|
> **Imagem Docker custom**: o container roda `ghcr.io/domfelipe/hermes-agent-custom:latest`, que já contém o `SOUL.md` embutido. Por isso não passamos mais `HERMES_SOUL_OVERRIDE` via env var, e o Dockerfile define o `CMD` (sem `startCommand` no Railway). Secrets removidos do projeto: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `HERMES_SOUL_OVERRIDE`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export interface AgentInstance {
|
||||||
telegram_onboarding_completed: boolean;
|
telegram_onboarding_completed: boolean;
|
||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
agent_name: string | null;
|
agent_name: string | null;
|
||||||
|
managed_bot_pending: boolean;
|
||||||
|
managed_bot_suggested_username: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,7 +35,7 @@ export function useAgentInstance() {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("agent_instances")
|
.from("agent_instances")
|
||||||
.select(
|
.select(
|
||||||
"id, user_id, uuid_tenant, status, telegram_bot_username, telegram_bot_token_vault_id, telegram_webhook_configured, telegram_token_invalid, telegram_first_message_received_at, telegram_connected_at, telegram_onboarding_completed, onboarding_completed, agent_name, created_at",
|
"id, user_id, uuid_tenant, status, telegram_bot_username, telegram_bot_token_vault_id, telegram_webhook_configured, telegram_token_invalid, telegram_first_message_received_at, telegram_connected_at, telegram_onboarding_completed, onboarding_completed, agent_name, managed_bot_pending, managed_bot_suggested_username, created_at",
|
||||||
)
|
)
|
||||||
.eq("user_id", user!.id)
|
.eq("user_id", user!.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export type Database = {
|
||||||
created_at: string
|
created_at: string
|
||||||
id: string
|
id: string
|
||||||
last_health_check_at: string | null
|
last_health_check_at: string | null
|
||||||
|
managed_bot_pending: boolean
|
||||||
|
managed_bot_suggested_username: string | null
|
||||||
model_config: Json
|
model_config: Json
|
||||||
onboarding_completed: boolean
|
onboarding_completed: boolean
|
||||||
provisioned_at: string | null
|
provisioned_at: string | null
|
||||||
|
|
@ -48,6 +50,8 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: string
|
id?: string
|
||||||
last_health_check_at?: string | null
|
last_health_check_at?: string | null
|
||||||
|
managed_bot_pending?: boolean
|
||||||
|
managed_bot_suggested_username?: string | null
|
||||||
model_config?: Json
|
model_config?: Json
|
||||||
onboarding_completed?: boolean
|
onboarding_completed?: boolean
|
||||||
provisioned_at?: string | null
|
provisioned_at?: string | null
|
||||||
|
|
@ -75,6 +79,8 @@ export type Database = {
|
||||||
created_at?: string
|
created_at?: string
|
||||||
id?: string
|
id?: string
|
||||||
last_health_check_at?: string | null
|
last_health_check_at?: string | null
|
||||||
|
managed_bot_pending?: boolean
|
||||||
|
managed_bot_suggested_username?: string | null
|
||||||
model_config?: Json
|
model_config?: Json
|
||||||
onboarding_completed?: boolean
|
onboarding_completed?: boolean
|
||||||
provisioned_at?: string | null
|
provisioned_at?: string | null
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
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,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
RefreshCcw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -42,6 +43,13 @@ function WelcomePage() {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
// Managed bot state
|
||||||
|
const [creatingBot, setCreatingBot] = useState(false);
|
||||||
|
const [waitingConfirm, setWaitingConfirm] = useState(false);
|
||||||
|
const [waitTimedOut, setWaitTimedOut] = useState(false);
|
||||||
|
const [previewUsername, setPreviewUsername] = useState<string | null>(null);
|
||||||
|
const waitStartedAt = useRef<number | null>(null);
|
||||||
|
|
||||||
const fullName = (profile?.full_name || "").trim();
|
const fullName = (profile?.full_name || "").trim();
|
||||||
const firstName = useMemo(
|
const firstName = useMemo(
|
||||||
() => (fullName.split(" ")[0] || "você").trim(),
|
() => (fullName.split(" ")[0] || "você").trim(),
|
||||||
|
|
@ -121,8 +129,9 @@ function WelcomePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConnectTelegram() {
|
async function handleOpenManualWizard() {
|
||||||
await markWelcomeDone();
|
await markWelcomeDone();
|
||||||
|
setWaitingConfirm(false);
|
||||||
setWizardOpen(true);
|
setWizardOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +140,84 @@ function WelcomePage() {
|
||||||
navigate({ to: "/painel", search: {} });
|
navigate({ to: "/painel", search: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sugere username localmente (apenas preview visual antes do clique)
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 3) return;
|
||||||
|
if (agent?.managed_bot_suggested_username) {
|
||||||
|
setPreviewUsername(agent.managed_bot_suggested_username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = (agentName || "mika")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]/g, "")
|
||||||
|
.substring(0, 28);
|
||||||
|
const safe = base.length >= 3 ? base : `mika${base}`;
|
||||||
|
setPreviewUsername(`${safe}bot`);
|
||||||
|
}, [step, agentName, agent?.managed_bot_suggested_username]);
|
||||||
|
|
||||||
|
async function handleCreateManagedBot() {
|
||||||
|
if (!agent) {
|
||||||
|
toast.error("Aguarde, ainda estamos preparando seu agente…");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatingBot(true);
|
||||||
|
setWaitTimedOut(false);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke<{
|
||||||
|
url: string;
|
||||||
|
suggested_username: string;
|
||||||
|
manager_username: string;
|
||||||
|
}>("create-managed-bot", {
|
||||||
|
body: {
|
||||||
|
agent_instance_id: agent.id,
|
||||||
|
agent_name: agentName.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (error || !data?.url) {
|
||||||
|
throw error ?? new Error("Resposta inválida");
|
||||||
|
}
|
||||||
|
setPreviewUsername(data.suggested_username);
|
||||||
|
window.open(data.url, "_blank", "noopener,noreferrer");
|
||||||
|
await markWelcomeDone();
|
||||||
|
waitStartedAt.current = Date.now();
|
||||||
|
setWaitingConfirm(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(
|
||||||
|
"Não foi possível iniciar a criação do bot. Tente novamente ou use o modo manual.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCreatingBot(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polling: enquanto aguardamos confirmação, useAgentInstance já refetch a cada 10s.
|
||||||
|
// Aceleramos o refetch a cada 3s e detectamos sucesso.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waitingConfirm) return;
|
||||||
|
if (!user) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["agent-instance", user.id] });
|
||||||
|
if (waitStartedAt.current && Date.now() - waitStartedAt.current > 5 * 60_000) {
|
||||||
|
setWaitingConfirm(false);
|
||||||
|
setWaitTimedOut(true);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [waitingConfirm, user, queryClient]);
|
||||||
|
|
||||||
|
// Detecta sucesso da confirmação via webhook
|
||||||
|
useEffect(() => {
|
||||||
|
if (!waitingConfirm) return;
|
||||||
|
if (agent?.telegram_onboarding_completed && !agent.managed_bot_pending) {
|
||||||
|
setWaitingConfirm(false);
|
||||||
|
toast.success("🎉 Bot criado! Seu agente está sendo ativado.");
|
||||||
|
navigate({ to: "/painel", search: {} });
|
||||||
|
}
|
||||||
|
}, [waitingConfirm, agent, navigate]);
|
||||||
|
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[oklch(0.21_0.04_265)]">
|
<div className="min-h-screen flex items-center justify-center bg-[oklch(0.21_0.04_265)]">
|
||||||
|
|
@ -282,68 +369,103 @@ function WelcomePage() {
|
||||||
className="text-center"
|
className="text-center"
|
||||||
>
|
>
|
||||||
<h2 className="text-3xl font-bold text-white">
|
<h2 className="text-3xl font-bold text-white">
|
||||||
Quase lá! Conecte seu Telegram.
|
Crie seu bot em 1 toque 🚀
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-3 text-white/70 max-w-lg mx-auto">
|
<p className="mt-3 text-white/70 max-w-lg mx-auto">
|
||||||
Para conversar com{" "}
|
Preparamos tudo para você. Basta confirmar no Telegram.
|
||||||
<span className="font-semibold text-white">
|
|
||||||
{agentName || "seu agente"}
|
|
||||||
</span>
|
|
||||||
, você precisa conectar seu Telegram. Leva menos de 2 minutos.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<motion.div
|
{/* Card de preview do bot */}
|
||||||
animate={{ scale: [1, 1.06, 1] }}
|
<div className="mx-auto mt-8 max-w-md rounded-2xl border border-white/10 bg-white/5 p-5 text-left">
|
||||||
transition={{ duration: 2.4, repeat: Infinity, ease: "easeInOut" }}
|
<div className="flex items-center gap-4">
|
||||||
className="mx-auto mt-8 mb-8 h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center"
|
<div className="h-14 w-14 rounded-full bg-primary/20 flex items-center justify-center shrink-0">
|
||||||
>
|
<TelegramIcon className="h-7 w-7 text-primary" />
|
||||||
<TelegramIcon className="h-12 w-12 text-primary" />
|
</div>
|
||||||
</motion.div>
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-base font-semibold text-white truncate">
|
||||||
|
{agentName || "Seu agente"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/60 truncate">
|
||||||
|
@{previewUsername || "carregando…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ol className="space-y-3 max-w-md mx-auto text-left">
|
{/* Estado: aguardando confirmação */}
|
||||||
<StepRow
|
{waitingConfirm && (
|
||||||
number={1}
|
<div className="mt-8 max-w-md mx-auto">
|
||||||
title="Abra o BotFather no Telegram"
|
<div className="flex items-center justify-center gap-3 text-white">
|
||||||
extra={
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
<Button
|
<span className="text-sm font-medium">
|
||||||
asChild
|
Aguardando confirmação no Telegram…
|
||||||
size="sm"
|
</span>
|
||||||
variant="outline"
|
</div>
|
||||||
className="mt-2 border-white/20 bg-white/5 text-white hover:bg-white/10 hover:text-white"
|
<p className="mt-2 text-xs text-white/50">
|
||||||
>
|
Confirme as informações na conversa com @mika_managerbot.
|
||||||
<a
|
</p>
|
||||||
href="https://t.me/BotFather?start"
|
</div>
|
||||||
target="_blank"
|
)}
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Abrir BotFather <ExternalLink className="ml-2 h-3.5 w-3.5" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StepRow
|
|
||||||
number={2}
|
|
||||||
title="Digite /newbot e siga as instruções"
|
|
||||||
hint={`Use "${agentName || "Mika"}Bot" como username sugerido`}
|
|
||||||
/>
|
|
||||||
<StepRow
|
|
||||||
number={3}
|
|
||||||
title="Cole o token aqui na próxima tela"
|
|
||||||
/>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
|
{/* Estado: timeout */}
|
||||||
|
{waitTimedOut && (
|
||||||
|
<div className="mt-8 max-w-md mx-auto rounded-lg border border-amber-500/30 bg-amber-500/10 p-4 text-left">
|
||||||
|
<p className="text-sm text-amber-100">
|
||||||
|
Não recebemos a confirmação ainda. Tente novamente ou faça
|
||||||
|
o processo manualmente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ícones explicativos — só antes de iniciar */}
|
||||||
|
{!waitingConfirm && !waitTimedOut && (
|
||||||
|
<div className="mt-8 grid grid-cols-3 gap-3 max-w-md mx-auto">
|
||||||
|
<ExplainIcon emoji="📱" label="Abre no Telegram" />
|
||||||
|
<ExplainIcon emoji="✅" label="Confirma as informações" />
|
||||||
|
<ExplainIcon emoji="🎉" label="Bot criado automaticamente" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ações */}
|
||||||
<div className="mt-10 flex flex-col items-center gap-3">
|
<div className="mt-10 flex flex-col items-center gap-3">
|
||||||
|
{!waitingConfirm && !waitTimedOut && (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="min-w-64"
|
className="min-w-64"
|
||||||
onClick={handleConnectTelegram}
|
onClick={handleCreateManagedBot}
|
||||||
|
disabled={creatingBot || !agent}
|
||||||
>
|
>
|
||||||
Conectar meu Telegram <ArrowRight className="ml-2 h-4 w-4" />
|
{creatingBot ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Criar meu bot no Telegram
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{waitTimedOut && (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="min-w-64"
|
||||||
|
onClick={handleCreateManagedBot}
|
||||||
|
disabled={creatingBot}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||||
|
Tentar novamente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenManualWizard}
|
||||||
|
className="text-sm text-white/70 hover:text-white underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Prefiro configurar manualmente
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="text-sm text-white/60 hover:text-white underline-offset-4 hover:underline"
|
className="text-xs text-white/40 hover:text-white/70 underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Fazer isso depois
|
Fazer isso depois
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -398,6 +520,15 @@ function StepRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ExplainIcon({ emoji, label }: { emoji: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-white/10 bg-white/5 p-3 text-center">
|
||||||
|
<div className="text-2xl">{emoji}</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-tight text-white/70">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Marker no componente para cumprir contrato de "marcar como visitada"
|
// Marker no componente para cumprir contrato de "marcar como visitada"
|
||||||
// (também usado pelo redirect do /painel)
|
// (também usado pelo redirect do /painel)
|
||||||
export function isWelcomeDone(): boolean {
|
export function isWelcomeDone(): boolean {
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,6 @@ verify_jwt = false
|
||||||
|
|
||||||
[functions.keep-alive-agents]
|
[functions.keep-alive-agents]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.managed-bot-webhook]
|
||||||
|
verify_jwt = false
|
||||||
|
|
|
||||||
134
supabase/functions/create-managed-bot/index.ts
Normal file
134
supabase/functions/create-managed-bot/index.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// create-managed-bot
|
||||||
|
// Recebe { agent_instance_id?, agent_name } do usuário autenticado.
|
||||||
|
// Gera username sugerido, marca o agent_instance como managed_bot_pending
|
||||||
|
// e retorna a URL de deep-link para o @mika_managerbot criar o bot em 1 toque.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
import { corsHeaders } from "../_shared/cors.ts";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUsername(name: string): string {
|
||||||
|
const base = (name || "mika")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "") // remove acentos
|
||||||
|
.replace(/[^a-z0-9]/g, "") // só letras e números
|
||||||
|
.substring(0, 28);
|
||||||
|
const safe = base.length >= 3 ? base : `mika${base}`;
|
||||||
|
return `${safe.substring(0, 28)}bot`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usernameAvailable(
|
||||||
|
admin: ReturnType<typeof createClient>,
|
||||||
|
username: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { data } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.select("id")
|
||||||
|
.or(`telegram_bot_username.eq.${username},managed_bot_suggested_username.eq.${username}`)
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
return !data;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 managerUsername =
|
||||||
|
Deno.env.get("TELEGRAM_MANAGER_BOT_USERNAME") || "mika_managerbot";
|
||||||
|
|
||||||
|
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 agentName = (body?.agent_name ?? "").toString().trim();
|
||||||
|
const explicitAgentId = (body?.agent_instance_id ?? "").toString().trim();
|
||||||
|
|
||||||
|
if (!agentName || agentName.length < 2) {
|
||||||
|
return jsonResponse({ error: "Nome do agente inválido." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
// Localiza agent_instance do usuário
|
||||||
|
const query = admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.select("id, user_id, agent_name, managed_bot_suggested_username")
|
||||||
|
.eq("user_id", userId);
|
||||||
|
const { data: agent, error: agentErr } = explicitAgentId
|
||||||
|
? await query.eq("id", explicitAgentId).maybeSingle()
|
||||||
|
: await query.maybeSingle();
|
||||||
|
|
||||||
|
if (agentErr) {
|
||||||
|
console.error("agent lookup error", agentErr);
|
||||||
|
return jsonResponse({ error: "Falha ao localizar agente." }, 500);
|
||||||
|
}
|
||||||
|
if (!agent) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: "Agente não encontrado. Aguarde o provisionamento." },
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gera username único (até 5 tentativas com sufixo numérico)
|
||||||
|
let suggestedUsername = generateUsername(agentName);
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 5 && !(await usernameAvailable(admin, suggestedUsername))) {
|
||||||
|
attempts++;
|
||||||
|
const suffix = Math.floor(Math.random() * 9000 + 1000);
|
||||||
|
const baseNoBot = suggestedUsername.replace(/bot$/, "");
|
||||||
|
suggestedUsername = `${baseNoBot.substring(0, 24)}${suffix}bot`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persiste estado pendente
|
||||||
|
const { error: updErr } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({
|
||||||
|
managed_bot_pending: true,
|
||||||
|
managed_bot_suggested_username: suggestedUsername,
|
||||||
|
agent_name: agent.agent_name || agentName,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", agent.id);
|
||||||
|
|
||||||
|
if (updErr) {
|
||||||
|
console.error("agent update error", updErr);
|
||||||
|
return jsonResponse({ error: "Falha ao iniciar criação do bot." }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://t.me/newbot/${managerUsername}/${suggestedUsername}` +
|
||||||
|
`?name=${encodeURIComponent(agentName)}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
url,
|
||||||
|
suggested_username: suggestedUsername,
|
||||||
|
manager_username: managerUsername,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("create-managed-bot fatal", err);
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: err instanceof Error ? err.message : "Erro inesperado" },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
180
supabase/functions/managed-bot-webhook/index.ts
Normal file
180
supabase/functions/managed-bot-webhook/index.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
// managed-bot-webhook
|
||||||
|
// Recebe updates do @mika_managerbot. Quando o update tem o campo "managed_bot",
|
||||||
|
// busca o token do novo bot via getManagedBotToken, salva no Vault, atualiza
|
||||||
|
// o agent_instance correspondente e dispara provisionamento automático.
|
||||||
|
//
|
||||||
|
// Setup do webhook: GET ?setup=true configura o webhook do manager bot.
|
||||||
|
//
|
||||||
|
// IMPORTANTE: Esta função usa endpoints experimentais do BotFather Bot
|
||||||
|
// Management Mode que NÃO são parte da Bot API pública oficial.
|
||||||
|
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
|
||||||
|
|
||||||
|
function ack(): Response {
|
||||||
|
// Sempre 200 — Telegram não deve retentar.
|
||||||
|
return new Response("ok", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||||
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||||
|
const managerToken = Deno.env.get("TELEGRAM_MANAGER_BOT_TOKEN");
|
||||||
|
|
||||||
|
// Setup: configura webhook
|
||||||
|
if (url.searchParams.get("setup") === "true") {
|
||||||
|
if (!managerToken) {
|
||||||
|
return json({ error: "TELEGRAM_MANAGER_BOT_TOKEN não configurado" }, 400);
|
||||||
|
}
|
||||||
|
const webhookUrl = `${supabaseUrl}/functions/v1/managed-bot-webhook`;
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.telegram.org/bot${managerToken}/setWebhook`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: webhookUrl,
|
||||||
|
allowed_updates: ["message", "managed_bot"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return json({ webhook_set: webhookUrl, telegram_response: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!managerToken) {
|
||||||
|
console.error("TELEGRAM_MANAGER_BOT_TOKEN ausente");
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
let update: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
update = await req.json();
|
||||||
|
} catch {
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Só processamos eventos de managed_bot
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const managed = (update as any).managed_bot;
|
||||||
|
if (!managed || !managed.bot) {
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = createClient(supabaseUrl, serviceKey);
|
||||||
|
|
||||||
|
const botId = managed.bot.id;
|
||||||
|
const botUsername: string = managed.bot.username;
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const tgUser = (managed as any).user ?? {};
|
||||||
|
|
||||||
|
// 1) Busca token do bot recém-criado
|
||||||
|
const tokenRes = await fetch(
|
||||||
|
`https://api.telegram.org/bot${managerToken}/getManagedBotToken`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bot_id: botId }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const tokenJson = await tokenRes.json().catch(() => ({}));
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const newBotToken: string | undefined = (tokenJson as any)?.result?.token;
|
||||||
|
if (!newBotToken) {
|
||||||
|
console.error("getManagedBotToken falhou", tokenJson);
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Localiza agent_instance pendente correspondente
|
||||||
|
const filter = tgUser?.id
|
||||||
|
? `telegram_user_chat_id.eq.${tgUser.id},managed_bot_suggested_username.eq.${botUsername}`
|
||||||
|
: `managed_bot_suggested_username.eq.${botUsername}`;
|
||||||
|
|
||||||
|
const { data: agentInstance, error: lookupErr } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.select("id, user_id")
|
||||||
|
.eq("managed_bot_pending", true)
|
||||||
|
.or(filter)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (lookupErr || !agentInstance) {
|
||||||
|
console.error(
|
||||||
|
"Nenhum agent_instance pendente encontrado para managed bot",
|
||||||
|
{ botUsername, tgUserId: tgUser?.id, lookupErr },
|
||||||
|
);
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Salva token no Vault (reutiliza vault_create_secret)
|
||||||
|
const secretName = `telegram_bot_token_${agentInstance.id}_${Math.floor(
|
||||||
|
Date.now() / 1000,
|
||||||
|
)}`;
|
||||||
|
const { data: vaultData, error: vaultErr } = await admin
|
||||||
|
.rpc("vault_create_secret", {
|
||||||
|
secret_value: newBotToken,
|
||||||
|
secret_name: secretName,
|
||||||
|
secret_description: `Managed bot token for agent ${agentInstance.id}`,
|
||||||
|
})
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (vaultErr || !vaultData) {
|
||||||
|
console.error("vault_create_secret falhou", vaultErr);
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
const secretId = (vaultData as any).secret_id ?? vaultData;
|
||||||
|
|
||||||
|
// 4) Atualiza agent_instance
|
||||||
|
const { error: updErr } = await admin
|
||||||
|
.from("agent_instances")
|
||||||
|
.update({
|
||||||
|
telegram_bot_token_vault_id: secretId,
|
||||||
|
telegram_bot_username: botUsername,
|
||||||
|
telegram_user_chat_id: tgUser?.id ? Number(tgUser.id) : null,
|
||||||
|
telegram_connected_at: new Date().toISOString(),
|
||||||
|
telegram_onboarding_completed: true,
|
||||||
|
telegram_token_invalid: false,
|
||||||
|
managed_bot_pending: false,
|
||||||
|
onboarding_completed: true,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", agentInstance.id);
|
||||||
|
|
||||||
|
if (updErr) {
|
||||||
|
console.error("agent update falhou", updErr);
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Dispara provisionamento (fire-and-forget)
|
||||||
|
fetch(`${supabaseUrl}/functions/v1/provision-agent`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${serviceKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent_instance_id: agentInstance.id,
|
||||||
|
user_id: agentInstance.user_id,
|
||||||
|
}),
|
||||||
|
}).catch((err) => console.error("Auto-provision error:", err));
|
||||||
|
|
||||||
|
return ack();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("managed-bot-webhook fatal", err);
|
||||||
|
return ack();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
ALTER TABLE public.agent_instances
|
||||||
|
ADD COLUMN IF NOT EXISTS managed_bot_pending boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS managed_bot_suggested_username text;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_instances_managed_bot_pending
|
||||||
|
ON public.agent_instances (managed_bot_pending)
|
||||||
|
WHERE managed_bot_pending = true;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue