Ctrl K
ComunidadGuías…

server/calendar-calls.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { z } from "zod";

import { writeFileAtomic } from "./atomic.ts";
import { DATA_DIR } from "./config.ts";
import { startDueTimer, type DueTimer } from "./due-timer.ts";
import { nextOccurrence } from "./routines.ts";

export type CalendarCallSchedule =
  | { type: "once"; at: number }
  | { type: "daily"; time: string; weekdays: number[] };

export type CalendarCallAttachmentKind = "file" | "image";

export interface CalendarCallAttachment {
  id: string;
  name: string;
  /** Stored as opaque event-reference metadata; this manager never opens the path. */
  path: string;
  size: number;
  kind: CalendarCallAttachmentKind;
}

export interface CalendarCall {
  id: string;
  name: string;
  description: string;
  botIds: string[];
  schedule: CalendarCallSchedule;
  durationMinutes: number;
  attachments: CalendarCallAttachment[];
  roomId?: string;
  nextRunAt: number | null;
  createdAt: number;
  updatedAt: number;
}

export interface CalendarCallInput {
  name: string;
  description?: string;
  botIds: string[];
  schedule: CalendarCallSchedule;
  durationMinutes?: number;
  attachments?: CalendarCallAttachment[];
}

export type CalendarCallPatch = Partial<CalendarCallInput>;

export interface CalendarCallManagerOptions {
  file?: string;
  now?: () => number;
  botExists: (botId: string) => boolean;
  onDue?: (call: CalendarCall, scheduledFor: number) => void | Promise<void>;
}

interface CalendarCallFile {
  version: 1;
  calls: CalendarCall[];
}

const MAX_BOTS = 100;
const MAX_ATTACHMENTS = 50;

const scheduleSchema = z.discriminatedUnion("type", [
  z.object({ type: z.literal("once"), at: z.number().finite() }),
  z.object({
    type: z.literal("daily"),
    time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
    weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
  }),
]);
const attachmentSchema = z.object({
  id: z.string().trim().min(1).max(200),
  name: z.string().trim().min(1).max(255),
  path: z.string().trim().min(1).max(4_096),
  size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
  kind: z.enum(["file", "image"]),
});
const callSchema = z.object({
  id: z.string().min(1),
  name: z.string(),
  description: z.string(),
  botIds: z.array(z.string().min(1)).min(1).max(MAX_BOTS),
  schedule: scheduleSchema,
  durationMinutes: z.number().int().min(5).max(240),
  attachments: z.array(attachmentSchema).max(MAX_ATTACHMENTS),
  roomId: z.string().min(1).optional(),
  nextRunAt: z.number().finite().nonnegative().nullable().optional(),
  createdAt: z.number().finite().nonnegative(),
  updatedAt: z.number().finite().nonnegative(),
});
const fileSchema = z.object({
  version: z.literal(1),
  calls: z.array(z.unknown()),
});

function cloneSchedule(schedule: CalendarCallSchedule): CalendarCallSchedule {
  return schedule.type === "once"
    ? { type: "once", at: schedule.at }
    : { type: "daily", time: schedule.time, weekdays: [...schedule.weekdays] };
}

function sameRoster(left: readonly string[], right: readonly string[]): boolean {
  if (left.length !== right.length) return false;
  const wanted = new Set(left);
  return right.every((id) => wanted.has(id));
}

function cloneCall(call: CalendarCall): CalendarCall {
  return {
    ...call,
    botIds: [...call.botIds],
    schedule: cloneSchedule(call.schedule),
    attachments: call.attachments.map((attachment) => ({ ...attachment })),
  };
}

function cleanSchedule(value: CalendarCallSchedule): CalendarCallSchedule {
  const parsed = scheduleSchema.safeParse(value);
  if (!parsed.success) throw new Error("Choose a valid call schedule");
  if (parsed.data.type === "once") return { type: "once", at: parsed.data.at };
  const weekdays = [...new Set(parsed.data.weekdays)].sort((a, b) => a - b);
  if (!weekdays.length) throw new Error("Choose at least one day");
  return { type: "daily", time: parsed.data.time, weekdays };
}

function cleanAttachments(value: CalendarCallAttachment[] | undefined): CalendarCallAttachment[] {
  if (value == null) return [];
  if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS) {
    throw new Error(`Add no more than ${MAX_ATTACHMENTS} attachments`);
  }
  const ids = new Set<string>();
  return value.map((candidate) => {
    const parsed = attachmentSchema.safeParse(candidate);
    if (!parsed.success || parsed.data.name.includes("\0") || parsed.data.path.includes("\0")) {
      throw new Error("Choose a valid attachment");
    }
    if (ids.has(parsed.data.id)) throw new Error("Each attachment must be unique");
    ids.add(parsed.data.id);
    return { ...parsed.data };
  });
}

function cleanInput(
  input: CalendarCallInput,
  botExists: CalendarCallManagerOptions["botExists"],
): Omit<CalendarCall, "id" | "roomId" | "nextRunAt" | "createdAt" | "updatedAt"> {
  const name = String(input.name ?? "").trim().slice(0, 120);
  const description = String(input.description ?? "").trim().slice(0, 20_000);
  if (!name) throw new Error("Give the call a title");

  if (!Array.isArray(input.botIds)) throw new Error("Add at least one bot");
  const botIds = [...new Set(input.botIds.map((id) => String(id ?? "").trim()).filter(Boolean))];
  if (!botIds.length) throw new Error("Add at least one bot");
  if (botIds.length > MAX_BOTS) throw new Error(`Add no more than ${MAX_BOTS} bots`);
  if (botIds.some((botId) => !botExists(botId))) throw new Error("One or more bots no longer exist");

  const duration = input.durationMinutes ?? 30;
  if (!Number.isInteger(duration) || duration < 5 || duration > 240) {
    throw new Error("Call duration must be between 5 and 240 minutes");
  }

  return {
    name,
    description,
    botIds,
    schedule: cleanSchedule(input.schedule),
    durationMinutes: duration,
    attachments: cleanAttachments(input.attachments),
  };
}

export class CalendarCallManager {
  private readonly file: string;
  private readonly now: () => number;
  private readonly botExists: CalendarCallManagerOptions["botExists"];
  private readonly onDue: CalendarCallManagerOptions["onDue"];
  private calls: CalendarCall[] = [];
  private timer: DueTimer | null = null;
  private ticking = false;

  constructor(options: CalendarCallManagerOptions) {
    this.file = options.file ?? join(DATA_DIR, "calendar-calls.json");
    this.now = options.now ?? Date.now;
    this.botExists = options.botExists;
    this.onDue = options.onDue;
    let migrated = false;
    try {
      const parsed = fileSchema.safeParse(JSON.parse(readFileSync(this.file, "utf8")));
      this.calls = parsed.success
        ? parsed.data.calls.flatMap((candidate) => {
            const call = callSchema.safeParse(candidate);
            if (!call.success) return [];
            if (call.data.nextRunAt === undefined) migrated = true;
            const nextRunAt = call.data.nextRunAt === undefined
              ? this.loadedOccurrence(call.data.schedule)
              : call.data.nextRunAt;
            return [cloneCall({ ...call.data, nextRunAt })];
          })
        : [];
    } catch {
      this.calls = [];
    }
    if (migrated) {
      try {
        this.save();
      } catch (error) {
        console.error("calendar calls: could not persist schedule migration", error);
      }
    }
  }

  list(): CalendarCall[] {
    return this.calls.map(cloneCall);
  }

  get(id: string): CalendarCall | null {
    const call = this.calls.find((candidate) => candidate.id === id);
    return call ? cloneCall(call) : null;
  }

  create(input: CalendarCallInput): CalendarCall {
    const clean = cleanInput(input, this.botExists);
    const timestamp = this.now();
    const call: CalendarCall = {
      id: randomUUID(),
      ...clean,
      nextRunAt: this.initialOccurrence(clean.schedule, timestamp),
      createdAt: timestamp,
      updatedAt: timestamp,
    };
    this.commit(() => this.calls.push(call));
    if (this.timer) queueMicrotask(() => void this.tick());
    return cloneCall(call);
  }

  /** Another server's call under its own id. A new one is not scheduled (`nextRunAt` null) until it is switched on;
   * one already here keeps its schedule's next time. */
  adopt(record: CalendarCall): CalendarCall {
    const clean = cleanInput(record, this.botExists);
    const index = this.calls.findIndex((call) => call.id === record.id);
    const held = index < 0 ? null : this.calls[index]!;
    const call: CalendarCall = { id: record.id, ...clean, ...(record.roomId ? { roomId: record.roomId } : {}), nextRunAt: held?.nextRunAt ?? null,
      createdAt: record.createdAt, updatedAt: this.now() };
    this.commit(() => { if (held) this.calls[index] = call; else this.calls.push(call); });
    return cloneCall(call);
  }

  update(id: string, patch: CalendarCallPatch): CalendarCall {
    const index = this.calls.findIndex((call) => call.id === id);
    if (index < 0) throw new Error("Call not found");
    const previous = this.calls[index]!;
    const clean = cleanInput({
      name: patch.name ?? previous.name,
      description: patch.description ?? previous.description,
      botIds: patch.botIds ?? previous.botIds,
      schedule: patch.schedule ?? previous.schedule,
      durationMinutes: patch.durationMinutes ?? previous.durationMinutes,
      attachments: patch.attachments ?? previous.attachments,
    }, this.botExists);
    const updated: CalendarCall = {
      id: previous.id,
      ...clean,
      roomId: patch.botIds && !sameRoster(clean.botIds, previous.botIds) ? undefined : previous.roomId,
      nextRunAt: this.initialOccurrence(clean.schedule, this.now()),
      createdAt: previous.createdAt,
      updatedAt: this.now(),
    };
    this.commit(() => {
      this.calls[index] = updated;
    });
    if (this.timer) queueMicrotask(() => void this.tick());
    return cloneCall(updated);
  }

  /** Not scheduled again until it is saved again, which works out its next time. */
  switchOff(id: string): boolean {
    const call = this.calls.find((candidate) => candidate.id === id);
    if (!call || call.nextRunAt === null) return false;
    this.commit(() => {
      call.nextRunAt = null;
      call.updatedAt = this.now();
    });
    return true;
  }

  linkRoom(id: string, roomId: string): CalendarCall {
    const call = this.calls.find((candidate) => candidate.id === id);
    if (!call) throw new Error("Call not found");
    if (call.roomId === roomId) return cloneCall(call);
    this.commit(() => {
      call.roomId = roomId;
    });
    return cloneCall(call);
  }

  remove(id: string): boolean {
    const index = this.calls.findIndex((call) => call.id === id);
    if (index < 0) return false;
    this.commit(() => {
      this.calls.splice(index, 1);
    });
    return true;
  }

  removeBot(botId: string): number {
    const affected = this.calls.filter((call) => call.botIds.includes(botId)).length;
    if (!affected) return 0;
    this.commit(() => {
      this.calls = this.calls.flatMap((call) => {
        if (!call.botIds.includes(botId)) return [call];
        const botIds = call.botIds.filter((id) => id !== botId);
        return botIds.length ? [{ ...call, botIds, updatedAt: this.now() }] : [];
      });
    });
    return affected;
  }

  get isTicking(): boolean { return this.ticking; }

  start(): void {
    if (this.timer) return;
    this.timer = startDueTimer({
      run: () => this.tick(),
      next: () => this.calls.reduce<number | null>((at, call) => call.nextRunAt == null ? at : Math.min(at ?? Infinity, call.nextRunAt), null),
      now: this.now,
      onError: (error) => console.error("calendar calls: a scheduler pass failed", error),
    });
  }

  stop(): void {
    this.timer?.stop();
    this.timer = null;
  }

  async tick(): Promise<void> {
    if (this.ticking) return;
    this.ticking = true;
    try {
      const now = this.now();
      for (const call of this.calls) {
        if (call.nextRunAt == null || call.nextRunAt > now) continue;
        const scheduledFor = call.nextRunAt;
        if (now - scheduledFor < call.durationMinutes * 60_000) {
          try {
            await this.onDue?.(cloneCall(call), scheduledFor);
          } catch (error) {
            console.error(`calendar call ${call.id}: scheduled room kickoff failed`, error);
            continue;
          }
        }
        call.nextRunAt = call.schedule.type === "once"
          ? null
          : nextOccurrence(call.schedule, Math.max(now, scheduledFor));
        this.save();
      }
    } finally {
      this.ticking = false;
    }
  }

  private initialOccurrence(schedule: CalendarCallSchedule, now: number): number | null {
    return schedule.type === "once" ? schedule.at : nextOccurrence(schedule, now);
  }

  /** Recurring reminders start from the next future slot; a past one-time reminder is never replayed. */
  private loadedOccurrence(schedule: CalendarCallSchedule): number | null {
    const now = this.now();
    if (schedule.type === "once") return schedule.at > now ? schedule.at : null;
    return nextOccurrence(schedule, now);
  }

  private commit(mutate: () => void): void {
    const before = this.calls.map(cloneCall);
    try {
      mutate();
      this.save();
    } catch (error) {
      this.calls = before;
      throw error;
    }
    this.timer?.poke();
  }

  private save(): void {
    mkdirSync(dirname(this.file), { recursive: true });
    writeFileAtomic(this.file, JSON.stringify({ version: 1, calls: this.calls } satisfies CalendarCallFile, null, 2), {
      mode: 0o600,
    });
  }
}