Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 11 additions & 23 deletions tools/folio-bot/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Mr Folio

Folio's Discord bot, application `1553079678988849294`. Phase 1 of the plan in
`~/dev/folio-marketing/discord/BOT.md`: six read-only commands, no permissions, no state.
Folio's Discord bot, application `1553079678988849294`. Six read-only commands, and one button: Get release pings.

| Command | Answers with | Read from |
|---|---|---|
Expand All @@ -11,31 +10,22 @@ Folio's Discord bot, application `1553079678988849294`. Phase 1 of the plan in
| `/help [topic]` | The matching help page, or the list | foliolauncher.com's sitemap |
| `/tweak [name]` | What a tweak does and which screens it runs on | `docs/sdk/source/index.json` |
| `/screens <width>` | Whether a window that wide fits, and how many panes | `screen-matrix.json` |
| `/redeem <code>` | Your supporter role, until your code ends. Only you see the reply | The code itself, checked by `kofi-worker/beta.js` |

Every answer comes from a file the project already publishes, cached for five minutes, so the bot cannot tell anyone
something the app does not do.

## /redeem
## Get release pings

A supporter code becomes a supporter role, and the role goes when the code does. It reuses the Ko-fi worker's own
checker rather than a copy: the same signature, the same withdrawn list, and the same first-seen day for a
months-code, so the role ends on the day the code ends everywhere else. The signing key never leaves the Mac.
A button on the pinned welcome post and in `#faq`. Pressing it gives you the `Folio updates` role, which the release
announcement mentions, and pressing it again takes it back. The answer is only visible to the person who pressed. It is
`pings.mjs`; the role is `ROLE_UPDATES` in `wrangler.toml`.

**Which role.** Every code minted so far is tier 1, so the tier cannot tell Coffee from Backer. The `thanks` scope
marks Builder (the Builder tier and tips of $15 and up); a code minted with `--tier 2` is Backer; anything else is
Coffee. That is `roleFor` in `redeem.mjs`, one function, if the mapping should change.
Discord only lets a bot hand out roles below its own, so **Mr Folio's role must sit above Folio updates** in Server
Settings › Roles. If it does not, the button says exactly that rather than failing with a bare 403.

**One code, one person.** The serial is the key of `discord_roles` in the shared D1 database. A code someone else has
redeemed is refused, and it is refused before the full check runs, so a stranger pasting it cannot start its month.

**The role goes.** The cron in `wrangler.toml` runs `expire` daily at 06:17 UTC. A role is taken back the day after
its code's last day, unless another live code of the same person earns the same role. Someone who has left the
server is closed off; a Discord error is tried again the next day.

**Role order.** Discord only lets a bot hand out roles below its own, so **Mr Folio's role must sit above Builder**
in Server Settings › Roles. If it does not, `/redeem` says exactly that rather than failing with a bare 403, and
records nothing, so the person can try again once it is fixed.
There is no `/redeem`. It turned a supporter code into a Discord role and was removed on 2 Oct 2026: Ko-fi's own
Discord bot manages the supporter roles, and two systems handing out the same roles could take a role from someone who
still has it. A code is redeemed in Folio, under Settings › Supporter.

## How it runs

Expand All @@ -54,14 +44,12 @@ what Discord's own endpoint check expects.
```

The public key is on the application's **General Information** page. It is not a secret, but it lives as one so
it cannot be changed by editing a file. `/redeem` also needs the bot token:
it cannot be changed by editing a file. The release-pings button also needs the bot token:

```
cat ~/.folio-discord-bot-token | npx wrangler secret put DISCORD_BOT_TOKEN
```

and the `discord_roles` table, which is in `tools/kofi-worker/schema.sql` beside the tables it shares.

2. **Point Discord at it.** Same page, **Interactions Endpoint URL**, the `folio-bot` workers.dev address. Discord
sends two deliberately bad requests when you save; the page only saves if the Worker refuses both.

Expand Down
13 changes: 0 additions & 13 deletions tools/folio-bot/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,8 @@ const DEFINITIONS = [
description: 'Whether Folio fits a screen that size',
options: [{ name: 'width', description: 'Width in dp, for example 932', type: 4, required: true, min_value: 1 }],
},
{
name: 'redeem',
description: 'Turn your supporter code into your supporter role',
options: [{ name: 'code', description: 'The code from your Ko-fi email', type: 3, required: true, min_length: 20 }],
// Server-installed and server channels only: the roles live in the Folio server, and a code typed anywhere
// else would be a code typed somewhere it can be seen for nothing.
integration_types: [0],
contexts: [0],
},
]

/** Commands whose answers only the person who asked can see. A code's reply never sits in a channel. */
export const PRIVATE = new Set(['redeem'])

// A command's own integration_types and contexts win over the default, which is how /redeem stays in the server.
export const COMMANDS = DEFINITIONS.map((command) => ({ ...EVERYWHERE, ...command }))

const trim = (text, limit = LIMIT) =>
Expand Down
5 changes: 2 additions & 3 deletions tools/folio-bot/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ const sources = {

test('every registered command has a handler, and every handler is registered', async () => {
const names = COMMANDS.map((command) => command.name).sort()
assert.deepEqual(names, ['changelog', 'help', 'redeem', 'roadmap', 'screens', 'tweak', 'version'])
// /redeem needs the database and the bot token, so the worker routes it to redeem.mjs rather than these handlers.
for (const name of names.filter((one) => one !== 'redeem')) {
assert.deepEqual(names, ['changelog', 'help', 'roadmap', 'screens', 'tweak', 'version'])
for (const name of names) {
const answer = await run(name, name === 'screens' ? { width: 932 } : {}, sources)
assert.ok(answer.length > 0, `${name} said nothing`)
assert.ok(answer.length <= LIMIT, `${name} was ${answer.length} characters`)
Expand Down
18 changes: 18 additions & 0 deletions tools/folio-bot/discord.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Talks to Discord as the bot, for the one thing it still does there: giving and taking back the release-ping role. */
export function discordFor(env, send = fetch) {
const call = async (method, path, reason) => {
const response = await send(`https://discord.com/api/v10${path}`, {
method,
headers: {
authorization: `Bot ${env.DISCORD_BOT_TOKEN}`,
// Shows in the server's audit log, so McCal can see why a role changed hands.
'x-audit-log-reason': encodeURIComponent(reason),
},
})
return response.status
}
return {
addRole: (guild, user, role, reason) => call('PUT', `/guilds/${guild}/members/${user}/roles/${role}`, reason),
removeRole: (guild, user, role, reason) => call('DELETE', `/guilds/${guild}/members/${user}/roles/${role}`, reason),
}
}
135 changes: 0 additions & 135 deletions tools/folio-bot/redeem.mjs

This file was deleted.

Loading
Loading