mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
// create-cronjob-from-agent
|
|
// Endpoint chamado pelo runtime Mika (Hermes container no Railway) quando o
|
|
// usuário pede para criar um cronjob via chat no Telegram.
|
|
//
|
|
// Fluxo:
|
|
// 1) Autentica via X-Internal-Secret (INTERNAL_FUNCTION_SECRET)
|
|
// 2) Recebe: agent_instance_id + natural_language_input (e opcionalmente
|
|
// campos já parseados: cron_expression, action_prompt, required_mcp_slugs)
|
|
// 3) Se não veio pré-parseado, chama Lovable AI Gateway (gemini-2.5-flash)
|
|
// para extrair cron + ação + MCPs (mesmo parser do wizard)
|
|
// 4) Valida cron com cron-parser, gera human_readable em pt-BR via cronstrue
|
|
// 5) Resolve user_id a partir do agent_instance_id
|
|
// 6) Insere em scheduled_jobs (service role; triggers de limite ainda valem)
|
|
// 7) Dispara sync-agent-runtime para empurrar o job ao runtime do agente
|
|
// 8) Retorna { success, job_id, name, human_readable, next_run_at }
|
|
//
|
|
// Esse endpoint substitui o uso da tool nativa `cron.create` do Hermes, que
|
|
// não entrega via Telegram nem aparece em scheduled_jobs.
|
|
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4";
|
|
import cronParser from "https://esm.sh/cron-parser@4.9.0";
|
|
import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n";
|
|
import { corsHeaders } from "../_shared/cors.ts";
|
|
import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts";
|
|
|
|
type GenericTable = {
|
|
Row: Record<string, unknown>;
|
|
Insert: Record<string, unknown>;
|
|
Update: Record<string, unknown>;
|
|
Relationships: [];
|
|
};
|
|
|
|
type GenericDatabase = {
|
|
public: {
|
|
Tables: Record<string, GenericTable>;
|
|
Views: Record<string, GenericTable>;
|
|
Functions: Record<string, { Args: Record<string, unknown>; Returns: unknown }>;
|
|
};
|
|
};
|
|
|
|
type SupabaseAdminClient = ReturnType<typeof createClient<GenericDatabase>>;
|
|
|
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? "";
|
|
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? "";
|
|
const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? "";
|
|
const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? "";
|
|
const CONTRACT_VERSION = "2026-05-28";
|
|
|
|
const MODEL = "google/gemini-2.5-flash";
|
|
|
|
const VALID_MCP_SLUGS = new Set([
|
|
"google_workspace",
|
|
"notion",
|
|
"todoist",
|
|
"calcom",
|
|
"microsoft_365",
|
|
]);
|
|
|
|
interface RequestBody {
|
|
agent_instance_id: string;
|
|
natural_language_input: string;
|
|
// Opcionais — se Mika já parseou no lado dele, evita 2ª chamada à IA
|
|
cron_expression?: string;
|
|
action_prompt?: string;
|
|
required_mcp_slugs?: string[];
|
|
name?: string;
|
|
description?: string | null;
|
|
timezone?: string;
|
|
}
|
|
|
|
function json(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function constantTimeEq(a: string, b: string): boolean {
|
|
if (a.length !== b.length) return false;
|
|
let diff = 0;
|
|
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
return diff === 0;
|
|
}
|
|
|
|
function isAuthorized(req: Request): boolean {
|
|
const received = req.headers.get("x-internal-secret") ?? "";
|
|
return (
|
|
!!INTERNAL_FUNCTION_SECRET && !!received && constantTimeEq(INTERNAL_FUNCTION_SECRET, received)
|
|
);
|
|
}
|
|
|
|
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
|
|
- MCPs: gmail/calendar/drive→google_workspace, notion→notion, todoist→todoist, cal.com→calcom, outlook/onedrive→microsoft_365
|
|
|
|
Formato (APENAS este JSON):
|
|
{"cron_expression": "0 9 * * 1", "action_description": "enviar resumo da semana", "required_mcp_slugs": ["google_workspace"]}`;
|
|
}
|
|
|
|
interface ParsedJob {
|
|
cron_expression: string;
|
|
action_description: string;
|
|
required_mcp_slugs: string[];
|
|
}
|
|
|
|
function tryParseJson(text: string): ParsedJob | null {
|
|
try {
|
|
return JSON.parse(text) as ParsedJob;
|
|
} catch (_) {
|
|
/* segue */
|
|
}
|
|
const match = text.match(/\{[\s\S]*\}/);
|
|
if (match) {
|
|
try {
|
|
return JSON.parse(match[0]) as ParsedJob;
|
|
} catch (_) {
|
|
/* falhou */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function parseWithAI(input: string, tz: string): Promise<ParsedJob | null> {
|
|
if (!LOVABLE_API_KEY) return null;
|
|
const res = 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: 400,
|
|
messages: [
|
|
{ role: "system", content: buildSystemPrompt(tz) },
|
|
{ role: "user", content: input },
|
|
],
|
|
}),
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
const content: string = data?.choices?.[0]?.message?.content ?? "";
|
|
return tryParseJson(content);
|
|
}
|
|
|
|
async function markJobRuntimeSyncError(
|
|
admin: SupabaseAdminClient,
|
|
jobId: string,
|
|
syncError: string,
|
|
): Promise<void> {
|
|
const message = syncError.slice(0, 2000);
|
|
const { error } = await admin
|
|
.from("scheduled_jobs")
|
|
.update({
|
|
status: "error",
|
|
auto_paused_reason: "Falha ao sincronizar esta automação com o runtime do agente.",
|
|
runtime_state: "error",
|
|
runtime_last_status: "error",
|
|
runtime_last_error: message,
|
|
})
|
|
.eq("id", jobId);
|
|
|
|
if (error) {
|
|
console.error("failed to mark scheduled_job runtime sync error:", error);
|
|
}
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
|
|
|
// 1) Auth: apenas X-Internal-Secret (chamada server-to-server do runtime)
|
|
if (!isAuthorized(req)) {
|
|
return json({ error: "unauthorized" }, 401);
|
|
}
|
|
|
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
return json({
|
|
success: true,
|
|
endpoint: "create-cronjob-from-agent",
|
|
contract_version: CONTRACT_VERSION,
|
|
expected_header: "X-Internal-Secret",
|
|
required_body_fields: ["agent_instance_id", "natural_language_input"],
|
|
optional_body_fields: [
|
|
"cron_expression",
|
|
"action_prompt",
|
|
"required_mcp_slugs",
|
|
"name",
|
|
"description",
|
|
"timezone",
|
|
],
|
|
});
|
|
}
|
|
|
|
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
|
|
// 2) Body
|
|
let body: RequestBody;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return json({ error: "invalid json body" }, 400);
|
|
}
|
|
|
|
if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400);
|
|
const input = (body.natural_language_input ?? "").trim();
|
|
if (input.length < 5) return json({ error: "natural_language_input too short" }, 400);
|
|
if (input.length > 1000)
|
|
return json({ error: "natural_language_input too long (max 1000)" }, 400);
|
|
|
|
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
});
|
|
|
|
// 3) Resolve user_id + timezone via agent_instance
|
|
const { data: agent, error: agentErr } = await admin
|
|
.from("agent_instances")
|
|
.select("id, user_id, agent_name")
|
|
.eq("id", body.agent_instance_id)
|
|
.maybeSingle();
|
|
if (agentErr || !agent) return json({ error: "agent_instance not found" }, 404);
|
|
|
|
let tz = (body.timezone ?? "").trim();
|
|
if (!tz) {
|
|
const { data: profile } = await admin
|
|
.from("profiles")
|
|
.select("timezone")
|
|
.eq("id", agent.user_id)
|
|
.maybeSingle();
|
|
tz = (profile?.timezone ?? "America/Sao_Paulo").trim() || "America/Sao_Paulo";
|
|
}
|
|
|
|
// 4) Parse (pré-parseado ou via IA)
|
|
let cron = (body.cron_expression ?? "").trim();
|
|
let actionPrompt = (body.action_prompt ?? "").trim();
|
|
let reqSlugs = Array.isArray(body.required_mcp_slugs)
|
|
? body.required_mcp_slugs.filter(
|
|
(s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s),
|
|
)
|
|
: [];
|
|
|
|
if (!cron || !actionPrompt) {
|
|
const parsed = await parseWithAI(input, tz);
|
|
if (!parsed) {
|
|
return json({ error: "failed to parse natural language input" }, 422);
|
|
}
|
|
cron = cron || (parsed.cron_expression ?? "").trim();
|
|
actionPrompt = actionPrompt || (parsed.action_description ?? "").trim();
|
|
if (reqSlugs.length === 0 && Array.isArray(parsed.required_mcp_slugs)) {
|
|
reqSlugs = parsed.required_mcp_slugs.filter(
|
|
(s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!cron) return json({ error: "cron_expression missing" }, 422);
|
|
if (!actionPrompt) return json({ error: "action_prompt missing" }, 422);
|
|
|
|
// 5) Valida cron + 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: `invalid cron expression: ${e instanceof Error ? e.message : "error"}` },
|
|
422,
|
|
);
|
|
}
|
|
|
|
// 6) human_readable em pt-BR
|
|
let humanReadable = cron;
|
|
try {
|
|
humanReadable = cronstrue.toString(cron, { locale: "pt_BR" });
|
|
} catch (_) {
|
|
/* fallback */
|
|
}
|
|
|
|
// 7) Nome: usa o fornecido ou deriva do input
|
|
const name = (body.name ?? "").trim() || input.slice(0, 80);
|
|
const description = body.description ?? null;
|
|
|
|
// 8) Insert (RLS bypass via service role; trigger enforce_job_limit ainda valida)
|
|
const { data: inserted, error: insertErr } = await admin
|
|
.from("scheduled_jobs")
|
|
.insert({
|
|
user_id: agent.user_id,
|
|
agent_instance_id: agent.id,
|
|
name,
|
|
description,
|
|
natural_language_input: input,
|
|
cron_expression: cron,
|
|
human_readable: humanReadable,
|
|
action_prompt: actionPrompt,
|
|
required_mcp_slugs: reqSlugs,
|
|
timezone: tz,
|
|
next_run_at: nextRunAt,
|
|
status: "active",
|
|
})
|
|
.select("*")
|
|
.single();
|
|
|
|
if (insertErr) {
|
|
const code = (insertErr as { code?: string }).code ?? "";
|
|
// Erros do trigger enforce_job_limit
|
|
if (code === "P0001") return json({ error: "no active subscription" }, 402);
|
|
if (code === "P0002") return json({ error: "job limit reached for plan" }, 403);
|
|
if (code === "P0004") return json({ error: "plan does not allow automations" }, 403);
|
|
if (code === "P0005") return json({ error: "agent is not active" }, 409);
|
|
console.error("insert scheduled_jobs failed:", insertErr);
|
|
return json({ error: "failed to create cronjob", detail: insertErr.message }, 500);
|
|
}
|
|
|
|
// 9) Push para o runtime. Se falhar, o job fica registrado como erro,
|
|
// mas não fica ativo na UI como se estivesse realmente agendado.
|
|
let syncOk = false;
|
|
let syncError: string | null = null;
|
|
try {
|
|
await syncAgentRuntimeSnapshot({
|
|
supabase: admin,
|
|
agentInstanceId: agent.id,
|
|
railwayToken: RAILWAY_API_TOKEN,
|
|
apiKey: HERMES_API_SERVER_KEY,
|
|
scope: "cronjobs",
|
|
});
|
|
syncOk = true;
|
|
} catch (e) {
|
|
syncError = e instanceof Error ? e.message : String(e);
|
|
console.error("runtime sync failed; marking job as error:", syncError);
|
|
await markJobRuntimeSyncError(admin, inserted.id, syncError);
|
|
return json(
|
|
{
|
|
success: false,
|
|
job_id: inserted.id,
|
|
name: inserted.name,
|
|
cron_expression: inserted.cron_expression,
|
|
human_readable: inserted.human_readable,
|
|
next_run_at: inserted.next_run_at,
|
|
required_mcp_slugs: inserted.required_mcp_slugs,
|
|
status: "error",
|
|
runtime_sync_ok: false,
|
|
runtime_sync_error: syncError,
|
|
},
|
|
502,
|
|
);
|
|
}
|
|
|
|
return json({
|
|
success: true,
|
|
job_id: inserted.id,
|
|
name: inserted.name,
|
|
cron_expression: inserted.cron_expression,
|
|
human_readable: inserted.human_readable,
|
|
next_run_at: inserted.next_run_at,
|
|
required_mcp_slugs: inserted.required_mcp_slugs,
|
|
status: inserted.status,
|
|
runtime_sync_ok: syncOk,
|
|
runtime_sync_error: syncError,
|
|
});
|
|
});
|