Conversation
Introduce a Google Calendar adapter alongside the CMS source via an EventSource registry. getEventSource resolves an explicit name (from /vc-bot-admin daily|weekly [source]), then env.EVENT_SOURCE, then the "cms" default, throwing on an unknown name. Google events use service-account JWT-bearer auth (jose) with module-level credential and token caching. SendResult now carries the source name, surfaced in the admin replies. Adds the GOOGLE_SERVICE_ACCOUNT_KEY secret, GOOGLE_CALENDAR_ID and EVENT_SOURCE config vars, docs, and tests.
# Conflicts: # CLAUDE.md # README.md # worker-configuration.d.ts
The Google Calendar source reads joinLink/hostCode (and slackChannelId) from extendedProperties.private, falling back to the legacy shared keys. private keeps the Zoom host code hidden from public calendar subscribers while the managing service account can still read it.
reminderRange("weekly") now runs from the current ISO week's Monday (Luxon startOf("week")) to the next Monday, instead of a rolling 7-day window anchored at the run time. Shared by the weekly summary and the calendar change-notices so both cover the same announced week regardless of run day.
Registering push (watch) channels and patching event properties needs the full calendar scope, not calendar.readonly.
…ver to Google Adds a CalendarSync Durable Object that owns a Calendar events/watch channel (7-day TTL, self-renewing via alarm) and a snapshot of the announced week. POST /google/notify verifies the X-Goog-Channel-Token, gates on EVENT_SOURCE=google, and kicks the DO, which diffs the live week against the snapshot and posts cancellation/reschedule notices (only for still-upcoming events) to the announcements, events, and event-admin channels, then reconciles the scheduled Starting Soon queue. The watch address is derived from PUBLIC_BASE_URL; the daily cron bootstraps/heals the watch; the admin panel gains watch status/start/stop buttons. Flips EVENT_SOURCE to "google" and pins the cms source in the CMS-specific reminder/admin tests so they keep exercising that adapter.
Resolve CLAUDE.md (keep both sources + the Quartz-weekday warning), regenerate the lockfile, and move the branch's cancellation/reschedule builders onto the shared 3-arg dateToken.
…ons (#14) Google Calendar is the system of record (virtualcoffee.io ADR 0014; docs/adr/0001 here): - Join Link = the event's location (video conferenceData as fallback); extendedProperties is never read. The 'private' properties were readable by any API reader of the public calendar. - The host key is not an event field. reconcileStartingSoon parses the meeting id from the Join Link and resolves it from Zoom at send time (GET /meetings/{id} -> host_id -> GET /users/{id} -> host_key; S2S apps can't use /users/me), cached per run. A Zoom failure fails the run. - Descriptions are Markdown, rendered with slackify-markdown; the local html-to-mrkdwn goes. - The CMS source, CMS_TOKEN/CMS_GRAPHQL_URL, graphql + graphql-request are removed; google is the only registered EventSource and the EVENT_SOURCE default. - ReminderEvent drops zoomHostCode and slackChannelId. - fetch-recorder answers the Zoom meeting/user GETs and Google Calendar; the reminders/admin suites use it instead of hand-rolled spies. Closes #14
A live check showed the S2S app is on granular scopes; Zoom rejects the user hop with 'does not contain scopes:[user:read:user:admin, user:read:user]', so the classic names were misleading.
…okup Zoom removed host_key from every API response in 2022, so the send-time lookup added in #14 cannot work under any scope. The events calendar is now private, so the key goes back to extendedProperties.private.hostCode and the Google source maps it to ReminderEvent.hostKey. A Zoom Join Link without one fails the run. src/zoom/host-key.ts shrinks to join-link.ts (parseZoomMeetingId only). Docs and ADR 0001 rewritten; the conflict with virtualcoffee.io ADR 0014 is flagged on PR #1579. Refs #14
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
vc-bots | f7c28b6 | Sep 17 2026, 08:26 PM |
📝 WalkthroughWalkthroughThe event-announcement system moves from CMS GraphQL to Google Calendar. It adds service-account authentication, Join Link classification, Calendar watch synchronization, shared admin actions, Zoom webhook extraction, and related tests and documentation. ChangesGoogle Calendar event pipeline
Sequence Diagram(s)sequenceDiagram
participant SlackAdmin
participant AdminActions
participant CalendarSync
participant GoogleCalendar
participant Slack
SlackAdmin->>AdminActions: submit reminder or watch action
AdminActions->>CalendarSync: ensureWatch(), stopWatch(), or watchStatus()
CalendarSync->>GoogleCalendar: register or stop watch
AdminActions->>Slack: send result response
GoogleCalendar->>CalendarSync: push notification
CalendarSync->>GoogleCalendar: fetch current event window
CalendarSync->>Slack: post event notices and reconcile scheduled messages
Priority: ➖ Normal Merge Risk: 🟡 Moderate · up to Calendar changes can be missed after seeding, token rotation, initialization failure, or partial Slack delivery. These synchronization and permission issues should be fixed before merge. 🚥 Pre-merge checks | ✅ 2 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (2 passed)
Full details: Linked Issues checkExplanation Issue Resolution Remove Full details: Out of Scope Changes checkExplanation Issue
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…mments The secret was in .dev.vars.example and Env but not the README's secrets list or deploy block. The env.ts / oauth.ts / wrangler.jsonc comments still described the Zoom host-key lookup that b5a2221 removed (the key is the calendar's private hostCode).
"name in SOURCES" accepted inherited names ("daily toString" would call
Object.prototype.toString as a source factory).
…eData A whitespace-only location suppressed the video entry-point fallback and rendered as a Location line. Trim it, and use the trimmed value.
The regex matched lookalikes (notzoom.us/j/...), which would then demand a host key and fail the whole reminder run. Parse the URL and accept only zoom.us and its subdomains.
reconcileStartingSoon cleared Slack's scheduled messages first, so a later Zoom event with no hostCode threw after the schedule was gone and partly rebuilt. Run the check over every upcoming event before the first mutation.
…ure-safe - ensureWatch creates and persists the replacement channel before stopping the old one, so a failed renewal leaves the live channel and its row intact. - alarm() re-arms itself (1h) after a failed renewal instead of silently ending automatic renewal; stopWatch's deleteAlarm is what ends retries. - stopChannel reports stopped/gone/failed (404/410 = gone = fine). stopWatch keeps the row + alarm and throws on a real failure so the admin panel doesn't report a watch as stopped while Google still pushes from it. - New notify(channelId) gate in front of processNotification drops pushes whose X-Goog-Channel-ID isn't the stored channel; the router calls it. - processNotification commits the snapshot before delivering, isolates each Slack post and the reconcile in try/catch, and throws one aggregate error at the end (-> #bot-log via the router). A partial failure now loses and alerts on a notice rather than re-posting it to every channel next push.
The source-agnostic event model now lives outside the reminders bot so adapters under src/google can depend on it without importing from src/bots. reminders/source.ts re-exports both types, so importers are unchanged.
All Google Calendar HTTP now lives in src/google/calendar.ts (createGoogleCalendarPort): listEvents / getEvent / watch / stopChannel, with the wire-to-ReminderEvent mapping in one place and the access token cached per adapter instance. auth.ts keeps only the pure JWT-bearer exchange (fetchGoogleAccessToken); the module-level cache and resetGoogleTokenCacheForTests are gone. The reminders Google source is the adapter's listEvents; the CalendarSync DO takes the port as an injected field and no longer knows CALENDAR_BASE, bearer headers, wire shapes, or how to re-derive startsAt. getEvent returns a live / cancelled / all-day lookup instead of a raw event. Tests: test/helpers/calendar-fake.ts is the second adapter at the seam; calendar-sync-do and router-gcal run against it instead of a fetch spy. reminders-google.test.ts becomes google-calendar.test.ts (the adapter suite). One shared RSA keygen (test/helpers/google-key.ts, Web Crypto) replaces three copies plus the node:crypto one in vitest.config.ts. CONTEXT.md gains Calendar and Calendar watch.
Move the per-event "Starting Soon" pair (public + event-admin mirror, its two Block Kit builders, reconcileStartingSoon, and the scheduled- message sweep) out of reminders/index.ts and reminders/blocks.ts into a new src/bots/reminders/starting-soon.ts. Behaviour is unchanged. Their tests moved with them into test/starting-soon.test.ts, unedited apart from import paths and de-duplicated fixture names.
…without a host key at derivation
ReminderEvent.join is now a JoinInfo union (zoom | url | place | none); only
zoom carries hostKey, so a Zoom link without one is unrepresentable. The Google
adapter enforces the rule: toReminderEvent is total (event | skipped | invalid),
listEvents drops invalid events with a #bot-log alert and lets the rest through,
and getEvent reports { kind: "invalid" } (CalendarSync posts no notice for it).
reconcileStartingSoon no longer pre-validates or fails the run. docs/adr/0002.
…nd panel become adapters src/bots/admin.ts and admin-panel.ts each ran the same operations (reminder, welcome DM, App Home publish, co-working announce, calendar watch) with their own gate, try/catch and reply strings, and panel.ts imported the gate from admin.ts (a cycle). Move both under src/bots/admin/ and put the operations behind one AdminAction / AdminResult union in actions.ts: runAdminAction gates, runs, and catches once; adminReplyText owns every reply line. slash.ts and panel.ts parse their own payload into an action and deliver the result (the panel keeps its replace-vs-dismiss matrix). Modal-opening buttons and the slash's non-action replies gate via the exported guardAdmin, so users.info is still called once per request. No reply text changes. The panel's per-handler log events (admin.panel.*_failed, admin.panel.denied) collapse into admin.failed / admin.denied. EASTERN is exported from reminders/source.ts instead of duplicated. New test/admin-actions.test.ts drives the actions directly, including the previously untested watch status/start/stop.
The panel could already manage the Calendar watch and send the welcome to another member; the slash command now reaches the same actions. `welcome` accepts an escaped mention (`<@U…|name>`, `<@U…>`) or a bare user id, matched against the rest of the line so a display name with spaces still parses; anything else gets a usage reply. Another-member sends reuse the panel's "Sent the welcome message to <@U>." line.
slack-edge hands handler.lazy(request) straight to ctx.waitUntil with no try/catch, so a lazy handler that rejects after the ACK is silent. Of the 16 lazy handlers only handleJoinClick alerted #bot-log; the admin adapters' respondEphemeral / views.open / deleteOriginal calls and the team_join / app_home_opened handlers had no last resort. createSlackApp now wraps every lazy arg in lazy(label, fn), which logs slack.lazy_failed and notifyBotLogs it. Handlers keep their own catches. `.action`'s lazy slot is a union of two handler types, so those registrations name the request type (ActionRequest) explicitly rather than let inference collapse payload to never.
Lift the snapshot diff out of CalendarSync.processNotification into src/bots/calendar-sync/diff.ts: departedUpcoming names the departed ids whose announced start is still upcoming (the ones that need a getEvent lookup), and diffSnapshot turns prior/current/lookups into notices + counts + invalid entries. No I/O, no `this`; the DO fetches the lookups into a Map and logs calendar_sync.event_invalid per invalid entry, then commits/delivers/reconciles exactly as before. Notice order is unchanged: departed (snapshot order) then in-window reschedules (live order). The seven rule cases move to test/calendar-sync-diff.test.ts as plain-map tests (plus notice-order, new-id and missing-lookup cases); the DO suite keeps the I/O around them: the end-to-end cancellation smoke, the zero-getEvent past case, reconcile, calendar failure, and commit-before-deliver.
Move handleZoomWebhook (and its safeJson) out of the router into its own module; the router now only dispatches. The DO call stays awaited on purpose (webhook ordering per meeting) — documented at the call site and in CLAUDE.md. Tests: test/helpers/signing.ts replaces the three hand-rolled v0 signers; test/router-zoom.test.ts becomes test/zoom-webhook.test.ts and covers the signature/handshake/filter/dispatch/bot-log paths end to end.
The DO's two outward edges — the Zoom invite-link mint and the room message's Slack port — become swappable private fields, mirroring CalendarSync's CalendarPort. createZoomInviteLinkPort (S2S token + createInviteLink) is the real InviteLinkPort; the DO no longer imports oauth.ts or createInviteLink directly. The DO suite now runs on in-memory fakes (invite-link-fake.ts, installRoomChannelFake) with fetch forbidden; the two wire-level invite-link cases move to test/zoom-invite-links.test.ts (renamed from zoom-invite-link-log.test.ts) as adapter tests.
…e host-code decision
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
…ger matches After GOOGLE_WATCH_TOKEN rotates, Google keeps sending the token the channel was registered with, so the router rejects every push — yet ensureWatch took the healthy fast path and watchStatus reported active until expiry approached. Both now also require the stored token to match the current one; a mismatch falls through to the (re)create branch.
…ced week rolled over Two problems with the old seeding: a first seed that failed after the channel row was persisted was never retried (later ensureWatch calls took the healthy fast path), and the daily cron reseeded unconditionally, so a Calendar change whose push was still in flight at cron time got absorbed into the baseline and the later push diffed to nothing. ensureWatch now owns the baseline on both branches: the snapshot is tagged with the weekly rangeStart it was taken for (KV key snapshot_range_start), and seed() runs only when that tag is absent or belongs to a previous week. runReminders no longer calls seed().
…efault getEventSource defaulted EVENT_SOURCE to "google" but the router and the daily-cron watch bootstrap compared the raw var, so the three could drift. All three now go through activeSourceName(env).
… accept http(s) Zoom links
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
Brings in #24 (DO event queue, ADR 0003), #26 (co-working deepening, ADRs 0004–0007) and #27 (ESLint/Prettier/knip + CI). Resolutions: - Zoom route: main's ACK-then-`waitUntil` dispatch (ADR 0003) ported into the branch's `src/zoom/webhook.ts`; the router passes `ctx` through. - CoworkingRoom: main's version, with the branch's swappable `InviteLinkPort` / `roomMessage` fields re-applied. - Admin: main's `sendWelcomeDm` / `publishHomeTab` helpers wired into the branch's `AdminAction` union (`src/bots/admin/`); `src/bots/admin.ts`, `app-home.ts`, `html-to-mrkdwn.ts` and the CMS test stay deleted. - coworking-do tests: main's new cases (correlation edge cases, participant_uuid, retire failure, schema migration, event serialization) ported to the fake ports; the fake channel port gained a `hold` gate so the serialization suite can park the DO mid-post/update. - CLAUDE.md / README: both sides' prose merged; ADR pointers kept. - Lint/format applied across the branch's files so `pnpm check` passes.
CLAUDE.md drops from 231 to 114 lines: commands the environment can't state, the cross-cutting invariants, and one paragraph per area ending in a conditional pointer. Detail it used to restate moves into new ADRs (0009 RoomMessage owns the channel message, 0010 one AdminAction union, 0011 one Calendar adapter behind CalendarPort) and amendments to 0004/0005/0006/0008. CONTEXT.md entries trimmed to definitions; .coderabbit.yaml path_instructions dropped in favour of its knowledge_base; docs/agents/domain.md example ADR no longer collides with the real 0007. Adds the missing POST /google/notify route to the map. Co-Authored-By: Claude <noreply@anthropic.com>
…wn cutover `pnpm fix-calendar [--apply]` (scripts/fix-calendar.ts) runs under plain node with a self-minted write-scoped service-account token — the Worker stays on the read-only scope. For every live series master and standalone event it sets `location` from the retired `private.joinLink` where they differ (the Morning/Afternoon Crowd series), converts an HTML description to Markdown, and deletes `joinLink`. Instance exceptions and Zoom Join Links without a hostCode are reported, not patched. Dry-run by default. scripts/html-to-markdown.ts is the pure converter, sized to the calendar's actual corpus (<p>, <br>, <a>, <b>/<strong>, <i>/<em>, lists, entities) and refusing any other tag so an event is skipped rather than guessed at; test/html-to-markdown.test.ts covers it. eslint gains a scripts/** override (syntax-only, console allowed); .dev.vars.example single-quotes the key because mise and `node --env-file` strip the JSON's own quotes. Co-Authored-By: Claude <noreply@anthropic.com>
One block per event, wrapped -/+ description lines instead of one-line JSON, identical conversions printed once and referenced after, no-ops and warnings grouped at the end. Co-Authored-By: Claude <noreply@anthropic.com>
…erate wire types `pnpm specs:update` (scripts/update-specs.ts) vendors APIs.guru's OpenAPI 3 conversion of Google Calendar v3 (openapi-typescript can't read the discovery document) and Zoom's API Hub document for Meetings into specs/. `pnpm gen:api-types` (scripts/gen-api-types.ts) prunes each to the operations the Worker calls — Redocly's `filter-in` on an `operationId` allow-list plus `removeUnusedComponents`, and a sweep for the path items Google's path-level parameters keep alive — then runs openapi-typescript into src/generated/ (Google 67 KB / 4 paths, Zoom 6 KB / 1 path; `defaultNonNullable` off so vendor defaults don't become required fields). Neither vendor publishes a document for its OAuth token endpoint, so specs/ also carries two hand-written fragments for those. Generated files are linguist-generated and skipped by Prettier and ESLint; the downloaded specs are linguist-vendored. scripts/** may import node:* (the workerd rule is for src/). @redocly/openapi-core is pinned to the 1.x range openapi-typescript declares — 2.x changes the `config.styleguide` surface — so the two bump together. Co-Authored-By: Claude <noreply@anthropic.com>
…l; ApiError All seven raw `fetch` sites — the Google token exchange, the four Calendar operations, the Zoom S2S token and invite_links — now go through `createApiClient<paths>` (src/http/client.ts), openapi-fetch typed by the generated wire types, so paths, params, bodies and 2xx responses are checked at compile time. The factory looks `globalThis.fetch` up per call (openapi-fetch captures it at creation, which would bypass a `fetch` the tests stub later) and takes an optional bearer resolver so token caching stays where it was. Non-2xx answers throw `ApiError` (src/http/error.ts) carrying provider, status and body; messages keep the `"<what failed>: <status> <body>"` shape the tests pin. Parsing stays tolerant (ADR 0001/0002): `GoogleCalendarEvent` is the spec's `Event` with `id` required, narrowed at the boundary — an id-less body is malformed, like a watch response without `expiration`. The Zoom token exchange gains the shape guard it lacked. The Slack response_url post never reads a body and stays raw. Tests: google-auth reads the form body from the `Request` the client now sends; a google-calendar fixture drops a cast the real schema makes unnecessary. Co-Authored-By: Claude <noreply@anthropic.com>
Records why the types are generated from vendored specs pruned by Redocly at gen time, why the OAuth token endpoints are hand-written fragments, why @redocly/openapi-core is called directly rather than @redocly/cli or a redocly.yaml, what stays raw, and the compile-time-only stance. ADR 0011 gets the amendment line; CLAUDE.md gains the regen commands and a "Provider HTTP" area pointing at the factory and the allow-list. Co-Authored-By: Claude <noreply@anthropic.com>
Same set .gitattributes marks generated/vendored; the hand-written OAuth fragments stay reviewable. Co-Authored-By: Claude <noreply@anthropic.com>
Linked Issue
Closes #14
Description
Event announcements now read from the Virtual Coffee Google Calendar instead of Craft CMS, and the CMS source is gone.
Google Calendar adapter.
src/google/calendar.tsis the oneCalendarPort(listEvents/getEvent/watch/stopChannel); service-account JWT-bearer auth viajoseinsrc/google/auth.ts.EVENT_SOURCEdefaults togoogle; theEventSourceregistry stays as the seam.Typed provider clients (
docs/adr/0012). Every Google and Zoom REST call goes throughcreateApiClient(src/http/client.ts) —openapi-fetchtyped bysrc/generated/*.d.ts, so paths, params, bodies and responses are checked at compile time. The types come from vendored specs inspecs/(Google Calendar v3 via APIs.guru's OpenAPI conversion, Zoom Meetings from Zoom's API Hub, two hand-written fragments for the OAuth token endpoints), pruned to the operations we call by Redocly at generation time;pnpm gen:api-typesregenerates offline,pnpm specs:updaterefreshes the vendored files. Non-2xx answers areApiError(src/http/error.ts) with the same messages as before; parsing stays tolerant (ADR 0001/0002).Event model (
docs/adr/0001, amended bydocs/adr/0002). Join Link = the event'slocation(videoconferenceDatafallback); descriptions are Markdown rendered withslackify-markdown.ReminderEvent.joinis a discriminated union (zoom/url/place/none); the Zoom host key (extendedProperties.private.hostCode) exists only on thezoomkind, is shown only in the event-admin mirror, and is never logged. A Zoom Join Link with no host key is rejected at derivation — dropped from the listing, alerted to#bot-log, every other event proceeds.Cancellations / reschedules via push. New route
POST /google/notify→ newCalendarSyncDurable Object (migration v2): owns theevents/watchchannel (7-day TTL, self-renewing via alarm), keeps anevent_snapshottable, diffs the announced Mon–Sun week against it via the purediff.ts, posts standout notices to announcements / events / event-admin, and re-queues the starting-soon pair. The daily cron bootstraps/heals the watch.Admin surfaces.
src/bots/admin/{actions,slash,panel}.ts: oneAdminActionunion behindrunAdminAction(workspace-admin gate and error handling inside); the slash command, panel buttons and modals are adapters. Slash gainswatch status|start|stopandwelcome [<@user>]; the panel gains Calendar Watch buttons.Config / secrets / deps. New vars
GOOGLE_CALENDAR_ID,EVENT_SOURCE; new secretsGOOGLE_SERVICE_ACCOUNT_KEY,GOOGLE_WATCH_TOKEN. Removed:CMS_TOKEN,CMS_GRAPHQL_URL,graphql,graphql-request, the localhtml-to-mrkdwnconverter, and the Zoom scopesmeeting:read:meeting:admin/user:read:user:admin/user:read:list_users:admin. Added:openapi-fetch(runtime),openapi-typescript+@redocly/openapi-core(dev)..gitattributesmarkssrc/generated/generated and the downloaded specs vendored; Prettier, ESLint and CodeRabbit skip them. Cron strings unchanged; the weekly window is now anchored to the ISO Mon–Sun week.Structural refactors (each its own commit, behaviour-preserving unless noted):
src/events.tsshared event model (a1f3ee0);CalendarPortinjected intoCalendarSyncwith a fake (48e3c4f);InviteLinkPort+ swappable room-channel port onCoworkingRoom,coworking-do.test.tson fakes only (8e3e13b); purediffSnapshot(11205da)starting-soon.tsowns the pair, lead time and sweep (5be07a8);JoinInfounion + reject-at-derivation, ADR 0002 (6a70384)83c58fd,e761f11);lazy()wrapper insrc/slack/app.tsso escaped lazy rejections alert#bot-log(7b95bab, behaviour change);src/zoom/webhook.tswith the full route-test matrix (f87811d)ae6c427),openapi-fetchclients andApiError(46dd0a7), ADR 0012 (00114c0), CodeRabbit filters (f7c28b6)56c0221); this PR template (da2a299); ADR 0001 notes virtualcoffee.io ADR 0014 reconciled (7c2297c)pnpm fix-calendar [--apply](1b449cb): the one-off calendar migration below, run locally under plainnodewith a self-minted write-scoped token; the Worker stays read-onlyRollout checklist (manual, before/after merge):
/admin/events)pnpm fix-calendar --apply— setlocationfrom the oldjoinLinkproperty where they differ (Morning/Afternoon Crowd), converted the HTML descriptions to Markdown, deletedjoinLinkon every series (applied 2026-09-17: 9 patches; re-run is all no-ops)wrangler secret put GOOGLE_SERVICE_ACCOUNT_KEYandGOOGLE_WATCH_TOKENin prod;wrangler secret delete CMS_TOKENPUBLIC_BASE_URLhost domain-verified in Google Cloud Console (push delivery requires it); then "Start watch" from the admin panel and live-test a cancel/movemeeting:write:invite_links:adminis used)Verification.
pnpm check(Prettier, ESLint, tsc, knip) andpnpm testgreen — 32 files / 344 tests on workerd via Miniflare;wrangler deploy --dry-runbundles.pnpm gen:api-typesis idempotent. Live read-only run of the Google source against the real calendar: all 10 upcoming events are Zoom events with a 6-digit host key mapped.Methodology
Why Google Calendar. The Craft CMS + Solspace Calendar backend is being retired; the community's events already live on the Google Calendar, so the bot reads the system of record directly instead of a copy. The
EventSourceseam was kept so a future source is a registry entry, not a rewrite.Why the host key is on the calendar (and the calendar is private). #14 originally had the bot fetch the host key from Zoom at send time. Zoom removed
host_keyfrom every API response in 2022 (verified live under every relevant granular scope; https://devforum.zoom.us/t/get-a-users-host-key-via-api/79004), so the only place it can live is the calendar's privatehostCodeproperty — which means the calendar can no longer be public. Recorded indocs/adr/0001. virtualcoffee.io ADR 0014 (Virtual-Coffee/virtualcoffee.io#1579) has since been reconciled with this: the Host Code is an Event field, the calendar is workspace-readable rather than public, and the site's/admin/eventsis the only writer ofhostCode.Why reject-at-derivation instead of fail-the-run (
docs/adr/0002). The original rule threw fromreconcileStartingSoon, so one calendar entry missing a host code black-holed the daily run, the weekly summary and every push-driven sync. Making the state unrepresentable in the model and dropping the one bad event with a#bot-logalert keeps everything else flowing; the trade-off (a Zoom event that loses its host code mid-week vanishes silently for members until fixed) is documented.Why push notifications rather than polling. Cancellations and moves need to reach the channels well before the scheduled "Starting Soon" pair fires; a daily poll can't do that. The watch channel is cheap, self-renewing, and the DO serialises notifications per calendar.
Why generated wire types (
docs/adr/0012). Seven hand-written response interfaces had nothing checking them against the vendors.googleapisis out (Node internals on workerd) andopenapi-typescriptcan't read Google's discovery document, so the Calendar spec is APIs.guru's OpenAPI conversion; neither vendor publishes a spec for its OAuth token endpoint, so those two are hand-written fragments. Zoom's Meetings document is 1.2 MB for one endpoint we call, hence pruning byoperationId— done by Redocly's decorators rather than our own$refwalker. Compile-time only: no runtime schema validation, so the tolerant parsing ADR 0001/0002 require is untouched.Why the refactor pass. An architecture review of the branch found the same seams missing that the calendar work had just added elsewhere (ports with injected fakes, pure diff logic, one place per rule). Landing them on this branch keeps the calendar code and its tests in the shape the rest of the worker now follows, before it merges.
Code of Conduct
Summary by CodeRabbit
New Features
Bug Fixes
Documentation