Skip to content

Virtual-Coffee/vc-bots

Repository files navigation

vc-bots

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.

What it does

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).

Architecture at a glance

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:

  1. Verifies the provider signature against the raw body first, before parsing JSON (timing-safe HMAC via crypto.subtle in src/crypto.ts).
  2. ACKs fast, works later. Slack and Zoom impose a ~3s response window, so routes return 200 immediately and run the real work via ctx.waitUntil(...), replying through Slack's response_url when 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.

Project layout

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)

Getting started

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 workerd

Configuration

Config and secrets are split deliberately:

  • Non-secret config lives in wrangler.jsonc vars and is typed in src/env.ts: ZOOM_MEETING_ID, PUBLIC_BASE_URL (join links surface under the virtualcoffee.io/bots Netlify 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-log channel for error alerts; empty disables alerting and the bot must be invited before it can post), CMS_GRAPHQL_URL, and LOG_LEVEL. After changing bindings or vars, rerun pnpm cf-types and keep src/env.ts in sync by hand.
  • Secrets go via wrangler secret put <NAME> in production and .dev.vars locally (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.

Provider setup

  • Slack app: event subscriptions for team_join and app_home_opened pointed at /slack/events, interactivity at /slack/interactivity, and the /vc-bot-admin slash command at /slack/commands.
  • Zoom app: webhook subscriptions for meeting.started, meeting.ended, meeting.participant_joined, and meeting.participant_left pointed 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't ZOOM_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.jsonc triggers.crons must stay byte-identical to CRON_TO_KIND in src/bots/reminders/index.ts — the fired cron string is the lookup key for the reminder kind. Two crons drive everything: 0 12 * * * (daily) and 0 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's chat.scheduleMessage. The crons are live. To disable, set triggers.crons to an empty array [] — deploying [] deregisters any crons already on Cloudflare, whereas deleting the key would leave them running.

Development

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 name

Tests 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.

Deploying

# 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 deploy

Further reading

  • CLAUDE.md — developer conventions and architecture invariants.

About

VirtualCoffee's Slack/Zoom automation

Resources

Code of conduct

Stars

Watchers

Forks

Sponsor this project

 

Contributors