Skip to content

Commit 603a6c3

Browse files
Thales-Chagasclaude
andcommitted
Bot do Telegram (substitui o robo do WhatsApp)
- Edge Function "telegram": audio/foto/texto -> Whisper+Claude -> grava com origem=telegram. Defesas: secret token do webhook, allowlist por chat_id (tabela telegram_links), rate limit, validacao de schema do JSON do modelo antes de gravar. - Migration: tabela telegram_links + origem 'telegram' em transacoes/ comprovantes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f2b2ed commit 603a6c3

3 files changed

Lines changed: 323 additions & 0 deletions

File tree

supabase/config.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,8 @@ verify_jwt = false
1010
# voltamos a exigir JWT aqui.
1111
[functions.processar]
1212
verify_jwt = false
13+
14+
# Webhook do bot do Telegram (chamado pelo Telegram, sem login Supabase).
15+
# A própria função valida o header secret do Telegram.
16+
[functions.telegram]
17+
verify_jwt = false
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// ============================================================
2+
// Thayfinance — Bot do Telegram (entrada por áudio/foto/texto)
3+
// Substitui o robô do WhatsApp (que exigia verificação de empresa).
4+
//
5+
// Defesas (ver plano de segurança):
6+
// - valida o header secret do webhook (X-Telegram-Bot-Api-Secret-Token)
7+
// - allowlist: só chat_ids cadastrados em telegram_links são atendidos
8+
// - rate limit por chat_id
9+
// - valida o JSON do modelo contra um schema rígido antes de gravar
10+
// - o modelo NUNCA decide tabela nem gera SQL; só preenche campos validados
11+
//
12+
// Segredos: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, ANTHROPIC_API_KEY,
13+
// OPENAI_API_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_SECRET
14+
// ============================================================
15+
16+
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
17+
18+
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
19+
const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
20+
const ANTHROPIC_API_KEY = Deno.env.get("ANTHROPIC_API_KEY")!;
21+
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY")!;
22+
const BOT_TOKEN = Deno.env.get("TELEGRAM_BOT_TOKEN")!;
23+
const WEBHOOK_SECRET = Deno.env.get("TELEGRAM_SECRET")!;
24+
25+
const API = `https://api.telegram.org/bot${BOT_TOKEN}`;
26+
const FILE_API = `https://api.telegram.org/file/bot${BOT_TOKEN}`;
27+
const CLAUDE_MODEL = "claude-haiku-4-5-20251001";
28+
29+
const db = createClient(SUPABASE_URL, SERVICE_ROLE);
30+
31+
// rate limit simples por chat_id (na memória do isolate)
32+
const ACESSOS = new Map<number, number[]>();
33+
const LIMITE = 12; // mensagens
34+
const JANELA = 60_000; // por 60s
35+
function dentroDoLimite(chatId: number): boolean {
36+
const agora = Date.now();
37+
const lista = (ACESSOS.get(chatId) || []).filter((t) => agora - t < JANELA);
38+
lista.push(agora);
39+
ACESSOS.set(chatId, lista);
40+
return lista.length <= LIMITE;
41+
}
42+
43+
// ------------------------------------------------------------
44+
async function responder(chatId: number, texto: string) {
45+
await fetch(`${API}/sendMessage`, {
46+
method: "POST",
47+
headers: { "Content-Type": "application/json" },
48+
body: JSON.stringify({ chat_id: chatId, text: texto }),
49+
});
50+
}
51+
52+
async function baixarArquivo(fileId: string): Promise<{ bytes: Uint8Array; mime: string }> {
53+
const r = await fetch(`${API}/getFile?file_id=${fileId}`);
54+
const j = await r.json();
55+
const path = j?.result?.file_path;
56+
if (!path) throw new Error("getFile falhou");
57+
const f = await fetch(`${FILE_API}/${path}`);
58+
const bytes = new Uint8Array(await f.arrayBuffer());
59+
const mime = path.endsWith(".oga") || path.endsWith(".ogg")
60+
? "audio/ogg"
61+
: path.endsWith(".mp3") ? "audio/mpeg"
62+
: path.endsWith(".m4a") ? "audio/m4a"
63+
: path.endsWith(".png") ? "image/png"
64+
: "image/jpeg";
65+
return { bytes, mime };
66+
}
67+
68+
function bytesParaBase64(bytes: Uint8Array): string {
69+
let bin = "";
70+
const chunk = 0x8000;
71+
for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
72+
return btoa(bin);
73+
}
74+
75+
function parseJsonSeguro(s: string): any {
76+
try {
77+
return JSON.parse(s.replace(/```json/gi, "").replace(/```/g, "").trim());
78+
} catch {
79+
return {};
80+
}
81+
}
82+
83+
// ------------------------------------------------------------
84+
// Validação rígida da saída do modelo (dados NÃO confiáveis)
85+
// ------------------------------------------------------------
86+
function validarLancamento(d: any) {
87+
const valor = Number(d?.valor);
88+
if (!isFinite(valor) || valor <= 0 || valor > 1_000_000) return null;
89+
const tipo = d?.tipo === "receita" ? "receita" : "despesa";
90+
let data: string | null =
91+
typeof d?.data === "string" && /^\d{4}-\d{2}-\d{2}$/.test(d.data) ? d.data : null;
92+
if (data && isNaN(new Date(data + "T00:00:00").getTime())) data = null;
93+
if (!data) data = new Date().toISOString().slice(0, 10);
94+
const txt = (v: any, n: number) => (typeof v === "string" ? v.trim().slice(0, n) : null);
95+
return {
96+
valor: Math.round(valor * 100) / 100,
97+
tipo,
98+
data,
99+
categoria: txt(d?.categoria, 40),
100+
estabelecimento: txt(d?.estabelecimento, 120),
101+
descricao: txt(d?.descricao, 200),
102+
};
103+
}
104+
105+
// ---- IA: comprovante (visão) ----
106+
async function lerComprovante(base64: string, mime: string) {
107+
const prompt = `Você é um assistente financeiro brasileiro. Analise este comprovante e extraia os dados.
108+
Responda APENAS um JSON válido:
109+
{"valor": number, "data": "YYYY-MM-DD", "estabelecimento": string, "categoria": string, "tipo": "despesa"|"receita"}
110+
Regras:
111+
- VALOR: o TOTAL da compra ("VALOR TOTAL"/"TOTAL A PAGAR"). NUNCA o "TROCO" nem o "DINHEIRO"/"VALOR RECEBIDO".
112+
- DATA: use a DATA DE EMISSÃO ("Emissão"/"Emitido em"), formato BR DD/MM/AAAA → converta p/ YYYY-MM-DD sem inverter dia e mês.
113+
- ESTABELECIMENTO: a razão social da primeira linha do topo, nome completo (ex: "Silva e Barbosa Comercio de Alimentos LTDA").
114+
- categoria simples (ex: Mercado, Farmácia, Transporte).`;
115+
const resp = await fetch("https://api.anthropic.com/v1/messages", {
116+
method: "POST",
117+
headers: { "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "Content-Type": "application/json" },
118+
body: JSON.stringify({
119+
model: CLAUDE_MODEL,
120+
max_tokens: 400,
121+
messages: [{ role: "user", content: [
122+
{ type: "image", source: { type: "base64", media_type: mime, data: base64 } },
123+
{ type: "text", text: prompt },
124+
] }],
125+
}),
126+
});
127+
const out = await resp.json();
128+
return parseJsonSeguro(out?.content?.[0]?.text ?? "{}");
129+
}
130+
131+
// ---- IA: Whisper transcreve ----
132+
async function transcrever(bytes: Uint8Array, mime: string): Promise<string> {
133+
const ext = mime.includes("ogg") ? "ogg" : mime.includes("mpeg") ? "mp3" : "m4a";
134+
const form = new FormData();
135+
form.append("file", new Blob([bytes], { type: mime }), `audio.${ext}`);
136+
form.append("model", "whisper-1");
137+
form.append("language", "pt");
138+
const resp = await fetch("https://api.openai.com/v1/audio/transcriptions", {
139+
method: "POST",
140+
headers: { Authorization: `Bearer ${OPENAI_API_KEY}` },
141+
body: form,
142+
});
143+
const out = await resp.json();
144+
return out?.text ?? "";
145+
}
146+
147+
// ---- IA: frase falada/escrita -> lançamento ----
148+
async function fraseParaLancamento(frase: string) {
149+
const hoje = new Date().toISOString().slice(0, 10);
150+
const prompt = `Hoje é ${hoje}. A pessoa falou/escreveu sobre um gasto ou ganho. Transforme em lançamento.
151+
Frase: "${frase}"
152+
Responda APENAS um JSON válido:
153+
{"valor": number, "data": "YYYY-MM-DD", "descricao": string, "categoria": string, "tipo": "despesa"|"receita"}
154+
Regras: interprete "ontem"/"hoje"/"anteontem" em relação a ${hoje}. valor com ponto decimal. categoria simples.`;
155+
const resp = await fetch("https://api.anthropic.com/v1/messages", {
156+
method: "POST",
157+
headers: { "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "Content-Type": "application/json" },
158+
body: JSON.stringify({ model: CLAUDE_MODEL, max_tokens: 400, messages: [{ role: "user", content: prompt }] }),
159+
});
160+
const out = await resp.json();
161+
return parseJsonSeguro(out?.content?.[0]?.text ?? "{}");
162+
}
163+
164+
async function acharCategoria(userId: string, nome: string | null): Promise<string | null> {
165+
if (!nome) return null;
166+
const { data: existe } = await db.from("categorias")
167+
.select("id").eq("user_id", userId).eq("modo", "pessoal").ilike("nome", nome).maybeSingle();
168+
if (existe) return existe.id;
169+
const { data: nova } = await db.from("categorias")
170+
.insert({ user_id: userId, modo: "pessoal", nome }).select("id").single();
171+
return nova?.id ?? null;
172+
}
173+
174+
const fmtBR = (v: number) => v.toLocaleString("pt-BR", { style: "currency", currency: "BRL" });
175+
176+
async function gravarTransacao(userId: string, dados: any) {
177+
const catId = await acharCategoria(userId, dados.categoria);
178+
const { data: tx } = await db.from("transacoes").insert({
179+
user_id: userId, modo: "pessoal", tipo: dados.tipo, status: "ok",
180+
data: dados.data, valor: dados.valor,
181+
descricao: dados.descricao || dados.estabelecimento || null,
182+
origem: "telegram", categoria_id: catId,
183+
}).select("id").single();
184+
return tx?.id ?? null;
185+
}
186+
187+
// ============================================================
188+
Deno.serve(async (req) => {
189+
// Atalho de setup (temporário): registra o webhook usando os segredos
190+
if (req.method === "GET" && new URL(req.url).searchParams.get("action") === "setwebhook") {
191+
const url = `${SUPABASE_URL}/functions/v1/telegram`;
192+
const r = await fetch(`${API}/setWebhook`, {
193+
method: "POST",
194+
headers: { "Content-Type": "application/json" },
195+
body: JSON.stringify({ url, secret_token: WEBHOOK_SECRET, allowed_updates: ["message"] }),
196+
});
197+
return new Response(await r.text(), { status: 200, headers: { "content-type": "application/json" } });
198+
}
199+
200+
// 1) só aceita POST com o secret correto do webhook
201+
if (req.method !== "POST") return new Response("ok", { status: 200 });
202+
if (req.headers.get("X-Telegram-Bot-Api-Secret-Token") !== WEBHOOK_SECRET) {
203+
return new Response("forbidden", { status: 401 });
204+
}
205+
206+
let update: any;
207+
try { update = await req.json(); } catch { return new Response("ok", { status: 200 }); }
208+
209+
const processar = async () => {
210+
try {
211+
const msg = update?.message;
212+
if (!msg) return;
213+
const chatId: number = msg.chat?.id;
214+
if (!chatId) return;
215+
216+
// 2) allowlist — chat_id precisa estar cadastrado
217+
const { data: link } = await db.from("telegram_links")
218+
.select("user_id").eq("chat_id", chatId).maybeSingle();
219+
if (!link) {
220+
console.log(`chat_id nao autorizado: ${chatId} (ignorado)`);
221+
return; // silêncio: não confirma que o bot existe
222+
}
223+
const userId = link.user_id;
224+
225+
// 3) rate limit
226+
if (!dentroDoLimite(chatId)) {
227+
await responder(chatId, "Calma! Muitas mensagens seguidas. Tente de novo em um minutinho. 🙂");
228+
return;
229+
}
230+
231+
// ---- ÁUDIO / VOZ ----
232+
const voz = msg.voice || msg.audio;
233+
if (voz?.file_id) {
234+
if (voz.duration && voz.duration > 120) {
235+
await responder(chatId, "Esse áudio é longo demais (máx ~2 min). Tente um mais curtinho. 🎙️");
236+
return;
237+
}
238+
const { bytes, mime } = await baixarArquivo(voz.file_id);
239+
const frase = await transcrever(bytes, mime);
240+
if (!frase) { await responder(chatId, "Não entendi o áudio. Pode repetir? 🎙️"); return; }
241+
const dados = validarLancamento(await fraseParaLancamento(frase));
242+
if (!dados) { await responder(chatId, `Entendi: "${frase}", mas não achei um valor válido. Tente: "gastei 50 no mercado". 🙂`); return; }
243+
await gravarTransacao(userId, dados);
244+
await responder(chatId, `✅ Lançado por áudio!\n\n💸 ${fmtBR(dados.valor)}\n🏷️ ${dados.categoria || "—"}\n📝 ${dados.descricao || frase}\n📅 ${dados.data}`);
245+
return;
246+
}
247+
248+
// ---- FOTO / IMAGEM (comprovante) ----
249+
const foto = (msg.photo && msg.photo[msg.photo.length - 1]) ||
250+
(msg.document && /^image\//.test(msg.document.mime_type || "") ? msg.document : null);
251+
if (foto?.file_id) {
252+
const { bytes, mime } = await baixarArquivo(foto.file_id);
253+
const caminho = `${userId}/${crypto.randomUUID()}.jpg`;
254+
await db.storage.from("comprovantes").upload(caminho, bytes, { contentType: mime, upsert: false });
255+
const dados = validarLancamento(await lerComprovante(bytesParaBase64(bytes), mime));
256+
if (!dados) { await responder(chatId, "Não consegui ler o valor desse comprovante. 📷 Pode mandar de novo, mais nítido?"); return; }
257+
const txId = await gravarTransacao(userId, dados);
258+
await db.from("comprovantes").insert({
259+
user_id: userId, transacao_id: txId, storage_path: caminho, mime_type: mime,
260+
tamanho_bytes: bytes.length, origem: "telegram", ocr_status: "concluido",
261+
ocr_bruto: dados, extraido_valor: dados.valor, extraido_data: dados.data,
262+
extraido_estabelecimento: dados.estabelecimento, extraido_categoria: dados.categoria,
263+
});
264+
await responder(chatId, `✅ Comprovante lançado!\n\n💸 ${fmtBR(dados.valor)}\n🏷️ ${dados.categoria || "—"}\n🏪 ${dados.estabelecimento || "—"}\n📅 ${dados.data}\n\nA foto ficou guardada na sua conta.`);
265+
return;
266+
}
267+
268+
// ---- TEXTO ----
269+
if (typeof msg.text === "string" && msg.text.trim()) {
270+
if (msg.text.trim().toLowerCase() === "/start") {
271+
await responder(chatId, "Oi! 👋 Sou o assistente do Thayfinance. Me mande um *áudio*, uma *foto de comprovante* ou *escreva* o gasto (ex: \"gastei 50 no mercado\") que eu lanço pra você.");
272+
return;
273+
}
274+
const dados = validarLancamento(await fraseParaLancamento(msg.text));
275+
if (!dados) { await responder(chatId, "Não achei um valor nessa mensagem. Tente algo como: \"gastei 50 no mercado ontem\". 🙂"); return; }
276+
await gravarTransacao(userId, dados);
277+
await responder(chatId, `✅ Lançado!\n\n💸 ${fmtBR(dados.valor)}\n🏷️ ${dados.categoria || "—"}\n📝 ${dados.descricao || msg.text}\n📅 ${dados.data}`);
278+
return;
279+
}
280+
281+
await responder(chatId, "Me mande um *áudio*, uma *foto de comprovante* ou *escreva* o gasto que eu lanço. 😊");
282+
} catch (e) {
283+
console.error("erro ao processar:", e);
284+
}
285+
};
286+
287+
EdgeRuntime.waitUntil(processar());
288+
return new Response("ok", { status: 200 });
289+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- ============================================================
2+
-- Thayfinance — Telegram (allowlist chat_id -> usuário)
3+
-- Substitui a entrada por WhatsApp (que exigia verificação de empresa).
4+
-- Cole no SQL Editor do Supabase e clique em RUN. É idempotente.
5+
-- ============================================================
6+
7+
-- 1) Mapa/allowlist: só chat_ids cadastrados aqui são atendidos pelo bot.
8+
create table if not exists public.telegram_links (
9+
chat_id bigint primary key,
10+
user_id uuid not null references auth.users(id) on delete cascade,
11+
nome text,
12+
verificado_em timestamptz not null default now(),
13+
created_at timestamptz not null default now()
14+
);
15+
16+
alter table public.telegram_links enable row level security;
17+
drop policy if exists "tg_dono_all" on public.telegram_links;
18+
create policy "tg_dono_all" on public.telegram_links
19+
for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
20+
21+
-- 2) Permite origem 'telegram' nos lançamentos
22+
alter table public.transacoes drop constraint if exists transacoes_origem_check;
23+
alter table public.transacoes add constraint transacoes_origem_check
24+
check (origem in ('manual','audio','foto','whatsapp','telegram'));
25+
26+
-- 3) Permite origem 'telegram' nos comprovantes
27+
alter table public.comprovantes drop constraint if exists comprovantes_origem_check;
28+
alter table public.comprovantes add constraint comprovantes_origem_check
29+
check (origem in ('app','whatsapp','telegram'));

0 commit comments

Comments
 (0)