src/bot/automationControl.ts

import { formatAutomationSchedule, resolveInitialNextRunAt } from "./automation.ts"; import { normalizeSkipSentinel } from "./botHelpers.ts"; import { sanitizeBotText } from "../utils.ts";

const MAX_AUTOMATIONS_PER_GUILD = 90; const MAX_AUTOMATION_LIST_ROWS = 10;

function queueAutomationCycle(runtime, { guildId = null, channelId = null, userId = null, trigger, automationId = null }: { guildId?: string | null; channelId?: string | null; userId?: string | null; trigger: string; automationId?: number | null; }) { runtime.maybeRunAutomationCycle().catch((error) => { runtime.store.logAction({ kind: "bot_error", guildId, channelId, userId, content: automation_cycle_trigger_${trigger}: ${String(error?.message || error)}.slice(0, 2000), metadata: { trigger, automationId } }); }); }

export function composeAutomationControlReply({ modelText, detailLines = [] }) { const cleanedModel = sanitizeBotText(normalizeSkipSentinel(modelText || ""), 500); const body = cleanedModel && cleanedModel !== "[SKIP]" ? cleanedModel : ""; if (!body || body === "[SKIP]") return "";

const extra = (Array.isArray(detailLines) ? detailLines : []) .map((line) => String(line || "").trim()) .filter(Boolean) .slice(0, 8); if (!extra.length) return body;

return sanitizeBotText(${body} ${extra.join(" ")}, 1700); }

export async function applyAutomationControlAction(runtime, { message, settings, automationAction }) { const operation = String(automationAction?.operation || "") .trim() .toLowerCase(); const guildId = String(message.guildId || "").trim(); if (!guildId) { return { handled: true, detailLines: [], metadata: { operation, ok: false, reason: "missing_guild_scope" } }; }

if (operation === "list") { const rows = runtime.store.listAutomations({ guildId, statuses: ["active", "paused"], limit: MAX_AUTOMATION_LIST_ROWS }); if (!rows.length) { return { handled: true, detailLines: [], metadata: { operation, ok: true, count: 0 } }; }

const detailLines = rows.map((row) => formatAutomationListLine(row));
return {
  handled: true,
  detailLines,
  metadata: {
    operation,
    ok: true,
    count: rows.length,
    automationIds: rows.map((row) => row.id)
  }
};

}

if (operation === "create") { const instruction = String(automationAction?.instruction || "").trim(); const schedule = automationAction?.schedule || null; if (!instruction || !schedule) { return { handled: true, detailLines: [], metadata: { operation, ok: false, reason: "missing_schedule_or_instruction" } }; }

const currentCount = runtime.store.countAutomations({
  guildId,
  statuses: ["active", "paused"]
});
if (currentCount >= MAX_AUTOMATIONS_PER_GUILD) {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "automation_cap_reached",
      currentCount
    }
  };
}

const requestedChannelId = String(automationAction?.targetChannelId || "").trim();
const targetChannelId = requestedChannelId || message.channelId;
if (!runtime.isChannelAllowed(settings, targetChannelId)) {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "target_channel_blocked",
      targetChannelId
    }
  };
}

const channel = runtime.client.channels.cache.get(String(targetChannelId));
if (!channel || !channel.isTextBased?.() || typeof channel.send !== "function") {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "target_channel_unavailable",
      targetChannelId
    }
  };
}

const nextRunAt = resolveInitialNextRunAt({
  schedule,
  nowMs: Date.now(),
  runImmediately: Boolean(automationAction?.runImmediately)
});
if (!nextRunAt) {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "schedule_invalid"
    }
  };
}

const title = String(automationAction?.title || "").trim() || String(instruction).slice(0, 80);
const created = runtime.store.createAutomation({
  guildId,
  channelId: String(channel.id),
  createdByUserId: message.author?.id || "unknown",
  createdByName: message.member?.displayName || message.author?.username || "unknown",
  title,
  instruction,
  schedule,
  nextRunAt
});

if (!created) {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "create_failed"
    }
  };
}

runtime.store.logAction({
  kind: "automation_created",
  guildId,
  channelId: created.channel_id,
  userId: message.author?.id || null,
  content: `${created.title}: ${created.instruction}`.slice(0, 400),
  metadata: {
    automationId: created.id,
    schedule: created.schedule,
    nextRunAt: created.next_run_at
  }
});

queueAutomationCycle(runtime, {
  guildId,
  channelId: created.channel_id,
  userId: message.author?.id || null,
  trigger: "create",
  automationId: created.id
});

return {
  handled: true,
  detailLines: [formatAutomationListLine(created)],
  metadata: {
    operation,
    ok: true,
    automationId: created.id,
    runImmediately: Boolean(automationAction?.runImmediately)
  }
};

}

if (operation === "pause" || operation === "resume" || operation === "delete") { const targetRows = resolveAutomationTargetsForControl(runtime, { guildId, channelId: message.channelId, operation, automationId: automationAction?.automationId, targetQuery: automationAction?.targetQuery }); if (!targetRows.length) { return { handled: true, detailLines: [], metadata: { operation, ok: false, reason: "no_matching_automation", targetQuery: automationAction?.targetQuery || null, automationId: automationAction?.automationId || null } }; }

const nowMs = Date.now();
const updatedRows = [];
for (const row of targetRows) {
  if (operation === "pause") {
    const paused = runtime.store.setAutomationStatus({
      automationId: row.id,
      guildId,
      status: "paused",
      nextRunAt: null
    });
    if (paused) updatedRows.push(paused);
    continue;
  }

  if (operation === "resume") {
    const nextRunAt = resolveInitialNextRunAt({
      schedule: row.schedule,
      nowMs,
      runImmediately: false
    });
    if (!nextRunAt) continue;
    const resumed = runtime.store.setAutomationStatus({
      automationId: row.id,
      guildId,
      status: "active",
      nextRunAt
    });
    if (resumed) updatedRows.push(resumed);
    continue;
  }

  const deleted = runtime.store.setAutomationStatus({
    automationId: row.id,
    guildId,
    status: "deleted",
    nextRunAt: null
  });
  if (deleted) updatedRows.push(deleted);
}

if (!updatedRows.length) {
  return {
    handled: true,
    detailLines: [],
    metadata: {
      operation,
      ok: false,
      reason: "status_update_failed",
      targetCount: targetRows.length
    }
  };
}

runtime.store.logAction({
  kind: "automation_updated",
  guildId,
  channelId: message.channelId,
  userId: message.author?.id || null,
  content: `${operation}: ${updatedRows.map((row) => `#${row.id}`).join(", ")}`.slice(0, 400),
  metadata: {
    operation,
    updatedIds: updatedRows.map((row) => row.id),
    targetQuery: automationAction?.targetQuery || null
  }
});

if (operation === "resume") {
  queueAutomationCycle(runtime, {
    guildId,
    channelId: message.channelId,
    userId: message.author?.id || null,
    trigger: "resume",
    automationId: updatedRows[0]?.id || null
  });
}

return {
  handled: true,
  detailLines: updatedRows.map((row) => formatAutomationListLine(row)),
  metadata: {
    operation,
    ok: true,
    updatedIds: updatedRows.map((row) => row.id)
  }
};

}

return false; }

export function resolveAutomationTargetsForControl( runtime, { guildId, channelId, operation, automationId = null, targetQuery = "" } ) { const statuses = operation === "pause" ? ["active"] : operation === "resume" ? ["paused"] : ["active", "paused"]; const normalizedQuery = String(targetQuery || "") .toLowerCase() .replace(/\s+/g, " ") .trim();

if (Number.isInteger(Number(automationId)) && Number(automationId) > 0) { const row = runtime.store.getAutomationById(Number(automationId), guildId); if (!row || !statuses.includes(row.status)) return []; return [row]; }

if (normalizedQuery) { const inChannel = runtime.store.findAutomationsByQuery({ guildId, channelId, query: normalizedQuery, statuses, limit: 8 }); if (inChannel.length) return inChannel;

return runtime.store.findAutomationsByQuery({
  guildId,
  query: normalizedQuery,
  statuses,
  limit: 8
});

}

const fallback = runtime.store.getMostRecentAutomations({ guildId, channelId, statuses, limit: 1 }); if (fallback.length) return fallback;

return runtime.store.getMostRecentAutomations({ guildId, statuses, limit: 1 }); }

export function formatAutomationListLine(row) { const channelLabel = row?.channel_id ? <#${row.channel_id}> : "(unknown channel)"; const scheduleLabel = formatAutomationSchedule(row?.schedule); const nextRunLabel = row?.next_run_at ? new Date(row.next_run_at).toLocaleString() : "paused"; const title = String(row?.title || "scheduled task").slice(0, 80); const status = String(row?.status || "active"); return - #${row?.id} [${status}] ${title} | ${scheduleLabel} | next: ${nextRunLabel} | ${channelLabel}; }