|
| 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 | +}); |
0 commit comments