From aceaf54519b74a4b9266c30fee12f1cd02309bf1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 5 Oct 2026 17:27:00 -0400 Subject: [PATCH] [www] discord link sync --- apps/www/README.md | 8 +++ apps/www/src/client/App.tsx | 70 +++++++++++++++++++++++ apps/www/src/staff.ts | 27 +++++++++ apps/www/src/test/integration/api.test.ts | 38 +++++++++++- apps/www/src/www.app.ts | 4 ++ 5 files changed, 144 insertions(+), 3 deletions(-) diff --git a/apps/www/README.md b/apps/www/README.md index 46f4c39..197940f 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -270,6 +270,14 @@ skip that one link and leave its snapshot for the next run, and the sweep honors Discord's rate-limit headers between calls. `wrangler tail www` shows a summary line per run: `discord role sweep: refreshed N of M links, …`. +A developer can run the same sweep on demand with `POST /api/staff/discord-roles/sync` +(developer role required, empty body). It answers with the run's summary — +`{ skipped, refreshed, changed, gone, failed, halted }` — so a run that stops on its first +call reports WHY (`halted: "the bot is not in the guild (Unknown Guild)"`) instead of +leaving a silent log line on a worker with observability off. Use it after changing the +bot token or inviting the bot, or when the role drop needs this morning's roles. The run +is written to `audit_log` as `sync_discord_roles` against the developer who pressed it. + ## Development ### Run in dev mode diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 460c169..2fe7c6e 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -3363,6 +3363,7 @@ function Dashboard({ <> + ), }, @@ -3833,6 +3834,75 @@ function RoleTokenDropForm() { ) } +/** What one run of the Discord role sweep did — www's `SweepSummary`, as the sync answers. */ +interface SweepSummary { + skipped: boolean + refreshed: number + changed: number + gone: number + failed: number + halted: string | null +} + +/** + * Developer-only: run the Discord role sweep now, instead of waiting for its nightly cron. + * Sits under the role drop because the drop reads the snapshot this refreshes — press this + * first and the drop sees this morning's roles rather than yesterday's. + * + * The answer is the point. The cron reports only to a log line, so a sweep that halts on + * its first call (a token Discord refuses, a bot not yet in the guild) halts silently every + * night; here the same run comes back with its summary, and a `halted` or `skipped` run is + * shown as the error it is for the operator, with the reason the server gave. A run that + * went through is summed up in a sentence. + */ +function DiscordRoleSyncCard() { + const { pending, error, done, run } = useAction() + + return ( +
+

Refresh Discord roles

+

+ Re-read every linked Discord member's roles from the server now. This runs on its own + once a day; use it after changing the bot or inviting it to the server, or when the role + drop above needs today's roles. If the sweep can't run, the reason is shown here. +

+ {error &&

{error}

} + {done &&

{done}

} + +
+ ) +} + /** * Change the account's username — the name used to sign in, here and in the game. * diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index d2f0b96..152b407 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -60,6 +60,8 @@ import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/ // The notification ids and the kick frame's recovered shape, owned by `notify`. Both are // imported as values/types with no runtime dependencies. import { NotificationType } from '../../notify/src/notification-types' +// The sweep itself, so a developer can run it on demand instead of waiting for the cron. +import { refreshDiscordRoles } from './discord-roles' import type { Context, MiddlewareHandler } from 'hono' import type { GiftContent } from '@repo/domain' @@ -1095,6 +1097,31 @@ export async function giftRoleTokensHandler(c: Context) { return c.json({ roleId, amount, message, paid }) } +/** + * Run the Discord role sweep NOW — the daily cron (discord-roles.ts) on a button, for a + * developer who has just changed the bot, invited it to the guild, or needs the role drop + * above to see this morning's roles. Same code, same credentials, same writes: every Discord + * link is re-read through the bot token and `platform_account.role` rewritten. + * + * The point of having it as a request is the ANSWER. The cron's summary goes to a log line + * on a worker with observability off, so a sweep that halts on its first call (a token + * Discord refuses, a bot not in the guild) halts silently every night, and the table looks + * exactly as it did — which is how it was found never to have completed a run. Here the + * same summary comes back as the response, `halted` reason and all. + * + * Always a 200 with the summary, whatever it says: a `skipped` or `halted` run is not a + * malformed request, it is the sweep reporting on its configuration, and the operator + * reads the reason off the body. Two developers pressing it at once run two sweeps that + * interleave harmlessly — each write is the absolute role list Discord served — at twice the + * Discord calls; nothing locks it. It is on the audit log like any other developer action. + */ +export async function syncDiscordRolesHandler(c: Context) { + const summary = await refreshDiscordRoles(c.env) + await recordPlayerAudit(c, 'sync_discord_roles', { ...summary }) + logger.info('staff ran the discord role sweep', { moderatorId: staffId(c), ...summary }) + return c.json(summary) +} + /** * Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked * here (the box, like every box, grants nothing when opened) and spent on any levels it now diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 6f3a7c3..f01d410 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -876,7 +876,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy writes.push( '/api/staff/rooms/1/gift-tokens', '/api/staff/online/gift-tokens', - '/api/staff/discord-roles/1/gift-tokens' + '/api/staff/discord-roles/1/gift-tokens', + '/api/staff/discord-roles/sync' ) for (const path of writes) { expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401) @@ -887,8 +888,11 @@ it('refuses every staff endpoint without a token, and without a staff role', asy expect(res.status).toBe(403) } - // The gifts and the Plus grant are narrower: a moderator is staff, but not a developer. - for (const path of writes.filter((p) => p.includes('/gift-') || p.includes('/grant-plus'))) { + // The gifts, the Plus grant and the role sweep are narrower: a moderator is staff, but + // not a developer. + for (const path of writes.filter( + (p) => p.includes('/gift-') || p.includes('/grant-plus') || p.endsWith('/sync') + )) { expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403) } @@ -2629,6 +2633,34 @@ it('runs the sweep from the scheduled handler and stays off without a guild', as expect(await discordLink(9100)).toEqual(before) }) +// The same sweep on a developer's request, answered with its summary. The deployed test +// config has no guild, so this pins the shape of the answer and the audit row, not a +// Discord round trip: the sweep's own behaviour is covered below through its seams. +it('runs the sweep on demand for a developer and answers with the summary', async () => { + await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]]) + const before = await discordLink(9100) + + const res = await devPost('/api/staff/discord-roles/sync', 8110, {}) + expect(res.status).toBe(200) + const summary = (await res.json()) as Record + expect(summary).toEqual({ + skipped: true, + refreshed: 0, + changed: 0, + gone: 0, + failed: 0, + halted: null, + }) + expect(await discordLink(9100)).toEqual(before) + + const row = await env.DB.prepare( + `SELECT player_id, data FROM audit_log WHERE action = 'sync_discord_roles' + ORDER BY audit_log_id DESC LIMIT 1` + ).first<{ player_id: number; data: string }>() + expect(row?.player_id).toBe(8110) + expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true }) +}) + it('re-reads every discord link and writes the roles back', async () => { await onlyDiscordLinks([ [9111, '900000000000000111', [ROLE_A]], diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 05e5ed5..6ec0816 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -58,6 +58,7 @@ import { requireDeveloper, requireStaff, searchReportsHandler, + syncDiscordRolesHandler, topReportedHandler, } from './staff' import { @@ -572,6 +573,9 @@ const app = new Hono() // Its sibling: the same gift to every account whose Discord link holds a role, offline // included — the supporter cron's audience, with an operator's amount and message. .post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler) + // The daily role sweep, run now and answered with its summary (halt reason included) — + // the cron reports only to a log line, and this is the way to see why it wrote nothing. + .post('/api/staff/discord-roles/sync', requireDeveloper, syncDiscordRolesHandler) // Rec Room Plus is worth tokens and a discount, so granting it is developer-only too. .post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler) .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)