server/calendar-calls.ts
Rama main · réplica de lectura del repositorio original.
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,
});
}
}