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(); + }); +});