Who owns what
You'll end up with five things. Every one of them should be owned by the club, not by you.
| Thing | Where | Owner |
|---|---|---|
| Notion integration | notion.so/my-integrations | The club Notion account |
| Discord application | discord.com/developers | A Discord Team, never a person |
| Cloudflare Worker, KV, R2 | dash.cloudflare.com | The club Gmail |
| Groq and Gemini API keys | console.groq.com · aistudio.google.com | The club Gmail |
worker.js and these docs | github.com/<club> | The club org |
Do the ownership steps first, not last. Transferring a Discord app to a Team, or moving a Cloudflare account, is annoying once things depend on it. Every one of these is a five-minute job on day one and a bad afternoon in eighteen months.
To copy IDs out of Discord you need Developer Mode on: User Settings → Advanced → Developer Mode. Keep a scratch note open — you'll collect about twenty values.
Notion
- Create the integration.
notion.so/my-integrations→ New integration → name it Bunker Bot → the club workspace → Internal. Under Capabilities tick Read, Update and Insert content. Save and copy the Internal Integration Secret — it startsntn_. That'sNOTION_TOKEN. - Connect the databases. Open the Club Operations page →
•••→ Connections → Bunker Bot. That grants every database under it at once. Then check each of the eleven below individually — a database created after the page was connected is not connected automatically. - Collect the data source IDs. They're fixed for this workspace and already filled in below; if you ever rebuild a database, its ID changes and the matching variable has to follow.
| Database | Variable | ID |
|---|---|---|
| Bunker Inventory | INVENTORY_DS | 37c50591-221f-80c8-ae9d-000bebcea235 |
| Bunker Checkout Log | LEDGER_DS | d900e4a9-df58-4de8-baa0-60c389fdaf6d |
| Projects | PROJECTS_DS | fd8587db-eafe-4dd3-99cf-a5d1d688bbb1 |
| Meetings | MEETINGS_DS | d9f5fa89-e8de-4e20-b5e0-83b69f912ca2 |
| Members | MEMBERS_DS | e935e4bb-cbc6-4a92-a2f1-e086bec48a55 |
| Attendance | ATTENDANCE_DS | 9c1d2b64-66de-4963-a69f-8c8dc37051ba |
| Tasks | TASKS_DS | f5140aa3-138a-4f57-adf7-d89c961bad4f |
| Purchase Requests | PURCHASES_DS | 2cb13198-de4b-4abf-86fc-295d3da631d2 |
| Bring Requests | REQUESTS_DS | cee8c7c0-cf01-4e28-b153-a8e62a2c96b0 |
| Nudge Templates | TEMPLATES_DS | 18970188-f9bd-404b-bf2a-a999b760e00b |
| Member Feedback | FEEDBACK_DS | 9c7027ce-591e-433c-adf5-a4284285f3f3 |
The first three are required; the Worker refuses to start without them. The rest each switch on one feature and the bot says what's missing if you use a command that needs one.
Discord
- Create the application.
discord.com/developers/applications→ New Application → Bunker Bot. - Move it to a Team, now. Settings → Team → create or pick one → transfer. An app owned by a person's account dies with that account.
- Copy two values from General Information: Application ID →
DISCORD_APP_ID, Public Key →DISCORD_PUBLIC_KEY. - Bot → Reset Token → copy it (shown once) →
DISCORD_BOT_TOKEN. Turn Public Bot off. - Invite it. OAuth2 → URL Generator → scopes
botandapplications.commands→ permissions Send Messages, Embed Links, Manage Events → open the URL → pick the server. - Collect three IDs from the server: right-click the server → Copy Server ID →
DISCORD_GUILD_ID. Server Settings → Roles → E-Board → Copy Role ID →EBOARD_ROLE_ID. You'll collect channel IDs in a later step.
Leave the developer tab open. You come back for the endpoint URL after the Worker exists.
The Worker
- Create it.
dash.cloudflare.comon the club Gmail → Workers & Pages → Create → Hello World → name itbunker-bot→ Deploy. Note its URL:https://bunker-bot.<something>.workers.dev. That'sPUBLIC_BASE, with no trailing slash. - Paste the code. Edit code → select all → paste the whole of
worker.js→ Deploy. - Bind the storage. Settings → Bindings → Add:
The names on the left must match exactly — the code looks for
Binding name Type Create it as CACHE KV namespace bunker-cache IMG R2 bucket bunker-images AUDIO R2 bucket bunker-audio env.CACHE. Photos, recordings, transcripts and the label queue all live in these; nothing else is stored anywhere. - Variables and secrets. Settings → Variables and Secrets. Use Secret for anything marked 🔒.
Name Value DISCORD_APP_ID from Discord DISCORD_PUBLIC_KEY from Discord DISCORD_BOT_TOKEN 🔒 from Discord DISCORD_GUILD_ID the server EBOARD_ROLE_ID the E-Board role NOTION_TOKEN 🔒 ntn_… REGISTER_SECRET 🔒 make one up — 20 random characters PRINT_SECRET 🔒 a different random string; it goes on the Pi PUBLIC_BASE the Worker's own URL, no trailing slash TZ_OFFSET -4 INVENTORY_DS … FEEDBACK_DS the eleven IDs above - The cron. Settings → Triggers → Cron Triggers → Add →
*/10 * * * *. Every scheduled job hangs off this one trigger. - Deploy again. Variables and bindings do not take effect until you do. This is the single most common way to lose twenty minutes.
- Smoke test. Open
PUBLIC_BASE/health.missingshould be empty anditemsshould be the size of the inventory. If Notion is failing, fix it here before touching Discord.
Connect them
- Point Discord at the Worker. Developer portal → General Information → Interactions Endpoint URL =
PUBLIC_BASE/with the trailing slash → Save. Discord sends a signed ping and a deliberately bad one; if it saves, signature verification works. If it refuses,DISCORD_PUBLIC_KEYis wrong or the Worker isn't deployed. - Register the commands. Open
PUBLIC_BASE/register?secret=<REGISTER_SECRET>. Expectstatus: 200. Commands appear in the server instantly. Re-run this every time a command or one of its options changes. - Try it.
/helpin any channel. Then/findsomething.
Model keys
Two free API keys, both on the club Gmail. Without them everything works except /notes.
| Variable | Get it at | Does |
|---|---|---|
| GROQ_API_KEY 🔒 | console.groq.com | Transcribes recordings. Free tier: 25 MB per file. |
| GEMINI_API_KEY 🔒 | aistudio.google.com | Writes the minutes and extracts tasks. Reads a whole meeting in one go, which Groq's free tier cannot. |
You don't need to pick a Gemini model. The Worker queries the live model list, picks the best flash-class one, and remembers it — and if that model is ever retired it rediscovers by itself. GEMINI_TEXT_MODEL exists if you ever want to pin one.
Channels
Six channels, all optional, each one a variable. Make the private ones private and add the bot to them — a bot that isn't in a channel posts nothing and says nothing about it.
| Variable | Suggested name | Visibility | Falls back to |
|---|---|---|---|
| LOG_CHANNEL_ID | #bunker-log | public | — |
| APPROVALS_CHANNEL_ID | #bunker-approvals | E-Board | log |
| ASSIGN_CHANNEL_ID | #assignments | E-Board | approvals |
| TASKS_CHANNEL_ID | #tasks | E-Board | assignments |
| MINUTES_CHANNEL_ID | #minutes | members | log |
| NUDGE_CHANNEL_ID | #retention | E-Board | approvals |
| ERRORS_CHANNEL_ID | #bot-errors | whoever owns the bot | nothing — errors are lost |
Right-click a channel → Copy Channel ID. Add the variables, deploy, and post the permanent check-in button once with /checkin-button, then pin it.
The printer
A Raspberry Pi in the bunker polls the Worker for labels and prints them. It needs only outbound Wi-Fi — no port forwarding, no domain, no tunnel.
- Raspberry Pi OS on the Pi 3, on the bunker Wi-Fi, SSH on.
- Copy the
bunker-labelsfolder over and runbash install.sh. It installs the dependencies, renders a preview label, and installs the service. - Edit
config.toml.[worker] baseisPUBLIC_BASE;secretisPRINT_SECRET. Leave the backend ondryrunfor now. sudo systemctl start labeld, then in Discord/label item:BURC-0001. The Pi's log (journalctl -u labeld -f) should show the job and write a PNG toout/./healthnow showsprinter_last_seen.- Pick a printer. The README in the folder walks through it — the Epson over Ethernet (a ten-minute test says whether it needs a driver or not), a Brother QL over USB, or anything CUPS can see. Set the backend, restart the service, print one, stick it on something.
Why the QR encodes PUBLIC_BASE/i/BURC-0001 and not the Notion page. It's shorter, so the QR is smaller and works on narrow tape. And it's permanent: if the club ever moves off Notion, one line in the Worker re-points every label ever printed. Print nothing until PUBLIC_BASE is set.
These pages
Hosted on the same Cloudflare account, free, with no domain needed.
- Cloudflare dashboard → Workers & Pages → Create → Pages → Upload assets.
- Name it
bunker-docs. Drag thedocsfolder in. Deploy. - It's live at
https://bunker-docs.pages.dev. Pin that in#general.
To update, upload the folder again — same project, new deployment, same URL. If the club ever gets access to its domain, Pages → Custom domains adds it in two clicks; nothing else changes.
Updating later
- Check the file as a module first:
cp worker.js /tmp/x.mjs && node --check /tmp/x.mjs. Plainnode --check worker.jsparses it as CommonJS and misses module-only syntax errors. - Edit code → select all → paste → Deploy.
- New variable or binding? Deploy again.
- New command or option?
/register?secret=…. - New Notion database? Connect it to the integration.
- Open
/healthand read it. - Twice a year:
TZ_OFFSETis-4from March and-5from November. Nobody else will remember.