[www] discord link sync

This commit is contained in:
Devin Zuczek
2026-10-05 17:27:00 -04:00
parent 4e3301ade8
commit aceaf54519
5 changed files with 144 additions and 3 deletions
+8
View File
@@ -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 Discord's rate-limit headers between calls. `wrangler tail www` shows a summary line per
run: `discord role sweep: refreshed N of M links, …`. 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 ## Development
### Run in dev mode ### Run in dev mode
+70
View File
@@ -3363,6 +3363,7 @@ function Dashboard({
<> <>
<TokenDropForm /> <TokenDropForm />
<RoleTokenDropForm /> <RoleTokenDropForm />
<DiscordRoleSyncCard />
</> </>
), ),
}, },
@@ -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 (
<section className="card">
<h2>Refresh Discord roles</h2>
<p className="muted">
Re-read every linked Discord member&apos;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&apos;s roles. If the sweep can&apos;t run, the reason is shown here.
</p>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button
type="button"
disabled={pending}
onClick={() =>
void run(async () => {
const s = await call<SweepSummary>('/api/staff/discord-roles/sync', {
authed: true,
method: 'POST',
})
if (s.skipped) {
throw new Error(
'The sweep is off: no Discord guild or bot token is configured on the server.'
)
}
if (s.halted !== null) {
throw new Error(
`The sweep stopped before writing anything: ${s.halted}. ${s.refreshed} link${s.refreshed === 1 ? '' : 's'} refreshed first.`
)
}
const parts = [
`Refreshed ${s.refreshed} link${s.refreshed === 1 ? '' : 's'}`,
`${s.changed} changed`,
`${s.gone} left the server`,
]
if (s.failed > 0) parts.push(`${s.failed} skipped for a transient error`)
return `${parts.join(', ')}.`
})
}
>
{pending ? 'Refreshing…' : 'Refresh roles now'}
</button>
</section>
)
}
/** /**
* Change the account's username — the name used to sign in, here and in the game. * Change the account's username — the name used to sign in, here and in the game.
* *
+27
View File
@@ -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 // The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
// imported as values/types with no runtime dependencies. // imported as values/types with no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types' 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 { Context, MiddlewareHandler } from 'hono'
import type { GiftContent } from '@repo/domain' import type { GiftContent } from '@repo/domain'
@@ -1095,6 +1097,31 @@ export async function giftRoleTokensHandler(c: Context<App>) {
return c.json({ roleId, amount, message, paid }) 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<App>) {
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 * 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 * here (the box, like every box, grants nothing when opened) and spent on any levels it now
+35 -3
View File
@@ -876,7 +876,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
writes.push( writes.push(
'/api/staff/rooms/1/gift-tokens', '/api/staff/rooms/1/gift-tokens',
'/api/staff/online/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) { for (const path of writes) {
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401) 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) expect(res.status).toBe(403)
} }
// The gifts and the Plus grant are narrower: a moderator is staff, but not a developer. // The gifts, the Plus grant and the role sweep are narrower: a moderator is staff, but
for (const path of writes.filter((p) => p.includes('/gift-') || p.includes('/grant-plus'))) { // 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) 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) 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<string, unknown>
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 () => { it('re-reads every discord link and writes the roles back', async () => {
await onlyDiscordLinks([ await onlyDiscordLinks([
[9111, '900000000000000111', [ROLE_A]], [9111, '900000000000000111', [ROLE_A]],
+4
View File
@@ -58,6 +58,7 @@ import {
requireDeveloper, requireDeveloper,
requireStaff, requireStaff,
searchReportsHandler, searchReportsHandler,
syncDiscordRolesHandler,
topReportedHandler, topReportedHandler,
} from './staff' } from './staff'
import { import {
@@ -572,6 +573,9 @@ const app = new Hono<App>()
// Its sibling: the same gift to every account whose Discord link holds a role, offline // 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. // included — the supporter cron's audience, with an operator's amount and message.
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler) .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. // 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/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler) .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)