VirtualCoffee's Slack/Zoom automation — a single Cloudflare Worker hosting the co-working room, the new-member welcome, the App Home tab, and event announcements.
| Bot | Trigger | Behavior |
|---|---|---|
| Co-working room | Zoom webhooks + a Slack Join button | Keeps a live "open room" message in the co-working channel: who's in the room, a ☕ Join button that hands each member a personal Zoom invite link, and a stats summary when the meeting ends. |
| Welcome | Slack team_join event |
DMs new members a welcome message. |
| App Home | Slack app_home_opened event |
Publishes the bot's App Home tab. |
| Event announcements | Cron triggers | Pulls upcoming events from the VirtualCoffee CMS (GraphQL), posts daily/weekly summaries to the announcements channel, and schedules a per-event "Starting Soon" message (start − 10 min) into the events channel, mirrored to the event-admin channel. Crons are live (daily + weekly); /vc-bot-admin can also fire a run manually. |
There's also a /vc-bot-admin slash command for manual previews and admin actions
(e.g. daily / weekly to fire an announcement run now, or coworking invite).
Everything runs on Cloudflare's edge runtime (workerd) — no node:* modules, Web APIs
only (fetch, crypto.subtle, etc.).
Request flow. src/index.ts is the Worker entrypoint (fetch + scheduled). fetch
delegates to src/router.ts, a plain method + path switch over six routes: POST /zoom/webhook, POST /slack/events, POST /slack/interactivity, POST /slack/commands,
GET /join/<token> (the co-working join redirect — the token itself is the credential, so
there's no signature to check), and GET /health. Every provider route:
- Verifies the provider signature against the raw body first, before parsing JSON
(timing-safe HMAC via
crypto.subtleinsrc/crypto.ts). - ACKs fast, works later. Slack and Zoom impose a ~3s response window, so routes return
200immediately and run the real work viactx.waitUntil(...), replying through Slack'sresponse_urlwhen needed.
The co-working room is the one stateful piece. CoworkingRoom
(src/bots/coworking/durable-object.ts) is a SQLite-backed Durable Object, one instance per
Zoom meeting ID. Routing all of a meeting's webhooks through a single instance serializes them,
eliminating eventual-consistency races. Zoom meeting.started posts (or updates the standing
invite into) the open-room message; participant_joined/left edit its live presence list;
meeting.ended turns it into a stats summary and posts a fresh invite. A stale-session alarm
force-closes sessions whose meeting.ended webhook never arrived.
Joining is per-user. The message's Join button mints a personal Zoom invite link
(src/zoom/invite-links.ts) with the member's name pre-filled — no Zoom registration involved
(the meeting must not require registration) — and answers with an ephemeral message holding
☕ Join / Cancel buttons; clicking either deletes the ephemeral (☕ Join also opens Zoom), so the
surface dismisses itself. The button's url is the Worker's own GET /join/<token> redirect,
which 302s to the personal link — the token-bearing Zoom url never appears in the Slack UI.
The redirect is surfaced under PUBLIC_BASE_URL (the virtualcoffee.io/bots Netlify rewrite
in front of the Worker); when unset it falls back to the request origin.
Correlating Zoom participants back to Slack members is best-effort by display name via the DO's
member_link table; people who join another way show as external guests. Personal join_urls
(and the redirect tokens that resolve to them) carry a join credential — they are never logged.
src/
index.ts Worker entrypoint: fetch + scheduled cron, re-exports the DO
router.ts method + path routing, signature verification, fast ACKs
env.ts hand-maintained Env interface (bindings, secrets, vars)
crypto.ts timing-safe HMAC helpers on crypto.subtle
log.ts leveled logger (threshold from LOG_LEVEL)
bots/
coworking/ the room: Durable Object, Zoom event handlers, join flow, message blocks
reminders/ cron dispatch, event model + CMS source, Block Kit builders, html-to-mrkdwn
welcome.ts new-member welcome DM
app-home.ts App Home tab
admin.ts /vc-bot-admin slash command
admin-panel.ts the interactive /vc-bot-admin button panel + its modals
slack/
app.ts the per-request SlackApp: event/action/command/viewSubmission handlers
client.ts Slack client factory (createSlackClient / createSlackApp)
notify.ts #bot-log error alerts (notifyBotLog)
response.ts ephemeral reply helpers over response_url
zoom/ Zoom S2S OAuth, webhook verification, invite links, payload types
test/ vitest suites that run inside real workerd (Miniflare)
Prerequisites: pnpm and a Cloudflare account (for deploys; local dev just needs wrangler, which is a dev dependency).
pnpm install
cp .dev.vars.example .dev.vars # then fill in real secrets
pnpm dev # wrangler dev — local server on workerdConfig and secrets are split deliberately:
- Non-secret config lives in
wrangler.jsoncvarsand is typed insrc/env.ts:ZOOM_MEETING_ID,PUBLIC_BASE_URL(join links surface under thevirtualcoffee.io/botsNetlify rewrite),SLACK_COWORKING_CHANNEL_ID,ROOM_TITLE,WELCOME_MAINTAINER_IDS(maintainers @-mentioned in the welcome message and App Home), the three announcement channels —SLACK_EVENTS_CHANNEL_ID(starting-soon messages),SLACK_ANNOUNCEMENTS_CHANNEL_ID(daily/weekly summaries),SLACK_EVENTADMIN_CHANNEL_ID(admin mirror with e.g. the Zoom host code) —SLACK_BOTLOG_CHANNEL_ID(private#bot-logchannel for error alerts; empty disables alerting and the bot must be invited before it can post),CMS_GRAPHQL_URL, andLOG_LEVEL. After changing bindings or vars, rerunpnpm cf-typesand keepsrc/env.tsin sync by hand. - Secrets go via
wrangler secret put <NAME>in production and.dev.varslocally (see.dev.vars.example):SLACK_BOT_TOKEN,SLACK_SIGNING_SECRET,ZOOM_WEBHOOK_SECRET_TOKEN,ZOOM_S2S_CLIENT_ID,ZOOM_S2S_CLIENT_SECRET,ZOOM_S2S_ACCOUNT_ID,CMS_TOKEN.
- Slack app: event subscriptions for
team_joinandapp_home_openedpointed at/slack/events, interactivity at/slack/interactivity, and the/vc-bot-adminslash command at/slack/commands. - Zoom app: webhook subscriptions for
meeting.started,meeting.ended,meeting.participant_joined, andmeeting.participant_leftpointed at/zoom/webhook, plus a Server-to-Server OAuth app for the invite-link API. The subscription is account-wide, so events arrive for every meeting under the account — the router ignores any meeting that isn'tZOOM_MEETING_ID. The co-working meeting must not require registration — invite links depend on it. - Cron triggers fire in UTC. The cron strings in
wrangler.jsonctriggers.cronsmust stay byte-identical toCRON_TO_KINDinsrc/bots/reminders/index.ts— the fired cron string is the lookup key for the reminder kind. Two crons drive everything:0 12 * * *(daily) and0 12 * * 1(weekly), both at 12:00 UTC (8am EDT / 7am EST). The per-event starting-soon messages need no extra cron granularity because the daily run schedules them via Slack'schat.scheduleMessage. The crons are live. To disable, settriggers.cronsto an empty array[]— deploying[]deregisters any crons already on Cloudflare, whereas deleting the key would leave them running.
pnpm dev # wrangler dev — local server on workerd
pnpm test # vitest run (inside the real workerd runtime via Miniflare)
pnpm typecheck # tsc --noEmit
pnpm cf-types # regenerate worker-configuration.d.ts after wrangler.jsonc changes
pnpm vitest run test/coworking-do.test.ts # a single test file
pnpm vitest -t "name of test" # tests matching a nameTests run inside workerd via @cloudflare/vitest-pool-workers, so Web Crypto, the Durable
Object, and bindings behave exactly as in production. Network calls are stubbed by spying on
fetch; the DO is driven with runInDurableObject / runDurableObjectAlarm from
cloudflare:test.
Use the leveled log from src/log.ts (log.info("event.name", { key: val })), never bare
console.* — and never log personal join_urls.
# One-time: set production secrets
wrangler secret put SLACK_BOT_TOKEN
wrangler secret put SLACK_SIGNING_SECRET
wrangler secret put ZOOM_WEBHOOK_SECRET_TOKEN
wrangler secret put ZOOM_S2S_CLIENT_ID
wrangler secret put ZOOM_S2S_CLIENT_SECRET
wrangler secret put ZOOM_S2S_ACCOUNT_ID
wrangler secret put CMS_TOKEN
pnpm deployCLAUDE.md— developer conventions and architecture invariants.