From 5b63a277f55c92a6c5fd9c0ba3a86cd3f51fd6e5 Mon Sep 17 00:00:00 2001 From: McCal-Codes <175259256+McCal-Codes@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:41:49 -0400 Subject: [PATCH] Mr Folio gets a Get release pings button --- tools/folio-bot/pings.mjs | 30 +++++++++++++++++++++++ tools/folio-bot/pings.test.mjs | 42 +++++++++++++++++++++++++++++++++ tools/folio-bot/worker.js | 11 +++++++++ tools/folio-bot/worker.test.mjs | 14 +++++++++++ tools/folio-bot/wrangler.toml | 2 ++ 5 files changed, 99 insertions(+) create mode 100644 tools/folio-bot/pings.mjs create mode 100644 tools/folio-bot/pings.test.mjs diff --git a/tools/folio-bot/pings.mjs b/tools/folio-bot/pings.mjs new file mode 100644 index 00000000..5a473119 --- /dev/null +++ b/tools/folio-bot/pings.mjs @@ -0,0 +1,30 @@ +/** + * The "Get release pings" button. Pressing it gives you the `Folio updates` role, which @-mentions you when a release + * is posted, and pressing it again takes the role back. The press is a component interaction (type 3), signed like a + * command, and the answer is private to the person who pressed. + */ +export const PINGS_BUTTON = 'folio-release-pings' + +/** The message component to attach to a post: one row, one button. */ +export const pingsComponents = () => [ + { type: 1, components: [{ type: 2, style: 2, label: 'Get release pings', emoji: { name: '🔔' }, custom_id: PINGS_BUTTON }] }, +] + +export async function togglePings(env, interaction, discord) { + if (!env.GUILD_ID || interaction.guild_id !== env.GUILD_ID || !env.ROLE_UPDATES) { + return 'Release pings are set up in the Folio Community server.' + } + const userId = interaction.member?.user?.id + if (!userId || !env.DISCORD_BOT_TOKEN) return 'That did not work. Try again in a little while.' + // The interaction carries the person's roles, so there is no second lookup to race. + const has = (interaction.member.roles ?? []).includes(env.ROLE_UPDATES) + const status = has + ? await discord.removeRole(env.GUILD_ID, userId, env.ROLE_UPDATES, 'Turned release pings off') + : await discord.addRole(env.GUILD_ID, userId, env.ROLE_UPDATES, 'Turned release pings on') + if (status === 403) { + // Discord only lets a bot hand out roles below its own, and this is the one that reads like nothing without a hint. + return "I could not change that: Mr Folio's role has to sit above Folio updates in Server Settings › Roles. McCal can fix it." + } + if (status >= 300) return 'That did not work. Try again in a little while.' + return has ? 'Release pings are off.' : 'Done. You will be pinged when a new release is posted. Press the button again to stop.' +} diff --git a/tools/folio-bot/pings.test.mjs b/tools/folio-bot/pings.test.mjs new file mode 100644 index 00000000..dcca2890 --- /dev/null +++ b/tools/folio-bot/pings.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { PINGS_BUTTON, pingsComponents, togglePings } from './pings.mjs' + +const env = { GUILD_ID: 'folio', ROLE_UPDATES: 'ping-role', DISCORD_BOT_TOKEN: 'token' } +const press = (roles, guild = 'folio') => ({ guild_id: guild, member: { user: { id: 'u1' }, roles } }) +function fakeDiscord(status = 204) { + const calls = [] + return { calls, addRole: async (...a) => (calls.push(['add', ...a]), status), removeRole: async (...a) => (calls.push(['remove', ...a]), status) } +} + +test('the button is one row with one button that carries the id the worker listens for', () => { + const [row] = pingsComponents() + assert.equal(row.type, 1) + assert.equal(row.components.length, 1) + assert.equal(row.components[0].custom_id, PINGS_BUTTON) + assert.equal(row.components[0].type, 2) +}) + +test('pressing it without the role gives the role, and pressing it with the role takes it back', async () => { + const on = fakeDiscord() + assert.match(await togglePings(env, press(['other']), on), /pinged/) + assert.deepEqual(on.calls[0].slice(0, 4), ['add', 'folio', 'u1', 'ping-role']) + const off = fakeDiscord() + assert.match(await togglePings(env, press(['ping-role', 'other']), off), /are off/) + assert.deepEqual(off.calls[0].slice(0, 4), ['remove', 'folio', 'u1', 'ping-role']) +}) + +test('a bot below the role says what to fix instead of failing quietly', async () => { + assert.match(await togglePings(env, press([]), fakeDiscord(403)), /has to sit above Folio updates/) +}) + +test('another server, or a missing setting, changes nothing', async () => { + const none = fakeDiscord() + assert.match(await togglePings(env, press([], 'elsewhere'), none), /Folio Community server/) + assert.match(await togglePings({ ...env, ROLE_UPDATES: undefined }, press([]), none), /Folio Community server/) + assert.equal(none.calls.length, 0) +}) + +test('Discord failing is answered plainly', async () => { + assert.match(await togglePings(env, press([]), fakeDiscord(500)), /did not work/) +}) diff --git a/tools/folio-bot/worker.js b/tools/folio-bot/worker.js index 1edc9139..799aed5e 100644 --- a/tools/folio-bot/worker.js +++ b/tools/folio-bot/worker.js @@ -10,10 +10,12 @@ */ import { PRIVATE, optionsOf, run } from './commands.mjs' import { discordFor, expire, redeem } from './redeem.mjs' +import { PINGS_BUTTON, togglePings } from './pings.mjs' import { sources as liveSources } from './sources.mjs' const PING = 1 const APPLICATION_COMMAND = 2 +const MESSAGE_COMPONENT = 3 const PONG = 1 const CHANNEL_MESSAGE = 4 const DEFERRED_CHANNEL_MESSAGE = 5 @@ -92,6 +94,15 @@ export function createWorker({ const interaction = JSON.parse(body) if (interaction.type === PING) return json({ type: PONG }) + if (interaction.type === MESSAGE_COMPONENT) { + // A button press. Only the release-pings button exists; anything else is acknowledged and ignored. + if (interaction.data?.custom_id !== PINGS_BUTTON) return json({ type: PONG }) + const said = await togglePings(env, interaction, discord(env)).catch((error) => { + console.error(`pings failed: ${error.message}`) + return 'That did not work. Try again in a little while.' + }) + return json({ type: CHANNEL_MESSAGE, data: message(said, true) }) + } if (interaction.type !== APPLICATION_COMMAND) return json({ type: PONG }) const name = interaction.data?.name diff --git a/tools/folio-bot/worker.test.mjs b/tools/folio-bot/worker.test.mjs index 4f4b6be1..3832b9d9 100644 --- a/tools/folio-bot/worker.test.mjs +++ b/tools/folio-bot/worker.test.mjs @@ -162,3 +162,17 @@ test('the read-only answers stay public', async () => { const answer = await (await worker.fetch(await signed(body), env)).json() assert.equal(answer.data.flags & 64, 0) }) + +test('a press of the release-pings button toggles the role and the answer is private', async () => { + const calls = [] + const bot = createWorker({ discord: () => ({ addRole: async (...a) => (calls.push(a), 204), removeRole: async () => 204 }) }) + const body = JSON.stringify({ type: 3, guild_id: 'folio', member: { user: { id: 'u1' }, roles: [] }, data: { custom_id: 'folio-release-pings' } }) + const answer = await (await bot.fetch(await signed(body), { ...env, GUILD_ID: 'folio', ROLE_UPDATES: 'ping', DISCORD_BOT_TOKEN: 't' }, {})).json() + assert.equal(answer.type, 4) + assert.equal(answer.data.flags & 64, 64, 'ephemeral') + assert.match(answer.data.content, /pinged/) + assert.equal(calls.length, 1) + // A button this bot did not make is acknowledged and ignored. + const other = JSON.stringify({ type: 3, guild_id: 'folio', member: { user: { id: 'u1' }, roles: [] }, data: { custom_id: 'something-else' } }) + assert.deepEqual(await (await bot.fetch(await signed(other), env, {})).json(), { type: 1 }) +}) diff --git a/tools/folio-bot/wrangler.toml b/tools/folio-bot/wrangler.toml index 0d69bb1d..29b3fa69 100644 --- a/tools/folio-bot/wrangler.toml +++ b/tools/folio-bot/wrangler.toml @@ -32,6 +32,8 @@ GUILD_ID = "1551382087985266838" 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]