src/bot/automation.ts

import { clamp } from "../utils.ts";

const AUTOMATION_SCHEDULE_KINDS = new Set(["daily", "interval", "once"]);

const MAX_AUTOMATION_TITLE_LEN = 90; const MAX_AUTOMATION_INSTRUCTION_LEN = 360;

export function normalizeAutomationTitle(rawTitle, fallback = "scheduled task") { const text = String(rawTitle || "") .replace(/\s+/g, " ") .trim() .slice(0, MAX_AUTOMATION_TITLE_LEN); if (text) return text; return String(fallback || "scheduled task") .replace(/\s+/g, " ") .trim() .slice(0, MAX_AUTOMATION_TITLE_LEN); }

export function normalizeAutomationInstruction(rawInstruction) { const text = String(rawInstruction || "") .replace(/\s+/g, " ") .trim() .slice(0, MAX_AUTOMATION_INSTRUCTION_LEN); return text || ""; }

export function buildAutomationMatchText({ title = "", instruction = "" }) { return ${String(title || "").trim()} ${String(instruction || "").trim()} .toLowerCase() .replace(/\s+/g, " ") .trim() .slice(0, 800); }

export function normalizeAutomationSchedule(raw, { nowMs = Date.now(), allowPastOnce = false } = {}) { if (!raw || typeof raw !== "object") return null;

const kind = String(raw.kind || "") .trim() .toLowerCase(); if (!AUTOMATION_SCHEDULE_KINDS.has(kind)) return null;

if (kind === "daily") { const hour = clamp(Math.floor(Number(raw.hour)), 0, 23); const minute = clamp(Math.floor(Number(raw.minute ?? 0)), 0, 59); if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; return { kind, hour, minute }; }

if (kind === "interval") { const everyMinutes = clamp(Math.floor(Number(raw.everyMinutes)), 1, 7 * 24 * 60); if (!Number.isFinite(everyMinutes) || everyMinutes < 1) return null; return { kind, everyMinutes }; }

const parsedAt = Date.parse(String(raw.atIso || "").trim()); if (!Number.isFinite(parsedAt)) return null; if (!allowPastOnce && parsedAt < nowMs - 15_000) return null; return { kind: "once", atIso: new Date(parsedAt).toISOString() }; }

export function resolveInitialNextRunAt({ schedule, nowMs = Date.now(), runImmediately = false }) { if (!schedule || typeof schedule !== "object") return null; if (runImmediately) return new Date(nowMs).toISOString();

if (schedule.kind === "daily") { const nextMs = resolveNextDailyRunMs(schedule, nowMs); return Number.isFinite(nextMs) ? new Date(nextMs).toISOString() : null; }

if (schedule.kind === "interval") { const everyMs = Number(schedule.everyMinutes) * 60_000; if (!Number.isFinite(everyMs) || everyMs < 60_000) return null; return new Date(nowMs + everyMs).toISOString(); }

if (schedule.kind === "once") { const atMs = Date.parse(String(schedule.atIso || "")); if (!Number.isFinite(atMs)) return null; return new Date(atMs).toISOString(); }

return null; }

export function resolveFollowingNextRunAt({ schedule, previousNextRunAt = null, runFinishedMs = Date.now() }) { if (!schedule || typeof schedule !== "object") return null;

if (schedule.kind === "once") return null;

if (schedule.kind === "daily") { const nextMs = resolveNextDailyRunMs(schedule, runFinishedMs + 1000); return Number.isFinite(nextMs) ? new Date(nextMs).toISOString() : null; }

if (schedule.kind === "interval") { const everyMs = Number(schedule.everyMinutes) * 60_000; if (!Number.isFinite(everyMs) || everyMs < 60_000) return null;

const baseMs = Number.isFinite(Date.parse(String(previousNextRunAt || "")))
  ? Date.parse(String(previousNextRunAt || ""))
  : runFinishedMs;

let nextMs = baseMs + everyMs;
if (!Number.isFinite(nextMs)) return null;
if (nextMs <= runFinishedMs) {
  const behindMs = runFinishedMs - nextMs;
  const steps = Math.floor(behindMs / everyMs) + 1;
  nextMs += everyMs * steps;
}
return new Date(nextMs).toISOString();

}

return null; }

export function formatAutomationSchedule(schedule) { if (!schedule || typeof schedule !== "object") return "unknown schedule";

if (schedule.kind === "daily") { const hour = clamp(Math.floor(Number(schedule.hour)), 0, 23); const minute = clamp(Math.floor(Number(schedule.minute)), 0, 59); return daily at ${formatHourMinute(hour, minute)}; }

if (schedule.kind === "interval") { const everyMinutes = clamp(Math.floor(Number(schedule.everyMinutes)), 1, 7 * 24 * 60); if (everyMinutes % 60 === 0) { const hours = everyMinutes / 60; return hours === 1 ? "every 1 hour" : every ${hours} hours; } return everyMinutes === 1 ? "every 1 minute" : every ${everyMinutes} minutes; }

if (schedule.kind === "once") { const atMs = Date.parse(String(schedule.atIso || "")); if (!Number.isFinite(atMs)) return "once"; return once at ${new Date(atMs).toLocaleString()}; }

return "unknown schedule"; }

export function getLocalTimeZoneLabel() { const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; return String(zone || "local time"); }

function resolveNextDailyRunMs(schedule, referenceMs) { const hour = clamp(Math.floor(Number(schedule.hour)), 0, 23); const minute = clamp(Math.floor(Number(schedule.minute)), 0, 59); if (!Number.isFinite(hour) || !Number.isFinite(minute)) return NaN;

const reference = new Date(referenceMs); const next = new Date(referenceMs); next.setSeconds(0, 0); next.setHours(hour, minute, 0, 0);

if (next.getTime() <= reference.getTime()) { next.setDate(next.getDate() + 1); }

return next.getTime(); }

function formatHourMinute(hour, minute) { const date = new Date(); date.setHours(hour, minute, 0, 0); return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); }