[api] chat msg report

This commit is contained in:
Devin Zuczek
2026-09-19 11:21:07 -04:00
parent 69dda96739
commit 5e2fad31df
6 changed files with 304 additions and 14 deletions
+118
View File
@@ -15,6 +15,10 @@ import {
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The message being reported, and whether the reporter can see it — `chat` owns both tables
// on the shared database, so its reads are used rather than restated here.
import { getMessage } from '../../../chat/src/message-db'
import { isThreadMember } from '../../../chat/src/thread-db'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { KickReportCategory } from '../../../notify/src/notification-payloads'
@@ -23,6 +27,7 @@ import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
BareBoolean,
CreateChatReportRequest,
CreateReportRequest,
CreateWarningRequest,
DeviceIdRequest,
@@ -91,6 +96,39 @@ const asFloat = (v: string | undefined): number | null => {
return Number.isNaN(n) ? null : n
}
/**
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
* them, rather than guessing at an enum nobody here has a copy of.
*/
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
discriminatory: KickReportCategory.CoCDiscrimination,
}
/**
* The numeric category a chat report is filed under. The chat report sends its category as
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
* what a ban's block screen names as the reason — so the name is mapped onto the same
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
*
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
* description.
*/
function chatReportCategory(raw: string | undefined): number | null {
if (raw === undefined) return KickReportCategory.Unknown
const name = raw.trim()
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
const key = name.toLowerCase()
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
if (alias !== undefined) return alias
for (const [member, value] of Object.entries(KickReportCategory)) {
if (typeof value === 'number' && member.toLowerCase() === key) return value
}
return null
}
/**
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
@@ -488,6 +526,86 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// Report one chat message. Stored in the `report` table every other report uses — same
// fields, same moderation life — with `chat_message_id` set. See
// migrations/0025_report_chat_message.sql.
.post(
'/api/chatreport/createChatReport',
describeRoute({
tags: ['Moderation'],
summary: 'Report a chat message',
description:
'Files a report against one chat message. Stored as a row in the same `report` table ' +
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
'invention and custom-avatar-item reports — the same submission with the same ' +
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
'the message’s SENDER, read from the `message` table, since the body names no ' +
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
'thread the message is in — the same gate reading the message sits behind. A ' +
'message the caller cannot see answers exactly as one that does not exist, so the ' +
'route cannot be used to probe message ids or to file reports citing conversations ' +
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
'report and is refused.\n\n' +
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
'one a player report sends, and is mapped onto the numeric category the table ' +
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
'nothing is filed as 0 with the name kept at the front of the details. ' +
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
'string rather than null, on the rejected branches too.',
security: AUTHED,
requestBody: form(CreateChatReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
if (chatMessageId === null) {
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
}
// The message supplies the reported player, and its OWN thread is the one the
// membership check runs against — not the body's `ChatThreadId`, which would let a
// caller vouch for a message by naming a thread they happen to be in.
const message = await getMessage(c.env.DB, chatMessageId)
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
return c.json({ success: false, error: 'No such message' }, 404)
}
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
if (message.senderPlayerId <= 0) {
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
}
const rawCategory = formField(body, c, 'ReportCategory')
const category = chatReportCategory(rawCategory)
const description = formField(body, c, 'ReportDescription') ?? null
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: message.senderPlayerId,
reportCategory: category ?? KickReportCategory.Unknown,
details:
category === null
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
: description,
chatMessageId,
})
return c.json({ success: true, error: '' })
}
)
// A player calling a vote to kick another. Ungated by role — anyone may start one —
// but both players have to be standing in the session the vote is called in, which is
// what stops a client putting a vote to a room it isn't in, about someone who isn't