mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[api] improve moderationblock
This commit is contained in:
+33
-8
@@ -1244,13 +1244,18 @@ export const VoteToKickReason = z.object({
|
|||||||
/**
|
/**
|
||||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
||||||
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
||||||
* `IsBan` true, the report's `ReportCategory`, `Duration` in seconds left (0 for a
|
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
|
||||||
* permanent ban, which has no end) and a fixed `Message` of "Rule violation". Otherwise it is the "not
|
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
|
||||||
* blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||||
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
||||||
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
|
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
|
||||||
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
|
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
|
||||||
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
|
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
|
||||||
|
*
|
||||||
|
* Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter
|
||||||
|
* reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are
|
||||||
|
* block kinds and screen dressings this server never hands out, so they always carry their
|
||||||
|
* "none" value; they are sent so a decoder that wants the key present finds it.
|
||||||
*/
|
*/
|
||||||
export const ModerationBlockDetails = z.object({
|
export const ModerationBlockDetails = z.object({
|
||||||
ReportCategory: z
|
ReportCategory: z
|
||||||
@@ -1261,18 +1266,38 @@ export const ModerationBlockDetails = z.object({
|
|||||||
Duration: z
|
Duration: z
|
||||||
.int()
|
.int()
|
||||||
.describe(
|
.describe(
|
||||||
'Seconds left on the block; 0 for a permanent ban (no end) and when not blocked — `IsBan` marks the block'
|
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
|
||||||
),
|
),
|
||||||
GameSessionId: z.int(),
|
GameSessionId: z.int(),
|
||||||
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
|
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
|
||||||
IsHostKick: z.boolean(),
|
|
||||||
IsVoiceModAutoban: z.boolean(),
|
|
||||||
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
|
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
|
||||||
PlayerIdReporter: z
|
PlayerIdReporter: z
|
||||||
.int()
|
.int()
|
||||||
.nullable()
|
.nullable()
|
||||||
.describe('Always null — the reporter is not shown to the reported'),
|
.describe('Always null — the reporter is not shown to the reported'),
|
||||||
TimeoutStartedAt: z.string().nullable(),
|
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
|
||||||
|
IsVoiceModAutoban: z.boolean().describe('Always false'),
|
||||||
|
IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'),
|
||||||
|
IsWarning: z
|
||||||
|
.boolean()
|
||||||
|
.describe('Always false — warnings are delivered as notifications, not here'),
|
||||||
|
VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'),
|
||||||
|
TimeoutStartedAt: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe(
|
||||||
|
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
|
||||||
|
),
|
||||||
|
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
|
||||||
|
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
|
||||||
|
TopMessageOverride: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe('Always null — the client’s default block-screen text stands'),
|
||||||
|
BottomMessageOverride: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.describe('Always null — the client’s default block-screen text stands'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -225,55 +225,67 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `Duration` on a permanent ban: 0, "no end". `IsBan` is what says the player is blocked;
|
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
|
||||||
* `Duration` only says for how long, and a ban with no expiry has no length to give. Not
|
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
|
||||||
* the int32-max sentinel E12354 uses — that reads as a 68-year countdown.
|
* end gets the largest value the field holds, 68 years past its start.
|
||||||
*/
|
*/
|
||||||
const PERMANENT_BAN_DURATION = 0
|
const PERMANENT_BAN_DURATION = 2_147_483_647
|
||||||
|
|
||||||
/** The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`. */
|
/**
|
||||||
|
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
|
||||||
|
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
|
||||||
|
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
|
||||||
|
* kinds and screen dressings this server never uses — a device ban, a warning, the
|
||||||
|
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
|
||||||
|
* message overrides — so they carry their "none" values on every answer.
|
||||||
|
*/
|
||||||
const NOT_BLOCKED = {
|
const NOT_BLOCKED = {
|
||||||
ReportCategory: -1,
|
ReportCategory: -1,
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
GameSessionId: 0,
|
GameSessionId: 0,
|
||||||
IsBan: false,
|
|
||||||
IsHostKick: false,
|
IsHostKick: false,
|
||||||
IsVoiceModAutoban: false,
|
|
||||||
Message: null,
|
Message: null,
|
||||||
PlayerIdReporter: null,
|
PlayerIdReporter: null,
|
||||||
|
IsBan: false,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
IsDeviceBan: false,
|
||||||
|
IsWarning: false,
|
||||||
|
VoteKickReason: null,
|
||||||
TimeoutStartedAt: null,
|
TimeoutStartedAt: null,
|
||||||
|
AssociatedAccountUsername: null,
|
||||||
|
ShowCreatorCodeOfConduct: false,
|
||||||
|
TopMessageOverride: null,
|
||||||
|
BottomMessageOverride: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||||
*
|
*
|
||||||
* `Duration` is the seconds left on the ban (rounded up, so a ban with a second to run
|
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||||
* doesn't read as over), or `PERMANENT_BAN_DURATION` (0, no end) when `ban_expires` is
|
* start for the duration. The start is the report's `created_at` — nothing records when
|
||||||
* NULL — `IsBan` alone marks the block, so the "not blocked" answer and a permanent ban
|
* the ban itself was handed down, and the report is the record the ban rests on — and the
|
||||||
* share a `Duration` of 0 without being confused. The
|
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
|
||||||
* category is the one the report was filed under, so the client's ban screen names the
|
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
|
||||||
* reason. `Message` is a fixed "Rule violation" rather than the report's `details` —
|
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||||
* those are the REPORTER's words, and the banned player isn't shown them, for the same
|
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||||
* reason `PlayerIdReporter` stays null: the reporter is not a host who kicked them, and
|
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||||
* naming them would tell the banned player who reported them. `IsHostKick`,
|
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||||
* `IsVoiceModAutoban` and `TimeoutStartedAt` describe the OTHER kinds of block, none of
|
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||||
* which this server hands out.
|
* screen dressings, none of which this server hands out.
|
||||||
*/
|
*/
|
||||||
function banBlockDetails(ban: ReportRow, now: Date) {
|
function banBlockDetails(ban: ReportRow) {
|
||||||
|
const startedAt = Date.parse(ban.created_at)
|
||||||
const duration =
|
const duration =
|
||||||
ban.ban_expires === null
|
ban.ban_expires === null
|
||||||
? PERMANENT_BAN_DURATION
|
? PERMANENT_BAN_DURATION
|
||||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
|
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
|
||||||
return {
|
return {
|
||||||
|
...NOT_BLOCKED,
|
||||||
ReportCategory: ban.report_category,
|
ReportCategory: ban.report_category,
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
GameSessionId: 0,
|
|
||||||
IsBan: true,
|
IsBan: true,
|
||||||
IsHostKick: false,
|
|
||||||
IsVoiceModAutoban: false,
|
|
||||||
Message: 'Rule violation',
|
Message: 'Rule violation',
|
||||||
PlayerIdReporter: null,
|
TimeoutStartedAt: ban.created_at,
|
||||||
TimeoutStartedAt: null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,17 +319,24 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
|
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
|
||||||
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
||||||
'screen) — so a caller with one in force gets ' +
|
'screen) — so a caller with one in force gets ' +
|
||||||
'`IsBan: true`, the `ReportCategory` the report was filed under, `Duration` as the ' +
|
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
|
||||||
'seconds left (0 for a permanent ban, which has no end) and the fixed ' +
|
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
|
||||||
'`Message` “Rule violation”. `PlayerIdReporter` stays null: it names a kicking ' +
|
'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' +
|
||||||
'host, and the reporter is not shown to the player they reported. Only the ' +
|
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
|
||||||
'caller’s own account is consulted, not the ban-evasion arms.\n\n' +
|
'ban). ' +
|
||||||
|
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
|
||||||
|
'shown to the player they reported. Only the caller’s own account is consulted, ' +
|
||||||
|
'not the ban-evasion arms.\n\n' +
|
||||||
'Everyone else gets the reference server’s stub “not blocked” answer: ' +
|
'Everyone else gets the reference server’s stub “not blocked” answer: ' +
|
||||||
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
|
||||||
'`Message` is null rather than the empty string that stub sends — the client ' +
|
'`Message` is null rather than the empty string that stub sends — the client ' +
|
||||||
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
|
||||||
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
|
||||||
'defaults. Answers GET or POST: the newer client POSTs it with no body.',
|
'defaults, as do the seven keys past the stub’s nine that the 2025 client’s decoder ' +
|
||||||
|
'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' +
|
||||||
|
'`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' +
|
||||||
|
'block kinds and screen dressings this server never uses. Answers GET or POST: ' +
|
||||||
|
'the newer client POSTs it with no body.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
|
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
|
||||||
@@ -327,9 +346,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const now = new Date()
|
const ban = await getActiveBan(c.env.DB, id)
|
||||||
const ban = await getActiveBan(c.env.DB, id, now)
|
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
|
||||||
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||||
|
|||||||
@@ -4509,16 +4509,24 @@ describe('player reports', () => {
|
|||||||
// What the banned player is TOLD. The block screen reads this; it's the same row
|
// What the banned player is TOLD. The block screen reads this; it's the same row
|
||||||
// matchmake and login refuse on, described rather than merely enforced.
|
// matchmake and login refuse on, described rather than merely enforced.
|
||||||
describe('moderationBlockDetails', () => {
|
describe('moderationBlockDetails', () => {
|
||||||
|
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
|
||||||
const NOT_BLOCKED = {
|
const NOT_BLOCKED = {
|
||||||
ReportCategory: -1,
|
ReportCategory: -1,
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
GameSessionId: 0,
|
GameSessionId: 0,
|
||||||
IsBan: false,
|
|
||||||
IsHostKick: false,
|
IsHostKick: false,
|
||||||
IsVoiceModAutoban: false,
|
|
||||||
Message: null,
|
Message: null,
|
||||||
PlayerIdReporter: null,
|
PlayerIdReporter: null,
|
||||||
|
IsBan: false,
|
||||||
|
IsVoiceModAutoban: false,
|
||||||
|
IsDeviceBan: false,
|
||||||
|
IsWarning: false,
|
||||||
|
VoteKickReason: null,
|
||||||
TimeoutStartedAt: null,
|
TimeoutStartedAt: null,
|
||||||
|
AssociatedAccountUsername: null,
|
||||||
|
ShowCreatorCodeOfConduct: false,
|
||||||
|
TopMessageOverride: null,
|
||||||
|
BottomMessageOverride: null,
|
||||||
}
|
}
|
||||||
const details = async (method: string, sub: string) => {
|
const details = async (method: string, sub: string) => {
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
@@ -4549,7 +4557,10 @@ describe('player reports', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
// A permanent ban has no end, so Duration is 0 — IsBan is what marks the block.
|
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
|
||||||
|
// start for the duration. The start is the report's created_at (nothing records when
|
||||||
|
// the ban itself landed), and a permanent ban runs for the largest span the int
|
||||||
|
// holds.
|
||||||
test('describes a permanent ban', async () => {
|
test('describes a permanent ban', async () => {
|
||||||
await submit(
|
await submit(
|
||||||
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||||
@@ -4559,32 +4570,34 @@ describe('player reports', () => {
|
|||||||
await banFromReport(env.DB, row!.id)
|
await banFromReport(env.DB, row!.id)
|
||||||
|
|
||||||
expect(await details('POST', '221')).toEqual({
|
expect(await details('POST', '221')).toEqual({
|
||||||
|
...NOT_BLOCKED,
|
||||||
ReportCategory: 102,
|
ReportCategory: 102,
|
||||||
Duration: 0,
|
Duration: 2_147_483_647,
|
||||||
GameSessionId: 0,
|
|
||||||
IsBan: true,
|
IsBan: true,
|
||||||
IsHostKick: false,
|
|
||||||
IsVoiceModAutoban: false,
|
|
||||||
// A fixed message — the report's `details` are the reporter's words, and
|
// A fixed message — the report's `details` are the reporter's words, and
|
||||||
// the reporter is not shown to the player they reported, hence null.
|
// the reporter is not shown to the player they reported (PlayerIdReporter
|
||||||
|
// stays null).
|
||||||
Message: 'Rule violation',
|
Message: 'Rule violation',
|
||||||
PlayerIdReporter: null,
|
TimeoutStartedAt: row!.created_at,
|
||||||
TimeoutStartedAt: null,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// A timed ban reports the seconds LEFT, not its original length.
|
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
|
||||||
test('describes a timed ban with the seconds remaining', async () => {
|
// sums to `ban_expires` — not the seconds left as of the request.
|
||||||
|
test('describes a timed ban as its report’s created_at plus the span to expiry', async () => {
|
||||||
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||||
const [row] = await getReportsAgainst(env.DB, 222)
|
const [row] = await getReportsAgainst(env.DB, 222)
|
||||||
await banFromReport(env.DB, row!.id, {
|
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
|
||||||
banExpires: new Date(Date.now() + 3600 * 1000).toISOString(),
|
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
|
||||||
})
|
|
||||||
|
|
||||||
const body = await details('GET', '222')
|
expect(await details('GET', '222')).toEqual({
|
||||||
expect(body).toMatchObject({ ReportCategory: 103, IsBan: true, Message: 'Rule violation' })
|
...NOT_BLOCKED,
|
||||||
expect(body.Duration).toBeGreaterThan(3500)
|
ReportCategory: 103,
|
||||||
expect(body.Duration).toBeLessThanOrEqual(3600)
|
Duration: 3600,
|
||||||
|
IsBan: true,
|
||||||
|
Message: 'Rule violation',
|
||||||
|
TimeoutStartedAt: row!.created_at,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// A ban that has served its time is not a block, even though the row still says
|
// A ban that has served its time is not a block, even though the row still says
|
||||||
|
|||||||
Reference in New Issue
Block a user