From 28c0ab87b8c3452ade759ec23adfd00ffff72471 Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Thu, 28 May 2026 09:48:49 +0200 Subject: [PATCH] feat(scheduler): cron-scheduled sweep on top of on-startup sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the verifications loop into sweepAll() and adds scheduleSweep() that wires node-cron to the configured `sweep.cron` expression. The READY handler runs the startup sweep (when enabled) and then registers the cron — so the scheduler only starts ticking after the gateway is up. - src/scheduler.ts: sweepAll + scheduleSweep - src/events/ready.ts: uses sweepAll for the startup pass, calls scheduleSweep at the end - test/scheduler.test.ts: 3 unit tests covering iteration, error isolation between verifications, and empty-array safety Verified live with cron "* * * * *": fired at the next minute boundary ~15s after start. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/events/ready.ts | 17 +++++------ src/scheduler.ts | 22 ++++++++++++++ test/scheduler.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 src/scheduler.ts create mode 100644 test/scheduler.test.ts diff --git a/src/events/ready.ts b/src/events/ready.ts index f25fa78..01c719b 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,8 @@ import { type Client, Events } from "discord.js"; import type { Config } from "../config.js"; import { logger } from "../logger.js"; -import { type DiscordRestLike, runSweep } from "../sweep.js"; +import { scheduleSweep, sweepAll } from "../scheduler.js"; +import type { DiscordRestLike } from "../sweep.js"; export function registerReady(client: Client, cfg: Config, rest: DiscordRestLike): void { client.once(Events.ClientReady, async (readyClient) => { @@ -16,15 +17,11 @@ export function registerReady(client: Client, cfg: Config, rest: DiscordRestLike "ready", ); - if (!cfg.sweep.on_startup) return; - - for (const v of cfg.verifications) { - try { - const result = await runSweep(rest, v); - logger.info({ verification: v.name, ...result }, "sweep complete"); - } catch (err) { - logger.error({ err, verification: v.name }, "sweep failed"); - } + if (cfg.sweep.on_startup) { + await sweepAll(cfg, rest); } + + scheduleSweep(cfg, rest); + logger.info({ cron: cfg.sweep.cron }, "cron sweep scheduled"); }); } diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..2709713 --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,22 @@ +import cron, { type ScheduledTask } from "node-cron"; +import type { Config } from "./config.js"; +import { logger } from "./logger.js"; +import { type DiscordRestLike, runSweep } from "./sweep.js"; + +export async function sweepAll(cfg: Config, rest: DiscordRestLike): Promise { + for (const v of cfg.verifications) { + try { + const result = await runSweep(rest, v); + logger.info({ verification: v.name, ...result }, "sweep complete"); + } catch (err) { + logger.error({ err, verification: v.name }, "sweep failed"); + } + } +} + +export function scheduleSweep(cfg: Config, rest: DiscordRestLike): ScheduledTask { + return cron.schedule(cfg.sweep.cron, async () => { + logger.info({ cron: cfg.sweep.cron }, "scheduled sweep starting"); + await sweepAll(cfg, rest); + }); +} diff --git a/test/scheduler.test.ts b/test/scheduler.test.ts new file mode 100644 index 0000000..0557261 --- /dev/null +++ b/test/scheduler.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Config, Verification } from "../src/config.js"; +import { sweepAll } from "../src/scheduler.js"; +import type { DiscordRestLike } from "../src/sweep.js"; + +function verification(name: string, message_id: string): Verification { + return { + name, + guild_id: "1000000000000000001", + channel_id: "1000000000000000002", + message_id, + emoji: "✅", + role_id: "1000000000000000004", + on_remove: "keep", + }; +} + +function mockRest(overrides: Partial = {}): DiscordRestLike { + return { + listReactors: vi.fn().mockResolvedValue([]), + getMember: vi.fn().mockResolvedValue(null), + addMemberRole: vi.fn().mockResolvedValue(undefined), + removeMemberRole: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function configWith(verifications: Verification[]): Config { + return { + verifications, + sweep: { on_startup: false, cron: "0 */6 * * *" }, + }; +} + +describe("sweepAll", () => { + it("runs sweep for each verification", async () => { + const cfg = configWith([ + verification("a", "1000000000000000003"), + verification("b", "1000000000000000005"), + ]); + const rest = mockRest(); + await sweepAll(cfg, rest); + expect(rest.listReactors).toHaveBeenCalledTimes(2); + }); + + it("continues to next verification when one throws", async () => { + const cfg = configWith([ + verification("a", "1000000000000000003"), + verification("b", "1000000000000000005"), + ]); + const rest = mockRest({ + listReactors: vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValue([]), + }); + await sweepAll(cfg, rest); + expect(rest.listReactors).toHaveBeenCalledTimes(2); + }); + + it("no-ops on an empty-verification config (shouldn't happen, but doesn't crash)", async () => { + // The zod schema rejects empty arrays at parse time, so this only matters + // if a future caller constructs Config in-memory; just confirm no throw. + const cfg: Config = { + verifications: [], + sweep: { on_startup: false, cron: "0 */6 * * *" }, + }; + const rest = mockRest(); + await expect(sweepAll(cfg, rest)).resolves.toBeUndefined(); + expect(rest.listReactors).not.toHaveBeenCalled(); + }); +});