diff --git a/main.ts b/main.ts index 1f3554a..ebbeca8 100644 --- a/main.ts +++ b/main.ts @@ -10,7 +10,9 @@ 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 { slackChannel, stdoutChannel, type Channel } from "./src/notify.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"; function buildChannels(cfg: Config): Channel[] { @@ -49,6 +51,7 @@ async function run(): Promise { 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)); diff --git a/src/channels/channel.ts b/src/channels/channel.ts new file mode 100644 index 0000000..4d87188 --- /dev/null +++ b/src/channels/channel.ts @@ -0,0 +1,7 @@ +import type { OpenMeetEvent } from "../openmeet.ts"; + +export type Channel = Readonly<{ + name: string; + notifyOne(event: OpenMeetEvent): Promise; + notifyMany(events: OpenMeetEvent[]): Promise; +}>; diff --git a/src/notify.ts b/src/channels/slack.ts similarity index 56% rename from src/notify.ts rename to src/channels/slack.ts index 416b692..1c50eef 100644 --- a/src/notify.ts +++ b/src/channels/slack.ts @@ -1,14 +1,6 @@ -// Notification channels: a Channel sends one message per new event -// (notifyOne) and a weekly digest (notifyMany). Formatting is pure functions. - -import { fetchJson } from "./http.ts"; -import { formatEvent, type OpenMeetEvent } from "./openmeet.ts"; - -export type Channel = Readonly<{ - name: string; - notifyOne(event: OpenMeetEvent): Promise; - notifyMany(events: OpenMeetEvent[]): Promise; -}>; +import { fetchJson } from "../http.ts"; +import { formatEvent, type OpenMeetEvent } from "../openmeet.ts"; +import type { Channel } from "./channel.ts"; export const slackOne = (e: OpenMeetEvent): string => `:sparkles: *New event posted:* ${formatEvent(e)}`; @@ -20,21 +12,6 @@ export const slackMany = (events: OpenMeetEvent[]): string => "\n", ); -export const stdoutOne = (e: OpenMeetEvent): string => `New event: ${formatEvent(e)}`; - -export const stdoutMany = (events: OpenMeetEvent[]): string => - events.length === 0 - ? "No upcoming events this week." - : ["Upcoming events this week:", ...events.map((e) => ` - ${formatEvent(e)}`)].join("\n"); - -export function stdoutChannel(write: (msg: string) => void = console.log): Channel { - return { - name: "stdout", - notifyOne: async (e) => write(stdoutOne(e)), - notifyMany: async (events) => write(stdoutMany(events)), - }; -} - export function slackChannel( token: string, channel: string, diff --git a/src/channels/stdout.ts b/src/channels/stdout.ts new file mode 100644 index 0000000..62d9f79 --- /dev/null +++ b/src/channels/stdout.ts @@ -0,0 +1,17 @@ +import { formatEvent, type OpenMeetEvent } from "../openmeet.ts"; +import type { Channel } from "./channel.ts"; + +export const stdoutOne = (e: OpenMeetEvent): string => `New event: ${formatEvent(e)}`; + +export const stdoutMany = (events: OpenMeetEvent[]): string => + events.length === 0 + ? "No upcoming events this week." + : ["Upcoming events this week:", ...events.map((e) => ` - ${formatEvent(e)}`)].join("\n"); + +export function stdoutChannel(write: (msg: string) => void = console.log): Channel { + return { + name: "stdout", + notifyOne: async (e) => write(stdoutOne(e)), + notifyMany: async (events) => write(stdoutMany(events)), + }; +} diff --git a/src/notify.test.ts b/src/notify.test.ts deleted file mode 100644 index 3119278..0000000 --- a/src/notify.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - slackChannel, - slackMany, - slackOne, - stdoutChannel, - stdoutMany, - stdoutOne, -} from "./notify.ts"; -import type { OpenMeetEvent } from "./openmeet.ts"; - -const event = (name: string, slug: string): OpenMeetEvent => ({ - name, - slug, - startDate: "2026-06-15T18:00:00", - endDate: "2026-06-15T20:00:00", -}); - -const meetup = event("Hack Night", "hack-night"); -const lunch = event("Dev Lunch", "dev-lunch"); - -test("formatters", () => { - assert.equal(slackOne(meetup), ":sparkles: *New event posted:* Hack Night (hack-night)"); - assert.equal( - slackMany([meetup, lunch]), - ":calendar: *Upcoming events this week:*\n• Hack Night (hack-night)\n• Dev Lunch (dev-lunch)", - ); - assert.equal(slackMany([]), ":calendar: No upcoming events this week."); - - assert.equal(stdoutOne(meetup), "New event: Hack Night (hack-night)"); - assert.equal(stdoutMany([meetup]), "Upcoming events this week:\n - Hack Night (hack-night)"); - assert.equal(stdoutMany([]), "No upcoming events this week."); -}); - -test("stdoutChannel writes formatted messages", async () => { - const lines: string[] = []; - const ch = stdoutChannel((msg) => lines.push(msg)); - await ch.notifyOne(meetup); - await ch.notifyMany([meetup, lunch]); - assert.deepEqual(lines, [stdoutOne(meetup), stdoutMany([meetup, lunch])]); -}); - -test("slackChannel posts to chat.postMessage with auth", async (t) => { - const requests: { url: string; auth: string | null; body: any }[] = []; - t.mock.method(globalThis, "fetch", async (url: any, init: any) => { - requests.push({ - url: String(url), - auth: new Headers(init.headers).get("authorization"), - body: JSON.parse(init.body), - }); - return Response.json({ ok: true }); - }); - - await slackChannel("xoxb-token", "#events").notifyOne(meetup); - - assert.deepEqual(requests, [ - { - url: "https://slack.com/api/chat.postMessage", - auth: "Bearer xoxb-token", - body: { channel: "#events", text: slackOne(meetup) }, - }, - ]); -}); - -test("slackChannel surfaces ok:false API errors", async (t) => { - t.mock.method(globalThis, "fetch", async () => - Response.json({ ok: false, error: "channel_not_found" }), - ); - await assert.rejects(slackChannel("t", "#nope").notifyMany([]), /channel_not_found/); -});