Lesson 46: A Chat Bot for the Club
Learning Objectives
- Explain how a chat platform calls your code when someone uses a slash command.
- Verify that a request really comes from Discord, by checking its signature.
- Register a slash command with Discord's API, keeping the bot's token secret.
- Answer a command from a Cloudflare Worker, using the club's database.
- Compare bots on Discord, Telegram and Slack.
46.1 When the Platform Calls You
In Lesson 17. Talking to Web Services, your code posted to a Discord channel through a webhook: your code called Discord. A slash command works the other way around. When a member types /nextshift in the club's Discord server, Discord sends a request to an address you chose, and your code's response becomes the bot's reply. Your Worker is now the server, and Discord is the client.
That changes the security question. A webhook address was a secret you kept. An address that Discord calls is one anyone could call, pretending to be Discord. So every request has to be checked, and Discord makes that possible by signing each one.
Slash command — A command in a chat app, such as
/nextshift, that the platform sends to a bot's code, and whose response it shows as the bot's reply.
46.2 Asking for the Command
Here's the request, in a new chat with your learner profile from Lesson 45. A Full-Stack App. It includes the Worker's code, following your rule about changing existing code:
Here is our sign-up app's Cloudflare Worker, src/index.js: (then paste the file) Our club talks on Discord. I want a slash command, /nextshift, that replies with the next shift that still has spots left, handled by this same Worker at the path /discord. Explain the setup in the Discord Developer Portal, how the Worker should verify that requests really come from Discord, how to register the command, and where the bot's secrets go. Use no packages in the Worker if you can.sendThis is a good multi-part task. I'll walk through it in order: Portal setup, how verification works, the Worker code, registering the command, and where secrets live.
One reminder up front: you'll be calling Discord's API and running a bot inside their platform, so it's worth glancing at Discord's Developer Terms of Service and Developer Policy to make sure a simple club sign-up bot fits within them (it should — this is exactly what the interactions API is for).
Part 1: Discord Developer Portal setup
- Go to https://discord.com/developers/applications and click New Application. Name it something like "Garden Shifts".
- On the General Information page, note two values you'll need:
- Application ID
- Public Key (this one isn't secret — it's used to verify signatures, not create them)
- Leave the Interactions Endpoint URL field blank for now — you'll fill it in after the Worker is deployed, because Discord immediately tests it.
- Go to the Bot tab, click Add Bot if there isn't one, and copy the Bot Token. This is secret — treat it like a password. You only need it to register the command (Part 4), not while the Worker is running.
- Go to OAuth2 → URL Generator, check the scope applications.commands (you don't need the "bot" scope just to reply to slash commands), copy the generated URL, open it, and add the app to your club's server.
Part 2: How verification works
Every request Discord sends to your endpoint is signed with a private key only Discord holds. Your Worker checks the signature using the public key from step 2. Two headers arrive with each request:
X-Signature-Ed25519(the signature) andX-Signature-Timestamp. The rule is: take the timestamp joined with the exact raw text of the request body, and check that against the signature.Two things here are new for you, so I want to flag them rather than use them silently:
crypto.subtleis part of the Web Crypto API, a built-in cryptography toolkit available in both browsers and Cloudflare Workers. We'll useimportKey(load the public key in a usable format) andverify(check the signature).parseInt(text, base)reads text as a number in a given base — we need base 16 because the keys and signatures arrive as hex text. It's likeNumber()but lets you pick the base.One honest caveat: I'm fairly confident Cloudflare Workers support the algorithm name
"Ed25519"with raw-format keys incrypto.subtle, since Cloudflare added this a while back — but I haven't personally run this exact code against Discord's endpoint check. Deploy it, then try setting the Interactions Endpoint URL in the Portal — if Discord reports it can't verify the endpoint, tell me, and we'll fall back to thediscord-interactionspackage (published by Discord itself on npm, widely used) which does the same check.Part 3: Worker code
Add this to
src/index.js:// checks that a request to /discord was really signed by Discord async function verifyDiscordRequest(request, publicKeyHex) { const signature = request.headers.get("x-signature-ed25519") const timestamp = request.headers.get("x-signature-timestamp") const body = await request.text() // raw text - the signature covers these exact bytes if (signature === null || timestamp === null) { return { valid: false, body } } const key = await crypto.subtle.importKey( "raw", hexToBytes(publicKeyHex), { name: "Ed25519" }, false, ["verify"] ) const message = new TextEncoder().encode(timestamp + body) const valid = await crypto.subtle.verify("Ed25519", key, hexToBytes(signature), message) return { valid, body } } // turns hex text like "1a2b" into raw bytes function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) } return bytes } // handles Discord's interactions - both its "are you alive" check and /nextshift async function handleDiscord(request, env) { const { valid, body } = await verifyDiscordRequest(request, env.DISCORD_PUBLIC_KEY) if (valid === false) { return new Response("Bad signature", { status: 401 }) } const interaction = JSON.parse(body) // type 1 is Discord's endpoint check - answer it the same way, every time if (interaction.type === 1) { return json({ type: 1 }) } // type 2 is someone running a slash command if (interaction.type === 2 && interaction.data && interaction.data.name === "nextshift") { const shift = await env.DB.prepare( `${SHIFT_QUERY} GROUP BY slots.id HAVING COUNT(slotSignups.id) < slots.capacity ORDER BY slots.date, slots.start LIMIT 1` ).first() const content = shift === null ? "No upcoming shifts have open spots." : `Next open shift: ${shift.task} on ${shift.date}, ${shift.start}–${shift.end} (${shift.spotsFilled}/${shift.capacity} filled)` // type 4 means "reply immediately with this message" return json({ type: 4, data: { content } }) } return json({ error: "Unhandled interaction" }, 400) }And add a route in
fetch, alongside your other routes:if (method === "POST" && parts.length === 1 && parts[0] === "discord") { return handleDiscord(request, env) }The
HAVING COUNT(slotSignups.id) < slots.capacityline filters after grouping, keeping only shifts that aren't full —HAVINGis needed instead ofWHEREhere because it checks a totalled (aggregate) value.Part 4: Registering the command
Registering a command is a one-time (or occasional) call to Discord's REST API, done with your bot token — not something the Worker itself needs to do. Run it locally as a small script, separate from the Worker:
// register-command.js // Run with: node --env-file=.env register-command.js const appId = process.env.DISCORD_APP_ID const guildId = process.env.DISCORD_GUILD_ID // your club's server ID - guild commands show up instantly, global ones take up to an hour const token = process.env.DISCORD_BOT_TOKEN const command = { name: "nextshift", description: "Show the next shift that still has open spots", type: 1 // a slash command } const url = `https://discord.com/api/v10/applications/${appId}/guilds/${guildId}/commands` const response = await fetch(url, { method: "POST", headers: { "Authorization": `Bot ${token}`, "content-type": "application/json" }, body: JSON.stringify(command) }) if (response.ok === false) { throw new Error(`Discord replied with ${response.status}: ${await response.text()}`) } console.log("Command registered:", await response.json())Find your server's ID by right-clicking the server icon in Discord with Developer Mode on (User Settings → Advanced → Developer Mode).
Part 5: Where secrets go
Value Secret? Where it lives Bot Token Yes .envfile next toregister-command.js, read asprocess.env.DISCORD_BOT_TOKEN. Never in the Worker. Add.envto.gitignore.Application ID, Guild ID Not really secret, but config Same .envfile, for conveniencePublic Key Not secret (it's for verifying, not signing) Set as a Cloudflare Worker secret with wrangler secret put DISCORD_PUBLIC_KEY, read in the Worker asenv.DISCORD_PUBLIC_KEYThe Worker never needs the bot token, since it only replies to interactions directly — it doesn't call Discord's API itself.
Part 6: Testing it
- Deploy the Worker first:
wrangler deploy.- In the Portal's General Information page, set Interactions Endpoint URL to
https://your-worker.workers.dev/discordand save. Discord sends a type-1 PING right then — if verification fails, it'll refuse to save and tell you.- Run
register-command.jsonce.- In your club's Discord server, type
/nextshiftand confirm it replies with a shift.- Try editing a slot's capacity or filling it completely in D1, then run
/nextshiftagain to confirm it skips full shifts.Let me know how the endpoint verification step goes — that's the part I'd like you to actually test, since I flagged some uncertainty about the exact Web Crypto call.
This reply is long because the job has five parts, and it takes them in order. It also follows two of your rules without being asked: it points to Discord's developer terms, and it's clear about the one thing it hadn't tested itself.
46.2.1 The Developer Portal
A Discord bot starts as an application in the Developer Portal. It has three values you'll use: an Application ID, which identifies it; a Public Key, used to check signatures; and a Bot Token, which is a password for the bot. Adding the application to the club's server with the applications.commands scope is what lets its commands appear there. Discord's portal changes its layout from time to time, so check the steps against your screen; the names of the three values have stayed the same for years.
The Discord Developer Portal's General Information page for an application named Garden Shifts, showing the Application ID and Public Key fields and the empty Interactions Endpoint URL field.
46.2.2 Checking the signature
Every request Discord sends has two headers: a timestamp and a signature, made with a private key that only Discord has. Anyone can check the signature with Discord's public key, but nobody can make a valid one without the private key. So if the check passes, the request came from Discord, and nobody changed it on the way.
Digital signature — A value made from a message with a private key, which anyone can check with the matching public key. A valid signature proves who sent the message, and that it wasn't changed.
Read verifyDiscordRequest carefully, because the details matter:
- The body is read with
request.text(), as the raw text, before parsing it. The signature covers the exact characters Discord sent; parsing the JSON and turning it back into text could change spacing and break the check. crypto.subtleis the Web Crypto API, built into Workers, browsers and Node.importKeyloads the public key, andverifychecks the signature against the timestamp joined with the body, using the Ed25519 method that Discord uses.- The key and signature arrive as hex text, pairs of characters from 0 to 9 and a to f, each pair one byte.
hexToBytesconverts them, withparseInt(text, 16), which reads a number written in base 16.
Then handleDiscord returns 401 for anything that fails the check, answers Discord's test request, type: 1, with the same type, and answers the command, type: 2, with a message.
The assistant said it hadn't run this exact code, and asked you to test it by setting the Interactions Endpoint URL, when Discord sends a test request and refuses the address unless it passes. That's the right test. The verification can also be checked on your own computer, with a key pair you make yourself, which is what this book did: a correctly signed request passed, and the same signature with a changed body failed. That's what you want from a signature check, and it's worth knowing how to test security code without trusting it.
46.2.3 The query
The query finds the first shift that isn't full, using HAVING, which the reply explains well: WHERE filters rows before they're grouped, and HAVING filters groups after they're counted, so it can compare the count with the capacity. For the club's data, it finds the harvesting shift on June 5, 2027, with 1 of 3 spots filled. A real version would also skip shifts whose date has passed, with WHERE slots.date >= ? and today's date.
46.2.4 Registering and secrets
Slash commands are registered once, by a small script you run on your own computer, which sends the command's name and description to Discord's API with the bot token. The table of secrets is exactly right, and it's the most important part of the reply:
- The bot token stays in a
.envfile on your computer, used only by the registration script, as in Lesson 39. Calling AI Models from Your Code. The Worker never needs it, because it only answers requests. - The public key isn't secret at all; that's what "public" means. Storing it with
wrangler secret putis harmless, and keeps all the Worker's settings in one place.
A leaked bot token is an emergency. Anyone with it can act as your bot in every server it's in. If a token ends up in code you've shared, in a chat or in a screenshot, reset it immediately in the Developer Portal's Bot page, which makes the old one stop working.
Here's the order that works: deploy the Worker, set its /discord address as the Interactions Endpoint URL in the portal, register the command, then type /nextshift in the club's server. For the club's data, the code builds this reply, which Discord shows as a message from the bot:
Next open shift: harvesting on 2027-06-05, 09:00–11:00 (1/3 filled)
46.3 Other Chat Platforms
Discord's approach is typical, but not the only one. Two others the club might use:
- Telegram bots are the easiest to set up: you talk to Telegram's own bot, [@BotFather], which gives you a token in a minute. Telegram then sends messages to your address, a webhook in Telegram's terms, with a secret token in a header that you check, instead of a signature.
- Slack apps work much like Discord's: commands are sent to your address, signed, and you verify the signature with a signing secret. Slack also offers Socket Mode, where your code connects out to Slack instead of waiting for requests, which works even without a public address, and its official Bolt library handles the details.
Whichever platform, the same three questions apply: how does the platform reach your code, how do you know a request really came from it, and where does the bot's secret live?
One thing to avoid, for a club: bots that read every message in a channel. They need broad permissions, raise privacy questions for every member, and are rarely needed. Commands, which members choose to use, are enough.
46.4 Your Learner Profile
That's the end of Part VII. Your environment line still describes servers and Workers, and in the last part of the book, you'll change it one final time.
46.5 Summary
A slash command reverses the direction of a webhook: when a member types /nextshift, Discord calls your Worker, and your response becomes the bot's reply. Because anyone could call the same address, every request must be verified: Discord signs each one with a private key, and your code checks the signature against the exact raw body with Discord's public key, using the built-in Web Crypto API. Security code like this can, and should, be tested with a key pair of your own. Commands are registered once, by a script that uses the bot token, which stays in a .env file and never in the Worker. Telegram and Slack follow the same pattern with their own ways of proving a request is genuine. That completes the club's system: a website, an API, a database, an app and a bot. Next, you'll look at the other places JavaScript runs, and plan what to learn next on your own.