diff --git a/tools/folio-bot/README.md b/tools/folio-bot/README.md index dccbbe7a..e65a04e6 100644 --- a/tools/folio-bot/README.md +++ b/tools/folio-bot/README.md @@ -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 | |---|---|---| @@ -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 ` | Whether a window that wide fits, and how many panes | `screen-matrix.json` | -| `/redeem ` | 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 @@ -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. diff --git a/tools/folio-bot/commands.mjs b/tools/folio-bot/commands.mjs index ded6aca9..bf864f6f 100644 --- a/tools/folio-bot/commands.mjs +++ b/tools/folio-bot/commands.mjs @@ -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) => diff --git a/tools/folio-bot/commands.test.mjs b/tools/folio-bot/commands.test.mjs index c5003bb7..4296f753 100644 --- a/tools/folio-bot/commands.test.mjs +++ b/tools/folio-bot/commands.test.mjs @@ -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`) diff --git a/tools/folio-bot/discord.mjs b/tools/folio-bot/discord.mjs new file mode 100644 index 00000000..88092699 --- /dev/null +++ b/tools/folio-bot/discord.mjs @@ -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), + } +} diff --git a/tools/folio-bot/redeem.mjs b/tools/folio-bot/redeem.mjs deleted file mode 100644 index 0f2d186f..00000000 --- a/tools/folio-bot/redeem.mjs +++ /dev/null @@ -1,135 +0,0 @@ -/** - * /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() - 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) - 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`, - ) - .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 < ?', - ) - .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 } diff --git a/tools/folio-bot/redeem.test.mjs b/tools/folio-bot/redeem.test.mjs deleted file mode 100644 index 3e6abcf0..00000000 --- a/tools/folio-bot/redeem.test.mjs +++ /dev/null @@ -1,207 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { DatabaseSync } from 'node:sqlite' -import { expire, redeem, roleFor } from './redeem.mjs' - -// A throwaway signing key, so these codes are signed exactly as McCal's Mac signs real ones: ECDSA P-256, SHA-256, -// the 9-byte payload followed by the 64-byte signature. -const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) -const spki = Buffer.from(await crypto.subtle.exportKey('spki', pair.publicKey)).toString('base64') -const stranger = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) - -const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' -const EPOCH = Date.UTC(2026, 0, 1) -const DAY = 86_400_000 -const SCOPES = ['beta', 'look', 'power', 'keys', 'dev', 'thanks'] - -function crockford(bytes) { - let out = '' - let buffer = 0 - let bits = 0 - for (const byte of bytes) { - buffer = ((buffer << 8) | byte) & 0xffff - bits += 8 - while (bits >= 5) { - bits -= 5 - out += ALPHABET[(buffer >> bits) & 31] - } - } - if (bits > 0) out += ALPHABET[(buffer << (5 - bits)) & 31] - return out -} - -/** Mints a code the way scripts/beta-code.py does, including the version-2 months-and-tier byte. */ -async function mint({ scopes = ['beta', 'look', 'power', 'keys'], tier = 1, months = 1, expires = null, serial = 1, key = pair.privateKey } = {}) { - const bits = scopes.reduce((all, scope) => all | (1 << SCOPES.indexOf(scope)), 0) - const version = months > 0 ? 2 : 1 - const tierByte = months > 0 ? (months << 4) | tier : tier - const day = expires ? Math.round((Date.parse(expires) - EPOCH) / DAY) : 0 - const payload = new Uint8Array([version, bits, tierByte, day >> 8, day & 0xff, serial >>> 24, (serial >> 16) & 0xff, (serial >> 8) & 0xff, serial & 0xff]) - const signature = new Uint8Array(await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, payload)) - return crockford(new Uint8Array([...payload, ...signature])) -} - -/** Real SQLite, loaded with the Ko-fi worker's real schema, behind the few calls D1 is asked for. */ -function database() { - const db = new DatabaseSync(':memory:') - db.exec(readFileSync(new URL('../kofi-worker/schema.sql', import.meta.url), 'utf8')) - return { - raw: db, - prepare(sql) { - const statement = db.prepare(sql) - let values = [] - const api = { - bind: (...args) => { values = args; return api }, - run: async () => { statement.run(...values); return { success: true } }, - first: async () => statement.get(...values) ?? null, - all: async () => ({ results: statement.all(...values) }), - } - return api - }, - } -} - -const ROLES = { ROLE_COFFEE: 'coffee-role', ROLE_BACKER: 'backer-role', ROLE_BUILDER: 'builder-role' } -const GUILD = 'guild-1' -const NOW = Date.UTC(2026, 8, 25, 12) - -function setup(overrides = {}) { - const env = { DB: database(), DISCORD_BOT_TOKEN: 'token', GUILD_ID: GUILD, SUPPORTER_KEYS: spki, ...ROLES, ...overrides } - const calls = [] - let status = 204 - const discord = { - addRole: async (...args) => { calls.push(['add', ...args]); return status }, - removeRole: async (...args) => { calls.push(['remove', ...args]); return status }, - answer: (next) => { status = next }, - } - const rows = () => env.DB.raw.prepare('SELECT * FROM discord_roles ORDER BY serial').all() - return { env, calls, discord, rows } -} - -test('a plain code earns Coffee, until the day its month runs out', async () => { - const { env, calls, discord, rows } = setup() - const reply = await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 7 }) }, discord, NOW) - assert.match(reply, /\*\*Coffee\*\* role until 2026-10-25/) - assert.deepEqual(calls, [['add', GUILD, 'u1', 'coffee-role', 'Redeemed supporter code 7']]) - const [row] = rows() - assert.equal(row.serial, 7) - assert.equal(row.user_id, 'u1') - assert.equal(row.ends_on, '2026-10-25') -}) - -test('the thanks scope earns Builder, and tier 2 earns Backer', async () => { - const builder = setup() - const code = await mint({ scopes: ['beta', 'look', 'power', 'keys', 'thanks'], months: 2, serial: 8 }) - assert.match(await redeem(builder.env, { userId: 'u1', guildId: GUILD, text: code }, builder.discord, NOW), /\*\*Builder\*\*.*2026-11-25/) - assert.equal(builder.calls[0][3], 'builder-role') - - const backer = setup() - await redeem(backer.env, { userId: 'u1', guildId: GUILD, text: await mint({ tier: 2, serial: 9 }) }, backer.discord, NOW) - assert.equal(backer.calls[0][3], 'backer-role') -}) - -test('roleFor reads the scope before the tier, since every code so far is tier 1', () => { - assert.equal(roleFor({ scopeBits: 0b100001, tier: 1 }, ROLES).name, 'Builder') - assert.equal(roleFor({ scopeBits: 0b100001, tier: 2 }, ROLES).name, 'Builder') - assert.equal(roleFor({ scopeBits: 0b000001, tier: 2 }, ROLES).name, 'Backer') - assert.equal(roleFor({ scopeBits: 0b001111, tier: 1 }, ROLES).name, 'Coffee') -}) - -test('a code signed by anyone else is refused, and nothing is granted or recorded', async () => { - const { env, calls, discord, rows } = setup() - const forged = await mint({ serial: 10, key: stranger.privateKey }) - assert.match(await redeem(env, { userId: 'u1', guildId: GUILD, text: forged }, discord, NOW), /not a Folio code/) - assert.equal(calls.length, 0) - assert.equal(rows().length, 0) -}) - -test('text that is not a code at all gets the same answer as a forged one', async () => { - const { env, discord } = setup() - assert.match(await redeem(env, { userId: 'u1', guildId: GUILD, text: 'hello there' }, discord, NOW), /not a Folio code/) -}) - -test('a withdrawn code is refused', async () => { - const { env, calls, discord } = setup({ WITHDRAWN: '11, 12' }) - assert.match(await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 11 }) }, discord, NOW), /withdrawn/) - assert.equal(calls.length, 0) -}) - -test('a code that has passed its last day is refused', async () => { - const { env, discord } = setup() - const old = await mint({ months: 0, expires: '2026-09-01', serial: 13 }) - assert.match(await redeem(env, { userId: 'u1', guildId: GUILD, text: old }, discord, NOW), /run out/) -}) - -test("someone else's code is refused without starting its month", async () => { - const { env, calls, discord } = setup() - const code = await mint({ serial: 14 }) - await redeem(env, { userId: 'owner', guildId: GUILD, text: code }, discord, NOW) - env.DB.raw.prepare('DELETE FROM beta_seen').run() - - const reply = await redeem(env, { userId: 'stranger', guildId: GUILD, text: code }, discord, NOW) - assert.match(reply, /already been redeemed by someone else/) - assert.equal(calls.length, 1, 'only the owner was ever given a role') - const seen = env.DB.raw.prepare('SELECT count(*) AS n FROM beta_seen').get() - assert.equal(seen.n, 0, "the stranger's attempt must not touch the code's clock") -}) - -test('redeeming the same code twice keeps one record', async () => { - const { env, discord, rows } = setup() - const code = await mint({ serial: 15 }) - await redeem(env, { userId: 'u1', guildId: GUILD, text: code }, discord, NOW) - await redeem(env, { userId: 'u1', guildId: GUILD, text: code }, discord, NOW + DAY) - assert.equal(rows().length, 1) - assert.equal(rows()[0].ends_on, '2026-10-25', 'the second go must not stretch the month') -}) - -test('a role Discord will not hand out says why, and is not recorded as given', async () => { - const { env, discord, rows } = setup() - discord.answer(403) - const reply = await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 16 }) }, discord, NOW) - assert.match(reply, /Mr Folio's role has to sit above Coffee/) - assert.equal(rows().length, 0, 'recording it would block the owner from trying again once the role order is fixed') -}) - -test('codes are only redeemed in the Folio server', async () => { - const { env, calls, discord } = setup() - assert.match(await redeem(env, { userId: 'u1', guildId: 'elsewhere', text: await mint() }, discord, NOW), /Folio Community server/) - assert.equal(calls.length, 0) -}) - -test('the sweep takes a role back the day after its code ends', async () => { - const { env, calls, discord, rows } = setup() - await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 20 }) }, discord, NOW) - calls.length = 0 - - assert.deepEqual((await expire(env, discord, Date.UTC(2026, 9, 25, 12))).removed, [], 'still its last day') - const later = await expire(env, discord, Date.UTC(2026, 9, 26, 12)) - assert.deepEqual(later.removed, [20]) - assert.deepEqual(calls, [['remove', GUILD, 'u1', 'coffee-role', 'Supporter code 20 ended']]) - assert.equal(rows()[0].removed_at, '2026-10-26') -}) - -test('a newer code that earns the same role keeps it', async () => { - const { env, calls, discord, rows } = setup() - await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 21 }) }, discord, NOW) - await redeem(env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 22 }) }, discord, NOW + 20 * DAY) - calls.length = 0 - - const result = await expire(env, discord, Date.UTC(2026, 9, 26, 12)) - assert.deepEqual(result.removed, []) - assert.equal(calls.length, 0, 'the role is still earned by code 22') - assert.equal(rows().find((row) => row.serial === 21).removed_at, '2026-10-26', 'code 21 is closed all the same') -}) - -test('someone who left the server is closed off, and a Discord error is tried again tomorrow', async () => { - const gone = setup() - await redeem(gone.env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 30 }) }, gone.discord, NOW) - gone.discord.answer(404) - assert.deepEqual((await expire(gone.env, gone.discord, Date.UTC(2026, 9, 26))).removed, [30]) - - const flaky = setup() - await redeem(flaky.env, { userId: 'u1', guildId: GUILD, text: await mint({ serial: 31 }) }, flaky.discord, NOW) - flaky.discord.answer(500) - assert.deepEqual((await expire(flaky.env, flaky.discord, Date.UTC(2026, 9, 26))).removed, []) - assert.equal(flaky.rows()[0].removed_at, null) -}) diff --git a/tools/folio-bot/worker.js b/tools/folio-bot/worker.js index 799aed5e..f66279fa 100644 --- a/tools/folio-bot/worker.js +++ b/tools/folio-bot/worker.js @@ -6,10 +6,10 @@ * same `wrangler deploy` as the supporter worker. * * Secrets: DISCORD_PUBLIC_KEY (from the application's General Information page) checks that a request came from - * Discord. DISCORD_BOT_TOKEN lets /redeem hand out a role and the daily sweep take it back; nothing else uses it. + * Discord. DISCORD_BOT_TOKEN lets the "Get release pings" button give and take back the Folio updates role; nothing else uses it. */ -import { PRIVATE, optionsOf, run } from './commands.mjs' -import { discordFor, expire, redeem } from './redeem.mjs' +import { optionsOf, run } from './commands.mjs' +import { discordFor } from './discord.mjs' import { PINGS_BUTTON, togglePings } from './pings.mjs' import { sources as liveSources } from './sources.mjs' @@ -106,49 +106,23 @@ export function createWorker({ if (interaction.type !== APPLICATION_COMMAND) return json({ type: PONG }) const name = interaction.data?.name - const secret = PRIVATE.has(name) - const options = optionsOf(interaction) - const answer = - name === 'redeem' - ? redeem( - env, - { - // In a server the person is interaction.member.user; in a DM it would be interaction.user. - userId: interaction.member?.user?.id ?? interaction.user?.id, - guildId: interaction.guild_id, - text: options.code, - }, - discord(env), - ).catch((error) => { - console.error(`redeem failed: ${error.message}`) - return 'Codes cannot be checked right now. Try again in a little while.' - }) - : run(name, options, sources) + const answer = run(name, optionsOf(interaction), sources) const timer = new Promise((resolve) => setTimeout(() => resolve(null), wait)) const quick = await Promise.race([answer, timer]) - if (quick !== null) return json({ type: CHANNEL_MESSAGE, data: message(quick, secret) }) + if (quick !== null) return json({ type: CHANNEL_MESSAGE, data: message(quick) }) // Too slow to answer in place. Say so now, and edit the real answer in when it lands. The interaction's own // token authorises the edit, so no bot token is involved. const followUp = answer.then((content) => send( `https://discord.com/api/v10/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`, - { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message(content, secret)) }, + { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message(content)) }, ).then((response) => { if (!response.ok) console.error(`Could not edit the answer in: Discord said ${response.status}`) }), ) ctx?.waitUntil?.(followUp) - // The deferral decides who can see the answer that follows, so a private one has to be private from here. - return json(secret ? { type: DEFERRED_CHANNEL_MESSAGE, data: { flags: EPHEMERAL } } : { type: DEFERRED_CHANNEL_MESSAGE }) - }, - - /** The daily sweep, from the cron in wrangler.toml: roles whose codes have ended are taken back. */ - async scheduled(_event, env, ctx) { - if (!env.DB || !env.DISCORD_BOT_TOKEN) return - ctx.waitUntil( - expire(env, discord(env)).then((result) => console.log(`sweep: ${result.checked} ended, ${result.removed.length} roles taken back`)), - ) + return json({ type: DEFERRED_CHANNEL_MESSAGE }) }, } } diff --git a/tools/folio-bot/worker.test.mjs b/tools/folio-bot/worker.test.mjs index 3832b9d9..72b397de 100644 --- a/tools/folio-bot/worker.test.mjs +++ b/tools/folio-bot/worker.test.mjs @@ -125,36 +125,17 @@ test('a slow answer is deferred inside the deadline, then edited in with the int assert.deepEqual(edited.allowed_mentions, { parse: [] }) }) -test('every command says where it can be used, and /redeem only works in a server', async () => { +test('every command can be used anywhere: servers, DMs and group chats', async () => { const { COMMANDS } = await import('./commands.mjs') for (const command of COMMANDS) { - if (command.name === 'redeem') { - assert.deepEqual(command.integration_types, [0]) - assert.deepEqual(command.contexts, [0]) - continue - } assert.deepEqual(command.integration_types, [0, 1], command.name) assert.deepEqual(command.contexts, [0, 1, 2], command.name) } }) -test("/redeem's answer is private, whether it comes back in place or after a deferral", async () => { - const redeemBody = JSON.stringify({ - type: 2, application_id: 'app', token: 'tok', guild_id: 'somewhere-else', - member: { user: { id: 'u1' } }, data: { name: 'redeem', options: [{ name: 'code', value: 'ABCDEFGHJKMNPQRSTVWX' }] }, - }) - const quick = createWorker({ discord: () => ({}) }) - const inPlace = await (await quick.fetch(await signed(redeemBody), { ...env, GUILD_ID: 'folio' }, {})).json() - assert.equal(inPlace.type, 4) - assert.equal(inPlace.data.flags & 64, 64, 'the reply must be ephemeral') - assert.match(inPlace.data.content, /Folio Community server/) - - // A zero wait forces the deferral path, which is the one that decides privacy for the edit that follows. - let finished - const slow = createWorker({ discord: () => ({}), wait: 0, send: async () => new Response(null, { status: 200 }) }) - const deferred = await (await slow.fetch(await signed(redeemBody), { ...env, GUILD_ID: 'folio' }, { waitUntil(p) { finished = p } })).json() - await finished - if (deferred.type === 5) assert.equal(deferred.data?.flags, 64, 'a public deferral would make the answer public') +test('there is no /redeem any more: a signed one is answered like any unknown command', async () => { + const { COMMANDS } = await import('./commands.mjs') + assert.ok(!COMMANDS.some((command) => command.name === 'redeem')) }) test('the read-only answers stay public', async () => { diff --git a/tools/folio-bot/wrangler.toml b/tools/folio-bot/wrangler.toml index 29b3fa69..d3657330 100644 --- a/tools/folio-bot/wrangler.toml +++ b/tools/folio-bot/wrangler.toml @@ -15,26 +15,7 @@ account_id = "2ac16bbf295c2dacf6e2d7c135c8ebdb" # The bot answers on its workers.dev address. It needs no custom domain: only Discord ever calls it. workers_dev = true -# The same database as the Ko-fi worker. /redeem records one code to one person in discord_roles, and a months-code's -# window runs from the same first-seen day in beta_seen, so a role ends when the code ends everywhere else. -[[d1_databases]] -binding = "DB" -database_name = "folio-codes" -database_id = "a878ea66-ca8b-426a-8ed9-ecdcf0c5e9ed" - [vars] -# The public half of the supporter key: BetaKeys.SUPPORTER in the app, and the same line as the Ko-fi worker's. -SUPPORTER_KEYS = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEM4vVq0D/ZzqkVWlyQYMTFN3TTbdqRgKdt2a30T88NkeQhDEWnujFjfyXyKtPIVBKMSiRVXeY/OcC0+TaB6eypg==" -# Keep in step with BetaKeys.WITHDRAWN and the Ko-fi worker: serials that no longer work, separated by commas. -WITHDRAWN = "" GUILD_ID = "1551382087985266838" -# McCal's three supporter roles. The bot never creates one; it only hands these out and takes them back. -ROLE_COFFEE = "1552857170033508472" -ROLE_BACKER = "1552857199267545178" -ROLE_BUILDER = "1552857224194560141" # The ping role the "Get release pings" button hands out and takes back. ROLE_UPDATES = "1553093586705449062" - -# The daily sweep that takes back roles whose codes have ended. 06:17 UTC, clear of the hour everyone else picks. -[triggers] -crons = ["17 6 * * *"] diff --git a/tools/kofi-worker/schema.sql b/tools/kofi-worker/schema.sql index 64dad969..69a681ec 100644 --- a/tools/kofi-worker/schema.sql +++ b/tools/kofi-worker/schema.sql @@ -43,8 +43,9 @@ CREATE TABLE IF NOT EXISTS checks ( at TEXT NOT NULL ); --- Supporter roles handed out in Discord by Mr Folio's /redeem. One code, one person: the serial is the key, so a code --- redeemed by someone else is refused rather than granted twice. The daily cron takes the role back after ends_on. +-- Unused since 2 Oct 2026. Mr Folio's /redeem used to hand out supporter roles in Discord and record them here, one code to +-- one person, with a daily cron taking the role back after ends_on. /redeem was removed (Ko-fi's own bot manages those +-- roles), so nothing reads or writes this table now. It is kept, not dropped, in case a row is ever worth a look. CREATE TABLE IF NOT EXISTS discord_roles ( serial INTEGER PRIMARY KEY, user_id TEXT NOT NULL,