diff --git a/main.ts b/main.ts index ebbeca8..b3a678f 100644 --- a/main.ts +++ b/main.ts @@ -6,14 +6,11 @@ // node --env-file=.env main.ts once single fetch, digest format // node --env-file=.env main.ts once event [slug] single fetch, new-event format -import { setTimeout as sleep } from "node:timers/promises"; - import { loadConfig, type Config } from "./src/config.ts"; -import { getUpcomingEvents, type Tokens } from "./src/openmeet.ts"; import { type Channel } from "./src/channels/channel.ts"; import { slackChannel } from "./src/channels/slack.ts"; import { stdoutChannel } from "./src/channels/stdout.ts"; -import { nextWeeklyOccurrence, TIMEZONE } from "./src/schedule.ts"; +import { newNotifier } from "./src/notifier.ts"; function buildChannels(cfg: Config): Channel[] { const channels: Channel[] = []; @@ -27,100 +24,26 @@ function buildChannels(cfg: Config): Channel[] { return channels; } -// Sends to every channel, logging per-channel failures rather than aborting. -async function notifyAll(channels: Channel[], send: (ch: Channel) => Promise): Promise { - for (const ch of channels) { - try { - await send(ch); - } catch (err) { - console.error(`notify via ${ch.name} failed:`, err); - } - } -} - async function run(): Promise { + const [, , mode, sub, slug] = process.argv; + const cfg = loadConfig(); const channels = buildChannels(cfg); const aborter = new AbortController(); for (const sig of ["SIGINT", "SIGTERM"] as const) process.once(sig, () => aborter.abort()); - const { signal } = aborter; - - const nextDigestAfter = (after: Temporal.ZonedDateTime) => - nextWeeklyOccurrence(cfg.weeklyNotifyDay, cfg.weeklyNotifyHour, cfg.weeklyNotifyMinute, after); - - let tokens: Tokens | null = null; - let seen: Set | null = null; // null until the first successful fetch primes it - - // Schedule the first digest strictly after startup so an occurrence that - // already passed today doesn't fire immediately. - let nextDigest = nextDigestAfter(Temporal.Now.zonedDateTimeISO(TIMEZONE)); - console.log(`next weekly digest at ${nextDigest.toString({ timeZoneName: "never" })}`); - - while (!signal.aborted) { - try { - const result = await getUpcomingEvents(cfg, tokens); - tokens = result.tokens; - - // The first successful fetch only primes the cache; reporting nothing - // keeps every restart from re-announcing all upcoming events as new. - const prevSeen = seen; - const newEvents = prevSeen ? result.events.filter((e) => !prevSeen.has(e.slug)) : []; - if (!seen) console.log(`primed event cache with ${result.events.length} events`); - seen = new Set(result.events.map((e) => e.slug)); - - for (const event of newEvents) { - await notifyAll(channels, (ch) => ch.notifyOne(event)); - } - const now = Temporal.Now.zonedDateTimeISO(TIMEZONE); - if (Temporal.ZonedDateTime.compare(now, nextDigest) >= 0) { - nextDigest = nextDigestAfter(now); - console.log( - `sending weekly digest; next at ${nextDigest.toString({ timeZoneName: "never" })}`, - ); - await notifyAll(channels, (ch) => ch.notifyMany(result.events)); - } - } catch (err) { - console.error("fetching events failed:", err); - } + const notifier = await newNotifier({ cfg, aborter, channels }); - await sleep(cfg.checkIntervalMs, undefined, { signal }).catch(() => {}); + try { + if (mode === "once" && sub === "event") await notifier.onceEvent(slug); + else if (mode === "once") await notifier.once(); + else if (mode === undefined) await notifier.start(); + else throw new Error(`unknown command: ${mode}`); + } catch (err) { + console.error("fatal:", err instanceof Error ? err.message : err); + process.exit(1); } } -// Single fetch-and-notify pass in the weekly digest format, then exit. -async function once(): Promise { - const cfg = loadConfig(); - const channels = buildChannels(cfg); - const { events } = await getUpcomingEvents(cfg, null); - for (const ch of channels) await ch.notifyMany(events); -} - -// Single fetch, then send one event (first, or by slug) in the new-event -// format, then exit. -async function onceEvent(slug?: string): Promise { - const cfg = loadConfig(); - const channels = buildChannels(cfg); - const { events } = await getUpcomingEvents(cfg, null); - if (events.length === 0) throw new Error("no upcoming events found"); - - const event = slug ? events.find((e) => e.slug === slug) : events[0]!; - if (!event) { - throw new Error( - `event with slug "${slug}" not found; available slugs: ${events.map((e) => e.slug).join(", ")}`, - ); - } - for (const ch of channels) await ch.notifyOne(event); -} - -const [, , mode, sub, slug] = process.argv; -try { - if (mode === "once" && sub === "event") await onceEvent(slug); - else if (mode === "once") await once(); - else if (mode === undefined) await run(); - else throw new Error(`unknown command: ${mode}`); -} catch (err) { - console.error("fatal:", err instanceof Error ? err.message : err); - process.exit(1); -} +await run(); diff --git a/src/notifier.ts b/src/notifier.ts new file mode 100644 index 0000000..641fd92 --- /dev/null +++ b/src/notifier.ts @@ -0,0 +1,103 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import type { Channel } from "./channels/channel.ts"; +import type { Config } from "./config.ts"; +import { authenticate, getUpcomingEvents } from "./openmeet.ts"; +import { nextWeeklyOccurrence, TIMEZONE } from "./schedule.ts"; + +interface Notifier { + start(): Promise; + once(): Promise; + onceEvent(slug?: string): Promise; +} + +interface NotifierOpts { + channels: Channel[]; + aborter: AbortController; + cfg: Config; +} + +export async function newNotifier({ cfg, aborter, channels }: NotifierOpts): Promise { + const nextDigestAfter = (after: Temporal.ZonedDateTime) => + nextWeeklyOccurrence(cfg.weeklyNotifyDay, cfg.weeklyNotifyHour, cfg.weeklyNotifyMinute, after); + + let tokens = await authenticate(cfg); + + return { + async start() { + let seen: Set | null = null; // null until the first successful fetch primes it + + // Schedule the first digest strictly after startup so an occurrence that + // already passed today doesn't fire immediately. + let nextDigest = nextDigestAfter(Temporal.Now.zonedDateTimeISO(TIMEZONE)); + console.log(`next weekly digest at ${nextDigest.toString({ timeZoneName: "never" })}`); + + while (!aborter.signal.aborted) { + try { + const result = await getUpcomingEvents(cfg, tokens); + // Tokens can be refreshed inside the above function, so make sure we update them. + tokens = result.tokens; + + // The first successful fetch only primes the cache; reporting nothing + // keeps every restart from re-announcing all upcoming events as new. + const prevSeen = seen; + const newEvents = prevSeen ? result.events.filter((e) => !prevSeen.has(e.slug)) : []; + if (!seen) console.log(`primed event cache with ${result.events.length} events`); + seen = new Set(result.events.map((e) => e.slug)); + + for (const event of newEvents) { + await notifyAll(channels, (ch) => ch.notifyOne(event)); + } + + const now = Temporal.Now.zonedDateTimeISO(TIMEZONE); + if (Temporal.ZonedDateTime.compare(now, nextDigest) >= 0) { + nextDigest = nextDigestAfter(now); + console.log( + `sending weekly digest; next at ${nextDigest.toString({ timeZoneName: "never" })}`, + ); + await notifyAll(channels, (ch) => ch.notifyMany(result.events)); + } + } catch (err) { + console.error("fetching events failed:", err); + } + + await sleep(cfg.checkIntervalMs, undefined, { + signal: aborter.signal, + }).catch(() => {}); + } + }, + + // Single fetch-and-notify pass in the weekly digest format, then exit. + async once(): Promise { + const tokens = await authenticate(cfg); + const { events } = await getUpcomingEvents(cfg, tokens); + for (const ch of channels) await ch.notifyMany(events); + }, + + // Single fetch, then send one event (first, or by slug) in the new-event + // format, then exit. + async onceEvent(slug?: string): Promise { + const tokens = await authenticate(cfg); + const { events } = await getUpcomingEvents(cfg, tokens); + if (events.length === 0) throw new Error("no upcoming events found"); + + const event = slug ? events.find((e) => e.slug === slug) : events[0]!; + if (!event) { + throw new Error( + `event with slug "${slug}" not found; available slugs: ${events.map((e) => e.slug).join(", ")}`, + ); + } + for (const ch of channels) await ch.notifyOne(event); + }, + }; +} + +// Sends to every channel, logging per-channel failures rather than aborting. +async function notifyAll(channels: Channel[], send: (ch: Channel) => Promise): Promise { + for (const ch of channels) { + try { + await send(ch); + } catch (err) { + console.error(`notify via ${ch.name} failed:`, err); + } + } +}