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
30 changes: 29 additions & 1 deletion tools/folio-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,32 @@ 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

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.

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

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

## How it runs

A Cloudflare Worker on Discord's HTTP interactions, not a process on a gateway socket: Discord posts each command
Expand All @@ -32,7 +54,13 @@ 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.
it cannot be changed by editing a file. `/redeem` 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
15 changes: 14 additions & 1 deletion tools/folio-bot/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,22 @@ 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],
},
]

export const COMMANDS = DEFINITIONS.map((command) => ({ ...command, ...EVERYWHERE }))
/** 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) =>
text.length <= limit ? text : `${text.slice(0, limit - 2).trimEnd()}…`
Expand Down
5 changes: 3 additions & 2 deletions tools/folio-bot/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ 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', 'roadmap', 'screens', 'tweak', 'version'])
for (const name of names) {
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')) {
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
135 changes: 135 additions & 0 deletions tools/folio-bot/redeem.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* /redeem: a supporter code becomes a supporter role, and the role goes when the code does.
*
* The code is checked by the Ko-fi worker's own checker, not a copy of it: 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 McCal's Mac; this only ever reads codes.
*/
import { checkBetaCode, decodeCode, readCode, signedByFolio } from '../kofi-worker/beta.js'

/** Bit 5 of the scope byte, as scripts/beta-code.py and BetaCodes.SCOPE_BITS number them. */
const SCOPE_THANKS = 5

const DAY = 86_400_000
const isoDay = (time) => new Date(time).toISOString().slice(0, 10)

/**
* Which of the server's roles a code earns. Every code Folio has minted so far is tier 1, so the tier cannot tell
* Coffee from Backer: the `thanks` scope is what marks Builder (the Builder tier and tips of $15 and up), and a code
* minted with `--tier 2` is Backer. Everything else is Coffee.
*/
export function roleFor(code, env) {
if ((code.scopeBits >> SCOPE_THANKS) & 1) return { id: env.ROLE_BUILDER, name: 'Builder' }
if (code.tier >= 2) return { id: env.ROLE_BACKER, name: 'Backer' }
return { id: env.ROLE_COFFEE, name: 'Coffee' }
}

/** What the checker's reasons mean to the person who typed the code. Nothing here says more than it needs to. */
const REFUSALS = {
'not a Folio code': 'That is not a Folio code. Check it was copied whole, including the last group after the final dash.',
'not signed by Folio': 'That is not a Folio code. Check it was copied whole, including the last group after the final dash.',
'this code has no beta access': 'That code does not include supporter access.',
'this code has been withdrawn': 'That code has been withdrawn. If it was yours, message McCal and it will be sorted.',
'this code has run out': 'That code has run out. Supporting again on Ko-fi gets a new one: https://ko-fi.com/mccal',
}
const UNAVAILABLE = 'Codes cannot be checked right now. Try again in a little while.'

/** Talks to Discord as the bot. Separate so the tests can stand in for it. */
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),
}
}

export async function redeem(env, { userId, guildId, text }, discord, now = Date.now()) {
if (!env.GUILD_ID || guildId !== env.GUILD_ID) {
return 'Codes are redeemed in the Folio Community server, where the supporter roles live.'
}
if (!env.DB || !env.DISCORD_BOT_TOKEN) return UNAVAILABLE

// Ownership comes before the full check, because the full check starts a months-code's clock. A stranger who
// pastes someone else's code should be turned away without touching that code's window.
const code = readCode(decodeCode(text))
if (!code) return REFUSALS['not a Folio code']
const keys = String(env.SUPPORTER_KEYS ?? '').split(/[,\s]+/).filter(Boolean)
if (!keys.length) return UNAVAILABLE
if (!(await signedByFolio(code, keys))) return REFUSALS['not signed by Folio']

const existing = await env.DB.prepare('SELECT user_id, removed_at FROM discord_roles WHERE serial = ?')
.bind(code.serial)
.first()
Comment on lines +70 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve the serial before granting the role

When two users submit the same previously unused code concurrently, both requests can complete this SELECT before either inserts a row, and both Discord addRole calls therefore succeed. The later upsert deliberately does not update user_id, so only one user is recorded while the other keeps an untracked role that the sweep can never remove; claim the serial atomically before granting the role.

Useful? React with 👍 / 👎.

if (existing && existing.user_id !== userId) {
return 'That code has already been redeemed by someone else. If it was yours, message McCal and it will be sorted.'
}

const check = await checkBetaCode(env, text, now)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start the code window only after a successful grant

For a previously unseen months-code, checkBetaCode inserts beta_seen here before Discord is asked to add the role. If the bot lacks permission, its role is ordered incorrectly, Discord is unavailable, or the later database write fails, the command reports failure but the paid validity window has already begun, so repeated infrastructure failures consume the supporter's access without granting the requested role.

Useful? React with 👍 / 👎.

if (!check.ok) return REFUSALS[check.why] ?? UNAVAILABLE

const role = roleFor(check.code, env)
if (!role.id) return UNAVAILABLE
const endsOn = check.ends === null ? null : isoDay(check.ends)

const status = await discord.addRole(guildId, userId, role.id, `Redeemed supporter code ${code.serial}`)
if (status === 403) {
// Discord only lets a bot hand out roles below its own. This is the one that reads like nothing without a hint.
return `The code is good, but I could not give you the ${role.name} role: Mr Folio's role has to sit above ${role.name} in Server Settings › Roles. McCal can fix that, then run /redeem again.`
}
if (status >= 300) return UNAVAILABLE

const today = isoDay(now)
await env.DB.prepare(
`INSERT INTO discord_roles (serial, user_id, guild_id, role_id, granted_at, ends_on, removed_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)
ON CONFLICT(serial) DO UPDATE SET role_id = excluded.role_id, ends_on = excluded.ends_on, removed_at = NULL`,
Comment on lines +92 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Undo a role grant when persistence fails

If Discord's role grant succeeds but this D1 write throws, the caller converts the exception into the generic unavailable reply while leaving the role assigned with no discord_roles row. That role is then invisible to the expiry sweep and can remain indefinitely; reserve/persist the claim before the external grant or remove the role as compensation when recording fails.

Useful? React with 👍 / 👎.

)
.bind(code.serial, userId, guildId, role.id, today, endsOn)
.run()

const until = endsOn ? `until ${endsOn}` : 'for as long as the code lasts'
return `Thank you for supporting Folio. You have the **${role.name}** role ${until}.\nIf you typed the code anywhere public, delete that message: anyone who can see a code can use it.`
}

/**
* The daily sweep: a role whose code has ended is taken back, unless another live code of the same person still
* earns the same role. A code works through its last day, so the role goes the day after.
*/
export async function expire(env, discord, now = Date.now()) {
const today = isoDay(now)
const { results: due } = await env.DB.prepare(
'SELECT serial, user_id, guild_id, role_id FROM discord_roles WHERE removed_at IS NULL AND ends_on IS NOT NULL AND ends_on < ?',
)
Comment on lines +110 to +112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include withdrawn codes in the expiry sweep

If a serial is added to WITHDRAWN after it has been redeemed, new checks reject it but this query never selects its existing role. A permanent withdrawn code therefore retains the role forever, and a timed code retains it until its original expiry, defeating withdrawal of a shared or refunded code; the sweep should also treat withdrawn serials as due.

Useful? React with 👍 / 👎.

.bind(today)
.all()

const removed = []
for (const row of due) {
const stillEarned = await env.DB.prepare(
`SELECT 1 FROM discord_roles WHERE user_id = ? AND role_id = ? AND serial != ? AND removed_at IS NULL
AND (ends_on IS NULL OR ends_on >= ?)`,
)
.bind(row.user_id, row.role_id, row.serial, today)
.first()
if (!stillEarned) {
const status = await discord.removeRole(row.guild_id, row.user_id, row.role_id, `Supporter code ${row.serial} ended`)
// 404 is someone who has left the server: there is no role to take, so the row is closed all the same.
if (status >= 300 && status !== 404) continue
removed.push(row.serial)
}
await env.DB.prepare('UPDATE discord_roles SET removed_at = ? WHERE serial = ?').bind(today, row.serial).run()
}
return { checked: due.length, removed }
}

export { DAY }
Loading
Loading