mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] discord link sync
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -3363,6 +3363,7 @@ function Dashboard({
|
||||
<>
|
||||
<TokenDropForm />
|
||||
<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'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.
|
||||
</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.
|
||||
*
|
||||
|
||||
@@ -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<App>) {
|
||||
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
|
||||
* here (the box, like every box, grants nothing when opened) and spent on any levels it now
|
||||
|
||||
@@ -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<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 () => {
|
||||
await onlyDiscordLinks([
|
||||
[9111, '900000000000000111', [ROLE_A]],
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
requireDeveloper,
|
||||
requireStaff,
|
||||
searchReportsHandler,
|
||||
syncDiscordRolesHandler,
|
||||
topReportedHandler,
|
||||
} from './staff'
|
||||
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
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user