From 97ee1930bb6f7833e01cf6260f1f44b5412295f8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:57:56 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- .../parse-cronjob-natural-language/index.ts | 194 ++++++++++++++++++ supabase/functions/test-integration/index.ts | 96 +++++++++ 2 files changed, 290 insertions(+) create mode 100644 supabase/functions/parse-cronjob-natural-language/index.ts create mode 100644 supabase/functions/test-integration/index.ts diff --git a/supabase/functions/parse-cronjob-natural-language/index.ts b/supabase/functions/parse-cronjob-natural-language/index.ts new file mode 100644 index 0000000..c4a5dc5 --- /dev/null +++ b/supabase/functions/parse-cronjob-natural-language/index.ts @@ -0,0 +1,194 @@ +// Parser de descrições de cronjobs em linguagem natural. +// Usa Lovable AI Gateway (gemini-2.5-flash) para extrair cron + ação + MCPs, +// valida com cron-parser, gera human_readable em pt-BR via cronstrue. +import { corsHeaders } from "../_shared/cors.ts"; +import { createClient } from "npm:@supabase/supabase-js@2"; +// @ts-expect-error remote esm +import cronParser from "https://esm.sh/cron-parser@4.9.0"; +// @ts-expect-error remote esm +import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n"; + +const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!; +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const MODEL = "google/gemini-2.5-flash"; + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +const VALID_MCP_SLUGS = new Set([ + "google_workspace", + "notion", + "todoist", + "calcom", + "microsoft_365", +]); + +function buildSystemPrompt(tz: string): string { + return `Você é um parser de descrições de cronjobs em português para o assistente Mika. Receba uma descrição em linguagem natural e retorne APENAS JSON válido, sem markdown, sem explicações. + +Regras: +- Timezone: ${tz} +- 'dia útil' = segunda a sexta (1-5) +- 'manhã' = 09:00, 'tarde' = 14:00, 'noite' = 19:00 (só se não especificado horário) +- 'primeiro dia do mês' = dia 1 +- 'último dia do mês' = dia 28 (com warning sobre aproximação) +- Detecte MCPs mencionados (Gmail, Calendar, Drive, Notion, Todoist, Cal.com, Outlook, OneDrive) e mapeie para slugs: gmail→google_workspace, calendar→google_workspace, drive→google_workspace, notion→notion, todoist→todoist, cal.com→calcom, outlook→microsoft_365, onedrive→microsoft_365 +- Confidence: 'high' se claro, 'medium' se fez suposições razoáveis, 'low' se vago + +Formato (APENAS este JSON): +{"cron_expression": "0 9 * * 1", "human_readable": "(gerado pelo sistema, não preencha)", "action_description": "enviar resumo da semana", "required_mcp_slugs": ["google_workspace"], "warnings": ["Interpretei 'manhã' como 09:00"], "confidence": "high"}`; +} + +interface ParsedJob { + cron_expression: string; + action_description: string; + required_mcp_slugs: string[]; + warnings: string[]; + confidence: "high" | "medium" | "low"; +} + +function tryParseJson(text: string): ParsedJob | null { + // 1) direto + try { + return JSON.parse(text) as ParsedJob; + } catch (_) { /* segue */ } + // 2) extrai bloco { ... } + const match = text.match(/\{[\s\S]*\}/); + if (match) { + try { + return JSON.parse(match[0]) as ParsedJob; + } catch (_) { /* falhou */ } + } + return null; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + // Auth + const authHeader = req.headers.get("Authorization"); + if (!authHeader) return json({ error: "Não autenticado" }, 401); + const token = authHeader.replace("Bearer ", ""); + const { data: userData, error: userErr } = await admin.auth.getUser(token); + if (userErr || !userData?.user) return json({ error: "Não autenticado" }, 401); + + let body: { natural_language_input?: string; user_timezone?: string }; + try { + body = await req.json(); + } catch { + return json({ error: "JSON inválido" }, 400); + } + + const input = (body.natural_language_input ?? "").trim(); + const tz = (body.user_timezone ?? "America/Sao_Paulo").trim() || "America/Sao_Paulo"; + + if (input.length < 5) { + return json({ error: "Descrição muito curta. Tente algo mais detalhado." }, 400); + } + if (input.length > 1000) { + return json({ error: "Descrição muito longa (máx 1000 caracteres)." }, 400); + } + + // Chama Lovable AI Gateway + let aiRes: Response; + try { + aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${LOVABLE_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 500, + messages: [ + { role: "system", content: buildSystemPrompt(tz) }, + { role: "user", content: input }, + ], + }), + }); + } catch (e) { + console.error("AI fetch error", e instanceof Error ? e.message : "unknown"); + return json({ error: "Falha ao contatar IA. Tente novamente." }, 502); + } + + if (aiRes.status === 429) { + return json({ error: "Muitas requisições. Aguarde 1 minuto." }, 429); + } + if (aiRes.status === 402) { + return json({ error: "Créditos de IA esgotados. Adicione créditos no workspace." }, 402); + } + if (!aiRes.ok) { + console.error("AI gateway error status", aiRes.status); + return json({ error: "Falha ao processar com IA." }, 502); + } + + const aiData = await aiRes.json(); + const content: string = aiData?.choices?.[0]?.message?.content ?? ""; + + const parsed = tryParseJson(content); + if (!parsed) { + return json( + { error: "Não conseguimos entender a descrição. Tente reescrever de forma mais clara." }, + 422, + ); + } + + // Sanitiza required_mcp_slugs + const reqSlugs = Array.isArray(parsed.required_mcp_slugs) + ? parsed.required_mcp_slugs.filter((s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s)) + : []; + + const warnings = Array.isArray(parsed.warnings) + ? parsed.warnings.filter((w): w is string => typeof w === "string") + : []; + + const confidence: "high" | "medium" | "low" = + parsed.confidence === "high" || parsed.confidence === "medium" || parsed.confidence === "low" + ? parsed.confidence + : "medium"; + + const cron = (parsed.cron_expression ?? "").trim(); + if (!cron) { + return json({ error: "IA não retornou expressão cron. Tente reescrever." }, 422); + } + + // Valida cron e calcula next_run_at + let nextRunAt: string | null = null; + try { + const interval = cronParser.parseExpression(cron, { tz }); + nextRunAt = interval.next().toDate().toISOString(); + } catch (e) { + return json( + { error: `Expressão cron inválida: ${e instanceof Error ? e.message : "erro"}` }, + 422, + ); + } + + // Gera human_readable em pt-BR + let humanReadable = ""; + try { + humanReadable = cronstrue.toString(cron, { locale: "pt_BR" }); + } catch (_) { + humanReadable = cron; // fallback + } + + const action = (parsed.action_description ?? "").trim(); + + return json({ + cron_expression: cron, + human_readable: humanReadable, + action_description: action, + required_mcp_slugs: reqSlugs, + warnings, + confidence, + next_run_at: nextRunAt, + }); +}); diff --git a/supabase/functions/test-integration/index.ts b/supabase/functions/test-integration/index.ts new file mode 100644 index 0000000..f811795 --- /dev/null +++ b/supabase/functions/test-integration/index.ts @@ -0,0 +1,96 @@ +// Testa uma integração OAuth fazendo uma chamada leve ao provider. +// Não consome refresh token. Atualiza status caso receba 401. +import { createClient } from "npm:@supabase/supabase-js@2"; +import { corsHeaders } from "../_shared/cors.ts"; +import { type ProviderSlug, testProviderConnection } from "../_shared/oauth-providers.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + +const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +function json(body: unknown, status = 200) { + 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 }); + + const authHeader = req.headers.get("Authorization"); + if (!authHeader) return json({ error: "Não autenticado" }, 401); + + const token = authHeader.replace("Bearer ", ""); + const { data: userData, error: userErr } = await admin.auth.getUser(token); + if (userErr || !userData?.user) return json({ error: "Não autenticado" }, 401); + const userId = userData.user.id; + + let body: { integration_id?: string }; + try { + body = await req.json(); + } catch { + return json({ error: "JSON inválido" }, 400); + } + + const { integration_id } = body; + if (!integration_id) return json({ error: "integration_id é obrigatório" }, 400); + + // Busca integração + ownership + mcp slug + const { data: integration, error: intErr } = await admin + .from("user_integrations") + .select( + "id, user_id, status, access_token_vault_id, mcp_id, available_mcps!inner(slug, supports_refresh_token)", + ) + .eq("id", integration_id) + .maybeSingle(); + + if (intErr || !integration) return json({ error: "Integração não encontrada" }, 404); + if (integration.user_id !== userId) return json({ error: "Acesso negado" }, 403); + if (!integration.access_token_vault_id) return json({ error: "Token ausente" }, 400); + + // @ts-expect-error nested + const slug = integration.available_mcps.slug as ProviderSlug; + // @ts-expect-error nested + const supportsRefresh = integration.available_mcps.supports_refresh_token as boolean; + + // Decrypt access token + const { data: secretRows, error: secretErr } = await admin.rpc("vault_decrypt_secret", { + secret_id: integration.access_token_vault_id, + }); + if (secretErr || !secretRows?.[0]?.decrypted_secret) { + return json({ error: "Falha ao recuperar token" }, 500); + } + const accessToken = secretRows[0].decrypted_secret as string; + + try { + const result = await testProviderConnection(slug, accessToken); + if (result.ok) { + // Garante status 'active' se estava em erro/expirado + if (integration.status !== "active") { + await admin + .from("user_integrations") + .update({ status: "active", error_message: null, updated_at: new Date().toISOString() }) + .eq("id", integration_id); + } + return json({ success: true, account_info: result.account ?? null }); + } + if (result.status === 401) { + const newStatus = supportsRefresh ? "expired" : "revoked"; + await admin + .from("user_integrations") + .update({ + status: newStatus, + error_message: "Token inválido. Reconecte a integração.", + updated_at: new Date().toISOString(), + }) + .eq("id", integration_id); + return json({ error: "Token inválido", status: newStatus }, 401); + } + return json({ error: `Provider retornou ${result.status}` }, 502); + } catch (e) { + console.error("test-integration error", e instanceof Error ? e.message : "unknown"); + return json({ error: "Falha ao testar conexão" }, 500); + } +});