From 472873815d66900a8ad33293416278ee3377bda3 Mon Sep 17 00:00:00 2001 From: Seth Etter Date: Fri, 12 Jun 2026 14:39:18 -0500 Subject: [PATCH] Checkpoint: working, bit of tuning ahead though --- .env.example | 34 +++++++++- .gitignore | 3 +- README.md | 60 ++++++++++++++++++ main.ts | 140 ++++++++++++++++++++++++++++++++++------- openmeet.ts | 83 ------------------------- package.json | 5 +- src/atproto.ts | 59 ++++++++++++++++++ src/config.test.ts | 51 +++++++++++++++ src/config.ts | 68 ++++++++++++++++++++ src/http.ts | 17 +++++ src/notify.test.ts | 59 ++++++++++++++++++ src/notify.ts | 51 +++++++++++++++ src/openmeet.test.ts | 144 +++++++++++++++++++++++++++++++++++++++++++ src/openmeet.ts | 89 ++++++++++++++++++++++++++ src/schedule.test.ts | 36 +++++++++++ src/schedule.ts | 18 ++++++ tsconfig.json | 12 +++- 17 files changed, 818 insertions(+), 111 deletions(-) create mode 100644 README.md delete mode 100644 openmeet.ts create mode 100644 src/atproto.ts create mode 100644 src/config.test.ts create mode 100644 src/config.ts create mode 100644 src/http.ts create mode 100644 src/notify.test.ts create mode 100644 src/notify.ts create mode 100644 src/openmeet.test.ts create mode 100644 src/openmeet.ts create mode 100644 src/schedule.test.ts create mode 100644 src/schedule.ts diff --git a/.env.example b/.env.example index 1760bc5..1b81de0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,31 @@ -# Get these from an active browser session in local storage -OPENMEET_AUTH_TOKEN= -OPENMEET_REFRESH_TOKEN= +# ATProto handle used to authenticate to OpenMeet +ATPROTO_HANDLE= + +# App password for the ATProto account above +ATPROTO_APP_PASSWORD= + +# Slack bot token used to post notifications +SLACK_TOKEN= + +# Slack channel (name like #events or ID like C123456) where notifications are posted +SLACK_CHANNEL= + +# Time of day (HH:MM, 24-hour) the weekly event digest is sent +# Interpreted in America/Chicago +WEEKLY_NOTIFY_TIME=07:30 + +# Day of the week (Monday, Tuesday, ...) the weekly digest is sent +WEEKLY_NOTIFY_DAY=Monday + +# How often the notifier polls OpenMeet for new events (e.g. 30s, 5m, 1h) +CHECK_INTERVAL=5m + +# Enable the stdout notification channel (useful for local development) +# Defaults to false (disabled); set to true to enable. +ENABLE_STDOUT=false + +# Override the OpenMeet API base URL (for local testing against a fake server) +# OPENMEET_BASE_URL=http://localhost:8800 + +# Preset the ATProto PDS URL, skipping handle resolution (for local testing against a fake PDS) +# ATPROTO_PDS_URL=http://localhost:8800 diff --git a/.gitignore b/.gitignore index 37d7e73..a7a94e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -.env +.env* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4f91c0 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# devict-events + +A small TypeScript/Node service that polls the [OpenMeet](https://openmeet.net) API for upcoming devict events and notifies a set of channels when new ones are posted, plus a weekly digest of the upcoming week on a configured day and time. + +A TypeScript port of the Go version, with zero runtime dependencies: native `fetch`, native `Temporal` for timezone math, and Node's type-stripping to run `.ts` files directly. Requires Node >= 24. + +## How it works + +On a configurable interval the loop: + +1. Fetches events for the next 7 days from OpenMeet, authenticating via ATProto (handle + app password) on the first call and refreshing the OpenMeet access token as needed. +2. Diffs the response against the slugs seen on the previous fetch to find new events, then notifies every configured channel about each one. +3. On the configured day-of-week and time-of-day (in `America/Chicago`), sends a digest of the upcoming week to every channel. + +The only state is the OpenMeet tokens and the set of seen slugs, both held as locals in the run loop in `main.ts`. The loop runs until `SIGINT`/`SIGTERM`. + +## Configuration + +Available config can be seen in `.env.example`, with more details in `src/config.ts`. + +## Run + +```sh +npm start # poll loop +npm run once # single fetch, weekly-digest format, then exit +npm run once event [slug] # single fetch, new-event format +``` + +### Offline testing against the fake OpenMeet server + +The Go repo's `cmd/fake-openmeet` fakes both the ATProto PDS and the OpenMeet +endpoints this client needs: + +```sh +(cd ../events && go run ./cmd/fake-openmeet) & + +OPENMEET_BASE_URL=http://localhost:8800 ATPROTO_PDS_URL=http://localhost:8800 \ + ENABLE_STDOUT=true ATPROTO_HANDLE=test.example ATPROTO_APP_PASSWORD=x \ + WEEKLY_NOTIFY_TIME=07:30 WEEKLY_NOTIFY_DAY=Monday CHECK_INTERVAL=5m \ + node main.ts once +``` + +## Test & typecheck + +```sh +npm test # node:test; HTTP flows run against a stubbed fetch +npm run typecheck # tsc --noEmit +``` + +## Project layout + +``` +main.ts entry point: CLI dispatch, run loop, channel wiring, signals +src/config.ts env -> Config (validation, duration parsing) +src/atproto.ts ATProto auth flow (resolve PDS, create session, service-auth token) +src/openmeet.ts OpenMeet API calls; tokens passed in and returned out, no client object +src/notify.ts Slack + stdout channels, pure message formatters +src/schedule.ts weekly digest scheduling via Temporal in America/Chicago +src/http.ts fetch wrapper with status-carrying errors +``` diff --git a/main.ts b/main.ts index f86e610..5e8cee0 100644 --- a/main.ts +++ b/main.ts @@ -1,21 +1,119 @@ -import { OpenMeetClient } from "./openmeet.ts"; - -// TODO: Proper validation for these config vars -const authToken = process.env.OPENMEET_AUTH_TOKEN!; -const refreshToken = process.env.OPENMEET_REFRESH_TOKEN!; - -(async () => { - const today = Temporal.Now.plainDateTimeISO().round("day"); - const todayPlus1Week = today.add(Temporal.Duration.from({ days: 7 })); - - const openmeet = new OpenMeetClient({ - token: authToken, - refreshToken, - }); - - const events = await openmeet.getEvents({ - start: today, - end: todayPlus1Week, - }); - console.log(JSON.stringify(events, null, 2)); -})(); +// devict-events: polls OpenMeet for upcoming devict events, notifies +// channels about new ones, and sends a weekly digest. +// +// Usage: +// node --env-file=.env main.ts poll loop (until SIGINT/SIGTERM) +// 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 { slackChannel, stdoutChannel, type Channel } from "./src/notify.ts"; +import { nextWeeklyOccurrence, TIMEZONE } from "./src/schedule.ts"; + +function buildChannels(cfg: Config): Channel[] { + const channels: Channel[] = []; + if (cfg.slackToken && cfg.slackChannel) { + channels.push(slackChannel(cfg.slackToken, cfg.slackChannel)); + } else { + console.log("skipping slack channel, incomplete config"); + } + if (cfg.enableStdout) channels.push(stdoutChannel()); + if (channels.length === 0) throw new Error("no notification channels configured"); + 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 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); + } + + await sleep(cfg.checkIntervalMs, undefined, { signal }).catch(() => {}); + } +} + +// 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); +} diff --git a/openmeet.ts b/openmeet.ts deleted file mode 100644 index f1c731f..0000000 --- a/openmeet.ts +++ /dev/null @@ -1,83 +0,0 @@ -export type OpenMeetEvent = { - name: string; - slug: string; - startDate: string; - endDate: string; -}; - -type OpenMeetTokens = { - token: string; - refreshToken: string; - tokenExpires: number; -}; - -export class OpenMeetClient { - private API_BASE = "https://api.openmeet.net"; - private GROUP_SLUG = "devict"; - private TENANT_ID = "lsdfaopkljdfs"; - - private tokens: OpenMeetTokens; - - constructor(tokens: Omit) { - this.tokens = { ...tokens, tokenExpires: 0 }; - } - - private url(path: string): string { - return `${this.API_BASE}${path}`; - } - - private headers( - additionalHeaders?: RequestInit["headers"], - ): RequestInit["headers"] { - return { - "X-Tenant-Id": this.TENANT_ID, - Authorization: `Bearer ${this.tokens.token}`, - ...additionalHeaders, - }; - } - - async getEvents( - opts: { - start: Temporal.PlainDateTime; - end: Temporal.PlainDateTime; - }, - attempt = 1, - ): Promise { - const url = new URL(this.url(`/api/groups/${this.GROUP_SLUG}/events`)); - - url.searchParams.set("startDate", opts.start.toString()); - url.searchParams.set("endDate", opts.end.toString()); - - const resp = await fetch(url, { - headers: this.headers(), - }); - - if (!resp.ok) { - if (resp.status === 401 && attempt < 3) { - await this.refreshTokens(); - return this.getEvents(opts, attempt + 1); - } - throw new Error(`request failed: ${resp.status}`, { - cause: await resp.json(), - }); - } - - return (await resp.json()) as OpenMeetEvent[]; - } - - private async refreshTokens() { - const resp = await fetch(this.url("/api/v1/auth/refresh"), { - headers: this.headers(), - method: "POST", - body: JSON.stringify({ ...this.tokens }), - }); - - if (!resp.ok) { - throw new Error(`request failed: ${resp.status}`, { - cause: await resp.json(), - }); - } - - this.tokens = (await resp.json()) as OpenMeetTokens; - } -} diff --git a/package.json b/package.json index 4f311d0..5d37acb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,10 @@ "name": "devict-events", "type": "module", "scripts": { - "start": "node --env-file=.env main.ts" + "start": "node main.ts", + "once": "node main.ts once", + "test": "node --test", + "typecheck": "tsc" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/src/atproto.ts b/src/atproto.ts new file mode 100644 index 0000000..521d683 --- /dev/null +++ b/src/atproto.ts @@ -0,0 +1,59 @@ +// ATProto auth flow: resolve handle -> DID -> PDS, create a session with the +// app password, and mint a service-auth token for a target service. + +import { fetchJson } from "./http.ts"; + +export type AtprotoLogin = Readonly<{ + handle: string; + appPassword: string; + // Presetting the PDS URL skips handle resolution; intended for local + // testing against a fake PDS. + pdsUrl?: string | undefined; +}>; + +// Performs the full ATProto flow and returns a service-auth token scoped to +// the given audience (service DID) and lexicon method. +export async function getServiceToken(login: AtprotoLogin, aud: string, lxm: string): Promise { + const pdsUrl = login.pdsUrl ?? (await resolvePds(login.handle)); + + const session = await fetchJson<{ accessJwt: string }>( + "session creation", + `${pdsUrl}/xrpc/com.atproto.server.createSession`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: login.handle, password: login.appPassword }), + }, + ); + + const url = new URL(`${pdsUrl}/xrpc/com.atproto.server.getServiceAuth`); + url.searchParams.set("aud", aud); + url.searchParams.set("lxm", lxm); + const { token } = await fetchJson<{ token: string }>("service auth", url, { + headers: { Authorization: `Bearer ${session.accessJwt}` }, + }); + return token; +} + +type DidDocument = { + service?: { id: string; type: string; serviceEndpoint: string }[]; +}; + +async function resolvePds(handle: string): Promise { + const { did } = await fetchJson<{ did: string }>( + "handle resolution", + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, + ); + + const docUrl = did.startsWith("did:plc:") + ? `https://plc.directory/${did}` + : did.startsWith("did:web:") + ? `https://${did.slice("did:web:".length)}/.well-known/did.json` + : null; + if (!docUrl) throw new Error(`unsupported DID method: ${did}`); + + const doc = await fetchJson("DID document fetch", docUrl); + const pds = doc.service?.find((s) => s.type === "AtprotoPersonalDataServer"); + if (!pds) throw new Error(`no PDS endpoint found for ${handle} (${did})`); + return pds.serviceEndpoint; +} diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..efbf3f0 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { loadConfig, parseDuration } from "./config.ts"; + +const baseEnv = { + ATPROTO_HANDLE: "test.example", + ATPROTO_APP_PASSWORD: "hunter2", + WEEKLY_NOTIFY_TIME: "07:30", + WEEKLY_NOTIFY_DAY: "Monday", + CHECK_INTERVAL: "5m", +}; + +test("loads a full config", () => { + const cfg = loadConfig({ ...baseEnv, SLACK_TOKEN: "xoxb-1", SLACK_CHANNEL: "#events", ENABLE_STDOUT: "true" }); + assert.equal(cfg.atprotoHandle, "test.example"); + assert.equal(cfg.weeklyNotifyDay, 1); + assert.equal(cfg.weeklyNotifyHour, 7); + assert.equal(cfg.weeklyNotifyMinute, 30); + assert.equal(cfg.checkIntervalMs, 300_000); + assert.equal(cfg.enableStdout, true); + assert.equal(cfg.slackToken, "xoxb-1"); + assert.equal(cfg.openMeetBaseUrl, "https://api.openmeet.net"); +}); + +test("optional fields default sensibly", () => { + const cfg = loadConfig(baseEnv); + assert.equal(cfg.slackToken, undefined); + assert.equal(cfg.enableStdout, false); + assert.equal(cfg.atprotoPdsUrl, undefined); +}); + +test("missing required var throws", () => { + const { ATPROTO_HANDLE: _, ...env } = baseEnv; + assert.throws(() => loadConfig(env), /ATPROTO_HANDLE/); +}); + +test("invalid day and time throw", () => { + assert.throws(() => loadConfig({ ...baseEnv, WEEKLY_NOTIFY_DAY: "Funday" }), /WEEKLY_NOTIFY_DAY/); + assert.throws(() => loadConfig({ ...baseEnv, WEEKLY_NOTIFY_TIME: "25:00" }), /WEEKLY_NOTIFY_TIME/); + assert.throws(() => loadConfig({ ...baseEnv, WEEKLY_NOTIFY_TIME: "7:30" }), /WEEKLY_NOTIFY_TIME/); +}); + +test("parseDuration handles Go-style durations", () => { + assert.equal(parseDuration("30s"), 30_000); + assert.equal(parseDuration("5m"), 300_000); + assert.equal(parseDuration("1h30m"), 5_400_000); + assert.throws(() => parseDuration("5"), /invalid duration/); + assert.throws(() => parseDuration("fast"), /invalid duration/); + assert.throws(() => parseDuration(""), /invalid duration/); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..44d360e --- /dev/null +++ b/src/config.ts @@ -0,0 +1,68 @@ +// Loads application configuration from environment variables. Throws if any +// required variable is missing or unparseable. + +export type Config = Readonly<{ + atprotoHandle: string; + atprotoAppPassword: string; + slackToken: string | undefined; + slackChannel: string | undefined; + weeklyNotifyDay: number; // ISO day-of-week: Monday=1 .. Sunday=7 + weeklyNotifyHour: number; + weeklyNotifyMinute: number; + checkIntervalMs: number; + enableStdout: boolean; + openMeetBaseUrl: string; + atprotoPdsUrl: string | undefined; +}>; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +export function loadConfig(env: Record = process.env): Config { + const required = (name: string): string => { + const value = env[name]; + if (!value) throw new Error(`missing required env var ${name}`); + return value; + }; + + const day = required("WEEKLY_NOTIFY_DAY"); + const dayIndex = WEEKDAYS.indexOf(day); + if (dayIndex === -1) throw new Error(`invalid WEEKLY_NOTIFY_DAY "${day}" (expected e.g. "Monday")`); + + const time = required("WEEKLY_NOTIFY_TIME"); + const match = /^(\d{2}):(\d{2})$/.exec(time); + const hour = Number(match?.[1]); + const minute = Number(match?.[2]); + if (!match || hour > 23 || minute > 59) { + throw new Error(`invalid WEEKLY_NOTIFY_TIME "${time}" (expected 24-hour HH:MM)`); + } + + return { + atprotoHandle: required("ATPROTO_HANDLE"), + atprotoAppPassword: required("ATPROTO_APP_PASSWORD"), + slackToken: env.SLACK_TOKEN || undefined, + slackChannel: env.SLACK_CHANNEL || undefined, + weeklyNotifyDay: dayIndex + 1, + weeklyNotifyHour: hour, + weeklyNotifyMinute: minute, + checkIntervalMs: parseDuration(required("CHECK_INTERVAL")), + enableStdout: env.ENABLE_STDOUT === "true", + openMeetBaseUrl: env.OPENMEET_BASE_URL || "https://api.openmeet.net", + atprotoPdsUrl: env.ATPROTO_PDS_URL || undefined, + }; +} + +const DURATION_UNITS: Record = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }; + +// Parses Go-style durations like "30s", "5m", "1h30m" into milliseconds. +export function parseDuration(input: string): number { + let total = 0; + let matched = ""; + for (const m of input.matchAll(/(\d+)(ms|s|m|h)/g)) { + total += Number(m[1]) * DURATION_UNITS[m[2]!]!; + matched += m[0]; + } + if (matched !== input || input === "") { + throw new Error(`invalid duration "${input}" (expected e.g. "30s", "5m", "1h")`); + } + return total; +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..7db9505 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,17 @@ +// Small fetch wrapper: non-2xx responses become HttpErrors carrying the +// status code so callers can branch on 401s; 2xx responses are parsed as JSON. + +export class HttpError extends Error { + readonly status: number; + + constructor(what: string, status: number, body: string) { + super(`${what} failed: ${status}: ${body}`); + this.status = status; + } +} + +export async function fetchJson(what: string, url: string | URL, init?: RequestInit): Promise { + const resp = await fetch(url, init); + if (!resp.ok) throw new HttpError(what, resp.status, await resp.text()); + return resp.json() as Promise; +} diff --git a/src/notify.test.ts b/src/notify.test.ts new file mode 100644 index 0000000..706beae --- /dev/null +++ b/src/notify.test.ts @@ -0,0 +1,59 @@ +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/); +}); diff --git a/src/notify.ts b/src/notify.ts new file mode 100644 index 0000000..4cbd9fc --- /dev/null +++ b/src/notify.ts @@ -0,0 +1,51 @@ +// 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; +}>; + +export const slackOne = (e: OpenMeetEvent): string => `:sparkles: *New event posted:* ${formatEvent(e)}`; + +export const slackMany = (events: OpenMeetEvent[]): string => + events.length === 0 + ? ":calendar: No upcoming events this week." + : [":calendar: *Upcoming events this week:*", ...events.map((e) => `• ${formatEvent(e)}`)].join("\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, apiBase = "https://slack.com/api"): Channel { + const post = async (text: string): Promise => { + const result = await fetchJson<{ ok: boolean; error?: string }>("slack post", `${apiBase}/chat.postMessage`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ channel, text }), + }); + // Slack returns 200 with {"ok": false, "error": "..."} for API-level + // failures (bad token, channel not found, etc.). + if (!result.ok) throw new Error(`slack API error: ${result.error}`); + }; + return { + name: "slack", + notifyOne: (e) => post(slackOne(e)), + notifyMany: (events) => post(slackMany(events)), + }; +} diff --git a/src/openmeet.test.ts b/src/openmeet.test.ts new file mode 100644 index 0000000..9624ea7 --- /dev/null +++ b/src/openmeet.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Config } from "./config.ts"; +import { getUpcomingEvents, type OpenMeetEvent } from "./openmeet.ts"; + +const cfg: Config = { + atprotoHandle: "test.example", + atprotoAppPassword: "hunter2", + slackToken: undefined, + slackChannel: undefined, + weeklyNotifyDay: 1, + weeklyNotifyHour: 7, + weeklyNotifyMinute: 30, + checkIntervalMs: 300_000, + enableStdout: true, + openMeetBaseUrl: "http://openmeet.test", + atprotoPdsUrl: "http://pds.test", // skip handle resolution; everything goes through fetch +}; + +const events: OpenMeetEvent[] = [ + { name: "Hack Night", slug: "hack-night", startDate: "2026-06-15T18:00:00", endDate: "2026-06-15T20:00:00" }, +]; + +// Routes fetch calls by URL path so a test can script each endpoint's +// responses. Handlers receive the RequestInit and may shift through an array +// of canned responses. +function routeFetch(t: any, routes: Record Response>) { + const calls: string[] = []; + t.mock.method(globalThis, "fetch", async (url: any, init: RequestInit = {}) => { + const path = new URL(String(url)).pathname; + calls.push(path); + const handler = routes[path]; + if (!handler) throw new Error(`unexpected fetch: ${path}`); + return handler(init); + }); + return calls; +} + +const authRoutes = { + "/xrpc/com.atproto.server.createSession": () => Response.json({ accessJwt: "fake-jwt" }), + "/xrpc/com.atproto.server.getServiceAuth": () => Response.json({ token: "fake-service-token" }), + "/api/v1/auth/atproto/service-auth": () => + Response.json({ token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }), +}; + +test("authenticates via ATProto on first use and fetches events", async (t) => { + const calls = routeFetch(t, { + ...authRoutes, + "/api/groups/devict/events": (init) => { + assert.equal(new Headers(init.headers).get("authorization"), "Bearer access-1"); + return Response.json(events); + }, + }); + + const today = Temporal.PlainDate.from("2026-06-12"); + const result = await getUpcomingEvents(cfg, null, today); + + assert.deepEqual(result.events, events); + assert.equal(result.tokens.token, "access-1"); + assert.deepEqual(calls, [ + "/xrpc/com.atproto.server.createSession", + "/xrpc/com.atproto.server.getServiceAuth", + "/api/v1/auth/atproto/service-auth", + "/api/groups/devict/events", + ]); +}); + +test("sends a 7-day date range", async (t) => { + let query: URLSearchParams | undefined; + t.mock.method(globalThis, "fetch", async (url: any) => { + query = new URL(String(url)).searchParams; + return Response.json(events); + }); + + const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; + await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + + assert.equal(query?.get("startDate"), "2026-06-12T00:00:00"); + assert.equal(query?.get("endDate"), "2026-06-19T00:00:00"); +}); + +test("refreshes tokens on 401 and retries", async (t) => { + const eventResponses = [ + () => new Response("expired", { status: 401 }), + (init: RequestInit) => { + assert.equal(new Headers(init.headers).get("authorization"), "Bearer access-2"); + return Response.json(events); + }, + ]; + const calls = routeFetch(t, { + "/api/groups/devict/events": (init) => eventResponses.shift()!(init), + "/api/v1/auth/refresh": () => + Response.json({ token: "access-2", refreshToken: "refresh-2", tokenExpires: 9999 }), + }); + + const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; + const result = await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + + assert.deepEqual(result.events, events); + assert.equal(result.tokens.token, "access-2"); + assert.deepEqual(calls, ["/api/groups/devict/events", "/api/v1/auth/refresh", "/api/groups/devict/events"]); +}); + +test("falls back to full re-auth when the refresh token is rejected", async (t) => { + let eventCalls = 0; + routeFetch(t, { + ...authRoutes, + "/api/v1/auth/refresh": () => new Response("nope", { status: 401 }), + "/api/groups/devict/events": () => (++eventCalls === 1 ? new Response("expired", { status: 401 }) : Response.json(events)), + }); + + const tokens = { token: "stale", refreshToken: "stale", tokenExpires: 0 }; + const result = await getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")); + + assert.deepEqual(result.events, events); + assert.equal(result.tokens.token, "access-1"); // came from the full ATProto handshake +}); + +test("gives up after exhausting retries on persistent 401s", async (t) => { + routeFetch(t, { + "/api/groups/devict/events": () => new Response("expired", { status: 401 }), + "/api/v1/auth/refresh": () => Response.json({ token: "x", refreshToken: "y", tokenExpires: 9999 }), + }); + + const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; + await assert.rejects( + getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")), + /events fetch failed: 401/, + ); +}); + +test("non-401 errors propagate immediately", async (t) => { + const calls = routeFetch(t, { + "/api/groups/devict/events": () => new Response("boom", { status: 500 }), + }); + + const tokens = { token: "access-1", refreshToken: "refresh-1", tokenExpires: 9999 }; + await assert.rejects( + getUpcomingEvents(cfg, tokens, Temporal.PlainDate.from("2026-06-12")), + /events fetch failed: 500: boom/, + ); + assert.equal(calls.length, 1); +}); diff --git a/src/openmeet.ts b/src/openmeet.ts new file mode 100644 index 0000000..dcd2be4 --- /dev/null +++ b/src/openmeet.ts @@ -0,0 +1,89 @@ +// OpenMeet API client as plain functions. There is no client object: tokens +// are passed in and returned out, so the caller owns all state. + +import { getServiceToken } from "./atproto.ts"; +import type { Config } from "./config.ts"; +import { fetchJson, HttpError } from "./http.ts"; + +const TENANT_ID = "lsdfaopkljdfs"; +const GROUP_SLUG = "devict"; +const SERVICE_DID = "did:web:api.openmeet.net"; +const LEXICON_METHOD = "net.openmeet.auth"; + +export type OpenMeetEvent = Readonly<{ + name: string; + slug: string; + startDate: string; + endDate: string; +}>; + +export type Tokens = Readonly<{ + token: string; + refreshToken: string; + tokenExpires: number; +}>; + +export const formatEvent = (e: OpenMeetEvent): string => `${e.name} (${e.slug})`; + +const headers = (tokens?: Tokens): Record => ({ + "X-Tenant-Id": TENANT_ID, + "Content-Type": "application/json", + ...(tokens ? { Authorization: `Bearer ${tokens.token}` } : {}), +}); + +// Full handshake: ATProto service token -> OpenMeet token exchange. +export async function authenticate(cfg: Config): Promise { + const serviceToken = await getServiceToken( + { handle: cfg.atprotoHandle, appPassword: cfg.atprotoAppPassword, pdsUrl: cfg.atprotoPdsUrl }, + SERVICE_DID, + LEXICON_METHOD, + ); + return fetchJson("token exchange", `${cfg.openMeetBaseUrl}/api/v1/auth/atproto/service-auth`, { + method: "POST", + headers: headers(), + body: JSON.stringify({ token: serviceToken }), + }); +} + +// Mints a new access token from the refresh token; a 401 falls back to a +// full re-auth via ATProto. +async function refreshOrReauth(cfg: Config, tokens: Tokens): Promise { + try { + return await fetchJson("token refresh", `${cfg.openMeetBaseUrl}/api/v1/auth/refresh`, { + method: "POST", + headers: headers(tokens), + body: JSON.stringify(tokens), + }); + } catch (err) { + if (err instanceof HttpError && err.status === 401) return authenticate(cfg); + throw err; + } +} + +// Fetches all events in [today, today+7d), authenticating on first use +// (tokens == null) and refreshing/re-authing on 401. Returns the events +// along with the current tokens so the caller can thread them into the +// next call. +export async function getUpcomingEvents( + cfg: Config, + tokens: Tokens | null, + today: Temporal.PlainDate = Temporal.Now.plainDateISO(), +): Promise<{ events: OpenMeetEvent[]; tokens: Tokens }> { + const url = new URL(`${cfg.openMeetBaseUrl}/api/groups/${GROUP_SLUG}/events`); + url.searchParams.set("startDate", today.toPlainDateTime().toString()); + url.searchParams.set("endDate", today.add({ days: 7 }).toPlainDateTime().toString()); + + tokens ??= await authenticate(cfg); + for (let attempt = 1; ; attempt++) { + try { + const events = await fetchJson("events fetch", url, { headers: headers(tokens) }); + return { events, tokens }; + } catch (err) { + if (err instanceof HttpError && err.status === 401 && attempt < 3) { + tokens = await refreshOrReauth(cfg, tokens); + continue; + } + throw err; + } + } +} diff --git a/src/schedule.test.ts b/src/schedule.test.ts new file mode 100644 index 0000000..8fa2027 --- /dev/null +++ b/src/schedule.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nextWeeklyOccurrence } from "./schedule.ts"; + +const MONDAY = 1; +const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[America/Chicago]`); + +// 2026-06-08 is a Monday; 2026-06-10 is a Wednesday. + +test("mid-week rolls forward to the next scheduled weekday", () => { + const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-10T12:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); +}); + +test("same day before the scheduled time fires today", () => { + const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T06:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-08T07:30:00-05:00"); +}); + +test("exactly at the scheduled time rolls to next week (strictly after)", () => { + const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T07:30:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); +}); + +test("same day after the scheduled time rolls to next week", () => { + const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-06-08T08:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-06-15T07:30:00-05:00"); +}); + +test("crosses the spring-forward DST boundary at the right wall-clock time", () => { + // DST begins 2026-03-08 in America/Chicago; the following Monday's 07:30 + // should be in CDT (-05:00) while "after" is still in CST (-06:00). + const next = nextWeeklyOccurrence(MONDAY, 7, 30, zdt("2026-03-06T12:00:00")); + assert.equal(next.toString({ timeZoneName: "never" }), "2026-03-09T07:30:00-05:00"); +}); diff --git a/src/schedule.ts b/src/schedule.ts new file mode 100644 index 0000000..057a4c3 --- /dev/null +++ b/src/schedule.ts @@ -0,0 +1,18 @@ +// Weekly digest scheduling, timezone-aware via the built-in Temporal API. + +export const TIMEZONE = "America/Chicago"; + +// Returns the next occurrence of the given ISO weekday (Monday=1 .. Sunday=7) +// at hour:minute, strictly after `after`, in `after`'s timezone. +export function nextWeeklyOccurrence( + day: number, + hour: number, + minute: number, + after: Temporal.ZonedDateTime, +): Temporal.ZonedDateTime { + let next = after.with({ hour, minute, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }); + while (next.dayOfWeek !== day || Temporal.ZonedDateTime.compare(next, after) <= 0) { + next = next.add({ days: 1 }); + } + return next; +} diff --git a/tsconfig.json b/tsconfig.json index 1a1122d..e6912d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, "target": "esnext", "module": "nodenext", "rewriteRelativeImportExtensions": true, @@ -11,6 +13,12 @@ ], "types": [ "node" - ], - } + ] + }, + // atproto/ and openmeet/ are the pre-Go-rewrite prototype, kept around + // until deleted; exclude them from typechecking. + "include": [ + "main.ts", + "src" + ] } -- 2.51.2