Farset Labs is a hackerspace — about tinkering with technology. This is a project. Tinker with it.
Read it. Break it (in a branch). Fix something that bugs you. Add a feature you want. Open a PR. The lab is collectively maintained, and so is this.
A small Cloudflare Worker that powers the Create an Event Slack app. Members run /create-an-event in Slack, fill in a form, and a real Google Calendar event lands on the Farset Labs Events calendar with the right rooms blocked.
In any channel in the Farset Labs Slack, type:
/create-an-event
A form pops up. Fill in Title, optional Description, Start, End, and tick one or more rooms. Hit Book. You'll get a DM within a few seconds with a link to the calendar event.
If EVENTS_CHANNEL_ID is configured, the booking is also posted to a Slack channel (typically #events) with the title, time, rooms, your @-mention, and a link to the calendar event.
If you don't want the booking to be public — e.g. it's a private meeting, a study session, or just blocking the room for an hour — use /book-a-room instead. Same form, but the booking is written directly to each room's resource calendar (so conflict detection still works) and is never posted to the public What's On calendar or the #events Slack channel.
/book-a-room— same form as/create-an-event, but the booking does not appear on the public What's On calendar and is not posted to#events. Each picked room gets the booking on its own resource calendar (so future bookings still detect a conflict). Use this for private meetings, study sessions, or anything you don't want broadcast. Members-only./stats— see your own membership info plus a lab-wide snapshot. Shows when you joined, your tenure and rank (Nth longest-active member), your tier, your lifetime contribution via Stripe, plus active member count (Stripe + Nexudus, deduplicated), tier split, joiners in the last 30 days, Stripe leavers in the last 30 days, and the lab's opening anniversary. Ephemeral reply (only you see it)./played— single line:Total time as a member: X years, Y days, Z hours, A minutes, B seconds (joined on [date] at [HH:MM:SS]). Ephemeral reply./members— list of all active members (Stripe + Nexudus, deduplicated by email, newest joiner first). Members visible to Slack are shown as@mentions; the count of members not on Slack is shown at the bottom. Ephemeral reply, members-only.
The bot only accepts bookings from people with an active Farset Labs membership in Nexudus. When you run /create-an-event, it:
- Reads your Slack profile email.
- Asks Nexudus whether that email has an active coworker record with an assigned tariff (membership plan).
- If yes → opens the form (with a wee greeting telling you how long you've been a member).
- If no → tells you politely, with two ways to resolve.
The two resolutions, in order of preference:
- You're not yet a member? Join Farset Labs.
- Your Nexudus email differs from your Slack email? Either update your email in the Nexudus member portal (link is in the bot's reply), or update your Slack profile email to match what's on Nexudus.
In a pinch a fellow member can book on your behalf, but please try the above first — bot accountability gets fuzzy when bookings don't match the actual person using the room.
DM whoever's been on the project lately, post in #tech (or whatever the current channel is), or just open a GitHub issue. Saying "this didn't work and here's the screenshot" is a perfectly valid contribution.
This is a normal repo. Clone it, edit it, deploy it. There's nothing magical going on.
The whole thing is one Cloudflare Worker plus four external services. Each piece does one thing:
- Slack app — the user-facing chat UI. Owns the slash command and the modal. Configured at api.slack.com/apps. Sends webhooks to the Worker when someone runs the command or submits the form.
- Cloudflare Worker — the brains. Receives Slack webhooks, talks to Stripe and Google, replies to Slack. Runs on Cloudflare's free tier. Configured at dash.cloudflare.com.
- Google Workspace + Calendar — where the actual events get created. Rooms are configured as Workspace resources (admin.google.com → Buildings and resources). The Worker uses a service account with Domain-Wide Delegation to act as a Workspace user (
services@farsetlabs.org.uk) — that's what gives it permission to attach rooms to events. - Nexudus — source of truth for "is this person a member?". The Worker authenticates as a dedicated admin API user (HTTP Basic auth) and reads coworker records.
- Stripe — used by
/statsfor lifetime contribution figures and the leavers-in-the-last-30-days count, and as a secondary membership source merged with Nexudus (so anyone with an active Stripe sub but no Nexudus record still counts). The Worker uses a read-only restricted API key. - GitHub — where this code lives. Pushing to
mainauto-deploys via GitHub Actions. PRs welcome.
| Piece | How to get access |
|---|---|
| GitHub repo | It's on Farset Labs' org. Ask a director if you need write access. Reading and forking are public. |
| Cloudflare | Ask a director — they'll add you to the Farset Labs Cloudflare account as a collaborator. |
| Slack app config | Ask a Workspace admin to add you as a "collaborator" on the app at api.slack.com/apps. |
| Google Workspace admin | Ask a director. Most maintenance doesn't need it. |
| Stripe dashboard | Ask a director. Most maintenance doesn't need it (the Worker uses a restricted API key, not a personal Stripe login). |
| Nexudus admin | Ask a director. The Worker authenticates as a dedicated API user — most maintenance doesn't need a personal Nexudus admin login. |
If you're not sure who a director is, see the Farset Labs website or ask in Slack.
The Worker is configured by secrets — encrypted environment variables stored in Cloudflare. Set them with npx wrangler secret put NAME (or pipe a file in: npx wrangler secret put NAME < file.txt).
🤖 Don't know what something means? Ask Claude / ChatGPT / any LLM. Paste the secret name, paste the description, and ask "where do I find this in the [Stripe / Google / Slack] dashboard". They're great at walking through these UIs.
These are non-sensitive or you can find them yourself.
| Secret | What it is | Where to get it |
|---|---|---|
MEMBERSHIP_SIGNUP_URL |
Where non-members are pointed to join. | Wherever the lab's signup page lives — usually farsetlabs.org.uk. |
STRIPE_BILLING_PORTAL_URL |
Self-service link for members to update their billing email. | dashboard.stripe.com → Settings → Billing → Customer portal → copy the "Login link". Not secret. |
ROOMS_JSON |
JSON array of { name, email, capacity } for each bookable room. |
admin.google.com → Buildings and resources → click each room → copy the Resource email field. Capacity is the number you set when creating the room. |
GOOGLE_CALENDAR_ID |
The Farset Labs Events calendar's ID. | calendar.google.com → "Farset Labs Events" → Settings and sharing → "Calendar ID" field. |
GOOGLE_IMPERSONATE_SUBJECT |
Workspace user the bot acts as (currently services@farsetlabs.org.uk). |
Just an email address. The user has to actually exist in Workspace and have access to the calendars. |
EVENTS_CHANNEL_ID (optional) |
Slack channel ID where bookings are announced (e.g. #events). Leave unset to disable channel announcements. |
In Slack, right-click the channel → "Copy link" → grab the trailing C… segment. Then /invite @Create an Event in that channel — the bot must be a member to post there. |
These are sensitive — leaking them could let someone impersonate the bot, read membership data, or mess with the Google account.
| Secret | What it is | How to get it (with director help) |
|---|---|---|
SLACK_SIGNING_SECRET |
Slack uses this to prove webhooks really came from Slack. | api.slack.com/apps → Create an Event → Basic Information → App Credentials → Signing Secret. A director can give you read access to the app. |
SLACK_BOT_TOKEN |
The Slack bot's identity (xoxb-...). Lets the Worker post messages as the bot. |
api.slack.com/apps → Create an Event → OAuth & Permissions → Bot User OAuth Token. Same access as above. |
GOOGLE_SERVICE_ACCOUNT_JSON |
The full JSON private key for the service account that books events. Treat like a password. | console.cloud.google.com → IAM & Admin → Service Accounts → click the bot account → Keys → Create new key (JSON). Pipe the file into wrangler secret put — don't paste it. Ask a director with GCP access. |
STRIPE_SECRET_KEY |
Read-only Stripe API key for the membership check, /stats lookups, and lifetime contribution sums. |
dashboard.stripe.com → Developers → API keys → Restricted keys → Create. Permissions, all Read only: Customers, Customer search (if shown separately), Subscriptions, Products, Events, Invoices. Nothing else. The bot never writes. Ask a director with Stripe access. |
STRIPE_MEMBERSHIP_PRICE_IDS |
Comma-separated list of Stripe Price IDs that count as a membership. | dashboard.stripe.com → Product catalog → click each membership product → copy each Price ID. Director can pull these in two minutes. |
NEXUDUS_EMAIL / NEXUDUS_PASSWORD |
Credentials for a dedicated Nexudus admin API user (HTTP Basic auth — Nexudus has no API-key concept). The user must have a role granting read access to coworkers and contracts. Don't reuse a personal admin login. | Nexudus admin → Settings → Team → Add team member. Give it a long random password, assign a read-only role. Ask a director with Nexudus admin access. First login of a new team member can be flaky — see the activation-email gotcha below. |
NEXUDUS_BUSINESS_ID |
Nexudus invoicing-business id (numeric) used to scope coworker queries to Farset Labs Hackerspace. | The id appears in the Nexudus admin URL when you filter to the Farset Labs business in operations → coworkers (look for coworker_InvoicingBusiness=… in the URL). |
NEXUDUS_PORTAL_URL |
Nexudus member self-service portal URL surfaced when a non-matching email comes through. | Farset Labs uses https://farsetlabs.spaces.nexudus.com/. Each Nexudus space has its own subdomain — confirm the lab is still using this one before changing. |
If you're rotating one of the sensitive secrets (because someone left, or it leaked), you also need to invalidate the old one — revoke the Stripe key, regenerate the service-account key, etc. Ask a director to help; this is the kind of thing where small mistakes have big consequences.
git clone https://github.com/farsetlabs/create-an-event-through-slack.git
cd create-an-event-through-slack
npm installMake your change. Type-check it:
npx tsc --noEmitPush to a branch, open a PR. When it merges to main, GitHub Actions auto-deploys (see .github/workflows/deploy.yml).
If you want to deploy a quick fix without going through CI (e.g. you're a maintainer with Cloudflare access and the prod bot is broken):
npx wrangler login
npx wrangler deploynpx wrangler devSet local secrets in a .dev.vars file (gitignored). Slack can't reach your localhost, so for end-to-end testing either:
- Use
npx wrangler dev --remote(Cloudflare gives you a temporary public URL) and point the Slack app at it temporarily. - Or just push to a branch and use the deployed preview URL.
Slack workspace (Farset Labs)
├─ /create-an-event ──► POST /slack/commands
│ │
│ ├─ verify Slack signature
│ ├─ ask Nexudus: "is this email a member?" (active coworker w/ tariff)
│ │ ├─ no → ephemeral "members only" reply
│ │ └─ yes → open booking modal
│ └─ done
│
├─ /stats ──────────► POST /slack/commands
│ │
│ ├─ verify Slack signature
│ ├─ ack with empty 200 (async work follows)
│ └─ in waitUntil:
│ ├─ Slack: get user's email
│ ├─ in parallel:
│ │ Stripe: list active members (with tier names)
│ │ Nexudus: list active coworkers (with tariff names)
│ │ Stripe: customer search for the requesting user (for contribution)
│ ├─ merge by email (earliest memberSince wins, Nexudus tier preferred)
│ ├─ in parallel:
│ │ Stripe: leavers-in-last-30 events walk
│ │ Stripe: lifetime invoices for the user (skipped if no Stripe customer)
│ └─ POST to response_url with rendered ephemeral
│
├─ /book-a-room ────► POST /slack/commands
│ │ (same gate + modal as /create-an-event,
│ │ different callback_id: submit_room_booking)
│ └─ done
│
└─ Modal submit ──► POST /slack/interactions
│
├─ list events across selected rooms (events.list)
│ → if any conflict, surface "There's a conflict with N other bookings"
├─ if submit_booking (from /create-an-event):
│ ├─ create one event on the Events calendar
│ │ with rooms attached as resource attendees
│ ├─ DM booker with calendar link
│ └─ (if EVENTS_CHANNEL_ID set) post announcement to #events channel
└─ if submit_room_booking (from /book-a-room):
├─ create one event per picked room directly on that room's resource calendar
├─ DM booker with one calendar link per room
└─ (no #events post — ever)
src/
├── index.ts # Worker entrypoint — routes /health, /slack/commands, /slack/interactions, glue logic
├── google.ts # Service-account JWT + DWD, room events listing, event creation
├── slack.ts # Signature verification, modal builders, users.info, DM + channel posting
├── nexudus.ts # Membership lookup + active-coworker list (source of truth for "is this person a member?")
├── stripe.ts # Active-member list, leavers-events walk, lifetime invoice sum, product-name map, customer search (used by /stats)
└── types.ts # Env, Room, SlackBlockValue, etc.
- Create the room in admin.google.com → Buildings and resources.
- Subscribe to it in calendar.google.com (Other calendars → + → Browse resources).
- Share its calendar with the impersonated Workspace user (currently
services@farsetlabs.org.uk), permission Make changes to events. - Append a new entry to your local
rooms.json:{ "name": "New Room", "email": "farsetlabs.org.uk_…@resource.calendar.google.com", "capacity": 12 } - Push the new file to Cloudflare:
npx wrangler secret put ROOMS_JSON < rooms.json npx wrangler deploy
No code change needed — the modal reads from ROOMS_JSON on every open.
A non-exhaustive list of "would be nice if someone built this" — pick whatever scratches your itch:
/my-bookings— list your future bookings./cancel-booking— cancel one of your bookings without leaving Slack.- Public-vs-private booking distinction — currently every booking ends up on the public Events calendar; not every meeting needs that.
- Cache the membership check — Stripe gets pinged every time someone runs the command. Workers KV could cache for an hour per user.
- Finish the Nexudus migration — the gate and
/statsnow both pull membership from a Stripe + Nexudus merge (Nexudus preferred for tier names, earliest start date wins). What's still Stripe-only on/stats: lifetime contribution (sums Stripe invoices) and the leavers-in-the-last-30-days count (usescustomer.subscription.deletedevents). To complete the migration: port the contribution sum to Nexudus invoices via/api/billing/coworkerinvoices, and figure out the cancelled-coworker filter on Nexudus to derive leavers from there too. Once done,src/stripe.tsand theSTRIPE_*secrets can be deleted. - Repeat-this-booking helper — DM button after a successful booking that pre-fills the modal for next week.
- Room descriptions in the dropdown — extend
ROOMS_JSONwith adescriptionfield so newcomers know what each room is good for. - Trustee/external-visitor override — allowlist of Slack IDs that can bypass the Stripe gate to book for visiting groups.
- Post-booking reminders — Cloudflare Cron Trigger that DMs bookers the day before.
- Lightweight charity analytics — weekly digest to trustees: top rooms, top members, occupancy by hour.
- Recurring bookings — Wednesday Hack Night, weekly knit-and-natter, etc.
- Better duration parsing — let people type "tomorrow 7-9pm" instead of using two pickers.
- Past-time guard — currently you can technically book yesterday.
- Tier-aware UI — show different rooms / time slots depending on membership tier.
- Tests — there are none. Bun has a test runner, vitest works too. PRs welcome.
GET /health— liveness check, returns200 ok.POST /slack/commands— receives the slash command.POST /slack/interactions— receives modal submissions and global shortcut clicks.
- Conflict detection runs
events.liston every selected room calendar before booking. If any room has a conflicting event, the modal stays open with a count of conflicts and a link to the public calendar — no event titles are exposed. - Room auto-accept is handled by Workspace's native resource booking. If a room declines (e.g. its policy refuses the user), the booker gets a warning in their DM. The channel announcement still goes out.
- Channel announcement is optional — if
EVENTS_CHANNEL_IDisn't set, only the DM goes out. The bot must be a member of the announcement channel (/invite @Create an Eventfrom inside the channel). - Markdwn injection — booker-supplied text (title, description, room names) is HTML-escaped before being broadcast to the public channel, so members can't inject
<!channel>,<@U…>, or other Slack control sequences. - Stripe error redaction — when a Stripe call fails, the response body is logged with any
rk_/sk_/pk_*_…key suffixes redacted before reachingwrangler tail. - Token caching — Google service-account JWTs are cached in-memory per Worker isolate.
- No caching on Stripe — each
/create-an-eventand/statsinvocation hits Stripe. Fine at hackerspace scale. - Cloudflare 50-subrequest limit — Workers on the free plan cap each invocation at 50 outgoing
fetch()calls (paid: 1000). Current commands stay well under: gate paths use 3,/membersuses 6,/statslands around 10–15 in realistic scenarios. The two patterns to watch are (a) per-member fan-out —/membersoriginally did 50×users.lookupByEmailand had to switch to a singleusers.listwalk to stay safe, and (b) Stripe-customer fan-out —getLifetimeContributionPencepaginates invoices per matched Stripe customer, so a user with many duplicate Stripe customer records can stack up. If you add a new command, count the calls.