Added Telegram notifications

X-Lovable-Edit-ID: edt-25aac978-7ef4-4c50-8773-e98f05b6352b
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-23 18:49:06 +00:00
commit 9f5b698d3a
2 changed files with 116 additions and 11 deletions

View file

@ -25,6 +25,8 @@ Já configuradas via `.env` (gerado automaticamente pelo Lovable Cloud):
- `SUPABASE_SERVICE_ROLE_KEY`
- `RAILWAY_API_TOKEN` — token da Railway Public API, usado por `provision-agent`/`suspend-agent`/`resume-agent` para criar e gerenciar containers Hermes.
- `OPENROUTER_API_KEY`**obrigatório**. Injetado em cada container Hermes provisionado para que o agente possa chamar os modelos `openrouter/google/gemma-4-*-it`. Sem isso o `provision-agent` retorna 500.
- `ADMIN_TELEGRAM_BOT_TOKEN` — token do bot de admin (ex: `@mika_test2_bot`) usado pelo `payments-webhook` para enviar notificações de novos clientes, falhas de pagamento e cancelamentos.
- `ADMIN_TELEGRAM_CHAT_ID` — chat ID do admin que recebe as notificações (ex: `179720882`).
- (opcional, Fase 5) credenciais SSH para Hermes
---

View file

@ -6,6 +6,48 @@ const supabase = createClient(
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
async function notifyAdmin(message: string) {
const adminBotToken = Deno.env.get('ADMIN_TELEGRAM_BOT_TOKEN');
const adminChatId = Deno.env.get('ADMIN_TELEGRAM_CHAT_ID');
if (!adminBotToken || !adminChatId) return;
try {
await fetch(`https://api.telegram.org/bot${adminBotToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: adminChatId,
text: message,
parse_mode: 'HTML',
}),
});
} catch (e) {
console.error('Admin notification failed:', e);
}
}
async function lookupEmailByUserId(userId: string): Promise<string | null> {
try {
const { data } = await supabase.auth.admin.getUserById(userId);
return data?.user?.email ?? null;
} catch (e) {
console.error('Failed to lookup email:', e);
return null;
}
}
async function lookupEmailByCustomerId(customerId: string, env: PaddleEnv): Promise<string | null> {
const { data } = await supabase
.from('subscriptions')
.select('user_id')
.eq('paddle_customer_id', customerId)
.eq('environment', env)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (!data?.user_id) return null;
return await lookupEmailByUserId(data.user_id);
}
Deno.serve(async (req) => {
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
@ -29,7 +71,6 @@ Deno.serve(async (req) => {
environment: env,
payload: event as any,
});
// Se já existe (unique violation), ignora silenciosamente
if (insertErr && insertErr.code !== '23505') {
console.error('Failed to record event:', insertErr);
} else if (insertErr?.code === '23505') {
@ -43,8 +84,10 @@ Deno.serve(async (req) => {
switch (event.eventType) {
case EventName.SubscriptionCreated:
await upsertSubscription((event as any).data, env, true);
break;
case EventName.SubscriptionUpdated:
await upsertSubscription((event as any).data, env);
await upsertSubscription((event as any).data, env, false);
break;
case EventName.SubscriptionCanceled:
await markCanceled((event as any).data, env);
@ -52,9 +95,25 @@ Deno.serve(async (req) => {
case EventName.TransactionCompleted:
console.log('Transaction completed:', (event as any).data.id);
break;
case EventName.TransactionPaymentFailed:
console.log('Payment failed:', (event as any).data.id);
case EventName.TransactionPaymentFailed: {
const data = (event as any).data;
console.log('Payment failed:', data.id);
const customerEmail =
data.customer?.email ||
(data.customerId ? await lookupEmailByCustomerId(data.customerId, env) : null) ||
'N/A';
try {
await notifyAdmin(
`🔴 <b>Pagamento falhou</b>\n\n` +
`📧 <b>Email:</b> ${customerEmail}\n` +
`📅 <b>Data:</b> ${new Date().toLocaleDateString('pt-BR')}\n\n` +
`O acesso do cliente será suspenso em breve.`
);
} catch (e) {
console.error('notifyAdmin (payment_failed) error:', e);
}
break;
}
default:
console.log('Unhandled event:', event.eventType);
}
@ -69,7 +128,7 @@ Deno.serve(async (req) => {
}
});
async function upsertSubscription(data: any, env: PaddleEnv) {
async function upsertSubscription(data: any, env: PaddleEnv, isCreated: boolean) {
const { id, customerId, items, status, currentBillingPeriod, scheduledChange, customData } = data;
const userId = customData?.userId;
@ -83,8 +142,6 @@ async function upsertSubscription(data: any, env: PaddleEnv) {
const productExt = item?.product?.importMeta?.externalId || item?.product?.id;
const billingCycle = item?.price?.billingCycle?.interval === 'year' ? 'yearly' : 'monthly';
// Resolve plan_id local pelo slug correspondente ao product externalId
// Mapeamento: basic_plan -> basic, starter_plan -> starter, professional_plan -> professional
const slugMap: Record<string, string> = {
basic_plan: 'basic',
starter_plan: 'starter',
@ -121,18 +178,50 @@ async function upsertSubscription(data: any, env: PaddleEnv) {
throw error;
}
// Atualiza paddle_customer_id no profile
await supabase.from('profiles').update({ paddle_customer_id: customerId }).eq('id', userId);
// Provisiona agent_instance se ainda não existir (idempotente via unique index em user_id).
// TODO Fase 5: dispatch provisioning job (criar container Docker na VPS via Coolify API)
let agentJustCreated = false;
if (status === 'active' || status === 'trialing') {
const { error: agentErr } = await supabase
.from('agent_instances')
.insert({ user_id: userId, status: 'provisioning' });
// 23505 = já existe (esperado para usuários retornando), ignora
if (agentErr && agentErr.code !== '23505') {
console.error('Failed to provision agent_instance:', agentErr);
} else if (!agentErr) {
agentJustCreated = true;
}
}
// Notifica admin no Telegram quando é uma assinatura nova / agente recém-criado
if (isCreated || agentJustCreated) {
try {
const [{ data: profile }, { data: plan }, customerEmail] = await Promise.all([
supabase.from('profiles').select('full_name, phone').eq('id', userId).single(),
planId
? supabase.from('plans').select('name, price_monthly_brl, slug').eq('id', planId).single()
: Promise.resolve({ data: null as any }),
lookupEmailByUserId(userId),
]);
const planEmoji: Record<string, string> = {
basic: '🟢',
starter: '🟡',
professional: '🔵',
enterprise: '🟣',
};
const slug = (plan as any)?.slug as string | undefined;
await notifyAdmin(
`${planEmoji[slug ?? ''] || '⚪'} <b>Novo cliente Mika!</b>\n\n` +
`👤 <b>Nome:</b> ${(profile as any)?.full_name || 'N/A'}\n` +
`📧 <b>Email:</b> ${customerEmail || 'N/A'}\n` +
`📱 <b>Telefone:</b> ${(profile as any)?.phone || 'N/A'}\n` +
`💳 <b>Plano:</b> ${(plan as any)?.name || 'N/A'} — R$ ${(plan as any)?.price_monthly_brl ?? '?'}/mês\n` +
`🤖 <b>Bot:</b> Aguardando configuração\n\n` +
`➡️ <a href="https://mika.domco.ai/admin">Provisionar agora</a>`
);
} catch (e) {
console.error('notifyAdmin (subscription_created) error:', e);
}
}
}
@ -143,4 +232,18 @@ async function markCanceled(data: any, env: PaddleEnv) {
.update({ status: 'canceled', updated_at: new Date().toISOString() })
.eq('paddle_subscription_id', data.id)
.eq('environment', env);
try {
const customerEmail =
data.customer?.email ||
(data.customerId ? await lookupEmailByCustomerId(data.customerId, env) : null) ||
'N/A';
await notifyAdmin(
`⚠️ <b>Assinatura cancelada</b>\n\n` +
`📧 <b>Email:</b> ${customerEmail}\n` +
`📅 <b>Data:</b> ${new Date().toLocaleDateString('pt-BR')}`
);
} catch (e) {
console.error('notifyAdmin (canceled) error:', e);
}
}