diff --git a/tools/folio-bot/README.md b/tools/folio-bot/README.md index 1f1a7c33..dccbbe7a 100644 --- a/tools/folio-bot/README.md +++ b/tools/folio-bot/README.md @@ -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 ` | 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 + +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 @@ -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. diff --git a/tools/folio-bot/commands.mjs b/tools/folio-bot/commands.mjs index a402a795..ded6aca9 100644 --- a/tools/folio-bot/commands.mjs +++ b/tools/folio-bot/commands.mjs @@ -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()}…` diff --git a/tools/folio-bot/commands.test.mjs b/tools/folio-bot/commands.test.mjs index 4296f753..c5003bb7 100644 --- a/tools/folio-bot/commands.test.mjs +++ b/tools/folio-bot/commands.test.mjs @@ -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`) diff --git a/tools/folio-bot/redeem.mjs b/tools/folio-bot/redeem.mjs new file mode 100644 index 00000000..0f2d186f --- /dev/null +++ b/tools/folio-bot/redeem.mjs @@ -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() + 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 new file mode 100644 index 00000000..3e6abcf0 --- /dev/null +++ b/tools/folio-bot/redeem.test.mjs @@ -0,0 +1,207 @@ +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 70624434..1edc9139 100644 --- a/tools/folio-bot/worker.js +++ b/tools/folio-bot/worker.js @@ -5,10 +5,11 @@ * alive: Discord posts an interaction here, this answers it, and that is the whole lifetime. Same account and the * same `wrangler deploy` as the supporter worker. * - * Secrets: DISCORD_PUBLIC_KEY (from the application's General Information page) is the only one phase 1 needs. - * Handing out roles comes later and needs a bot token; nothing here has one. + * 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. */ -import { optionsOf, run } from './commands.mjs' +import { PRIVATE, optionsOf, run } from './commands.mjs' +import { discordFor, expire, redeem } from './redeem.mjs' import { sources as liveSources } from './sources.mjs' const PING = 1 @@ -50,14 +51,29 @@ async function verify(request, body, publicKey) { const json = (data, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }) -/** A message the way every answer is shaped: no preview cards under it, and nobody pinged by it. */ -const message = (content) => ({ content, flags: 4, allowed_mentions: { parse: [] } }) +const SUPPRESS_EMBEDS = 4 +const EPHEMERAL = 64 + +/** + * A message the way every answer is shaped: no preview cards under it, and nobody pinged by it. A private one is + * seen only by whoever asked, which is how a reply about a supporter code never lands in a channel. + */ +const message = (content, secret = false) => ({ + content, + flags: SUPPRESS_EMBEDS | (secret ? EPHEMERAL : 0), + allowed_mentions: { parse: [] }, +}) /** * Built with its dependencies passed in, so the tests can hand it slow sources and a fake fetch rather than * reaching GitHub and Discord. */ -export function createWorker({ sources = liveSources, send = fetch, wait = ANSWER_WITHIN_MS } = {}) { +export function createWorker({ + sources = liveSources, + send = fetch, + wait = ANSWER_WITHIN_MS, + discord = (env) => discordFor(env), +} = {}) { return { async fetch(request, env, ctx) { if (request.method === 'GET') { @@ -78,23 +94,50 @@ export function createWorker({ sources = liveSources, send = fetch, wait = ANSWE if (interaction.type === PING) return json({ type: PONG }) if (interaction.type !== APPLICATION_COMMAND) return json({ type: PONG }) - const answer = run(interaction.data?.name, optionsOf(interaction), sources) + 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 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) }) + if (quick !== null) return json({ type: CHANNEL_MESSAGE, data: message(quick, secret) }) // 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)) }, + { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(message(content, secret)) }, ).then((response) => { if (!response.ok) console.error(`Could not edit the answer in: Discord said ${response.status}`) }), ) ctx?.waitUntil?.(followUp) - return json({ type: DEFERRED_CHANNEL_MESSAGE }) + // 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`)), + ) }, } } diff --git a/tools/folio-bot/worker.test.mjs b/tools/folio-bot/worker.test.mjs index 55f2a42d..4f4b6be1 100644 --- a/tools/folio-bot/worker.test.mjs +++ b/tools/folio-bot/worker.test.mjs @@ -125,10 +125,40 @@ 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, since user install is on', async () => { +test('every command says where it can be used, and /redeem only works in a server', 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('the read-only answers stay public', async () => { + const body = JSON.stringify({ type: 2, data: { name: 'sandwich', options: [] } }) + const answer = await (await worker.fetch(await signed(body), env)).json() + assert.equal(answer.data.flags & 64, 0) +}) diff --git a/tools/folio-bot/wrangler.toml b/tools/folio-bot/wrangler.toml index fe5e1484..0d69bb1d 100644 --- a/tools/folio-bot/wrangler.toml +++ b/tools/folio-bot/wrangler.toml @@ -14,3 +14,25 @@ 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 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 cf5357ce..64dad969 100644 --- a/tools/kofi-worker/schema.sql +++ b/tools/kofi-worker/schema.sql @@ -42,3 +42,17 @@ CREATE TABLE IF NOT EXISTS checks ( value TEXT NOT NULL, -- JSON 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. +CREATE TABLE IF NOT EXISTS discord_roles ( + serial INTEGER PRIMARY KEY, + user_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + role_id TEXT NOT NULL, + granted_at TEXT NOT NULL, -- YYYY-MM-DD, UTC + ends_on TEXT, -- the code's last day, YYYY-MM-DD; null for a code with no end + removed_at TEXT -- set when the cron takes the role back +); +CREATE INDEX IF NOT EXISTS discord_roles_due ON discord_roles (removed_at, ends_on); +CREATE INDEX IF NOT EXISTS discord_roles_person ON discord_roles (user_id, role_id);