174 lines
5.4 KiB
TypeScript
174 lines
5.4 KiB
TypeScript
export const CONTENT_REPORT_REASONS = [
|
|
'harmful',
|
|
'sexual',
|
|
'hateful',
|
|
'violent',
|
|
'self_harm',
|
|
'misinformation',
|
|
'privacy',
|
|
'spam',
|
|
'other',
|
|
] as const
|
|
|
|
export type ContentReportReason = typeof CONTENT_REPORT_REASONS[number]
|
|
|
|
export const CONTENT_REPORT_SOURCE_TYPES = [
|
|
'talk_response',
|
|
'command_response',
|
|
'action_response',
|
|
'meeting_document',
|
|
] as const
|
|
|
|
export type ContentReportSourceType = typeof CONTENT_REPORT_SOURCE_TYPES[number]
|
|
|
|
export interface ContentReportRequest {
|
|
kind: 'ai_output'
|
|
source: {
|
|
type: ContentReportSourceType
|
|
generationId: string
|
|
}
|
|
reason: ContentReportReason
|
|
comment?: string
|
|
snapshot: string
|
|
}
|
|
|
|
export interface ContentReportReceipt {
|
|
reportId: string
|
|
status: 'submitted'
|
|
idempotent: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export interface ContentReportRpcError {
|
|
status: number
|
|
code: string
|
|
}
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
const REASON_SET = new Set<string>(CONTENT_REPORT_REASONS)
|
|
const SOURCE_TYPE_SET = new Set<string>(CONTENT_REPORT_SOURCE_TYPES)
|
|
|
|
export class ContentReportRequestError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
readonly status: number = 400,
|
|
) {
|
|
super(code)
|
|
this.name = 'ContentReportRequestError'
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function codePointLength(value: string): number {
|
|
return Array.from(value).length
|
|
}
|
|
|
|
function assertExactKeys(
|
|
value: Record<string, unknown>,
|
|
required: readonly string[],
|
|
optional: readonly string[] = [],
|
|
): void {
|
|
const allowed = new Set([...required, ...optional])
|
|
const keys = Object.keys(value)
|
|
if (
|
|
required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))
|
|
|| keys.some((key) => !allowed.has(key))
|
|
) {
|
|
throw new ContentReportRequestError('invalid_request')
|
|
}
|
|
}
|
|
|
|
function requireUuid(value: unknown): string {
|
|
if (typeof value !== 'string') throw new ContentReportRequestError('invalid_request')
|
|
const normalized = value.trim().toLowerCase()
|
|
if (!UUID_PATTERN.test(normalized)) throw new ContentReportRequestError('invalid_request')
|
|
return normalized
|
|
}
|
|
|
|
function requireBoundedText(value: unknown, minimum: number, maximum: number): string {
|
|
if (typeof value !== 'string') throw new ContentReportRequestError('invalid_request')
|
|
const normalized = value.trim()
|
|
const length = codePointLength(normalized)
|
|
if (length < minimum || length > maximum) {
|
|
throw new ContentReportRequestError('invalid_request')
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
export function parseIdempotencyKey(value: string | null): string {
|
|
if (value === null) throw new ContentReportRequestError('idempotency_key_required')
|
|
return requireUuid(value)
|
|
}
|
|
|
|
export function parseContentReportRequest(value: unknown): ContentReportRequest {
|
|
if (!isRecord(value)) throw new ContentReportRequestError('invalid_request')
|
|
assertExactKeys(value, ['kind', 'source', 'reason', 'snapshot'], ['comment'])
|
|
if (value.kind !== 'ai_output' || !isRecord(value.source)) {
|
|
throw new ContentReportRequestError('invalid_request')
|
|
}
|
|
assertExactKeys(value.source, ['type', 'generationId'])
|
|
if (typeof value.source.type !== 'string' || !SOURCE_TYPE_SET.has(value.source.type)) {
|
|
throw new ContentReportRequestError('invalid_request')
|
|
}
|
|
if (typeof value.reason !== 'string' || !REASON_SET.has(value.reason)) {
|
|
throw new ContentReportRequestError('invalid_request')
|
|
}
|
|
|
|
const comment = value.comment === undefined
|
|
? undefined
|
|
: requireBoundedText(value.comment, 1, 500)
|
|
|
|
return {
|
|
kind: 'ai_output',
|
|
source: {
|
|
type: value.source.type as ContentReportSourceType,
|
|
generationId: requireUuid(value.source.generationId),
|
|
},
|
|
reason: value.reason as ContentReportReason,
|
|
...(comment === undefined ? {} : { comment }),
|
|
snapshot: requireBoundedText(value.snapshot, 1, 4_000),
|
|
}
|
|
}
|
|
|
|
export function parseContentReportReceipt(value: unknown): ContentReportReceipt {
|
|
if (
|
|
!isRecord(value)
|
|
|| typeof value.reportId !== 'string'
|
|
|| !UUID_PATTERN.test(value.reportId)
|
|
|| value.status !== 'submitted'
|
|
|| typeof value.idempotent !== 'boolean'
|
|
|| typeof value.createdAt !== 'string'
|
|
|| Number.isNaN(Date.parse(value.createdAt))
|
|
) {
|
|
throw new ContentReportRequestError('invalid_rpc_response', 500)
|
|
}
|
|
return {
|
|
reportId: value.reportId.toLowerCase(),
|
|
status: 'submitted',
|
|
idempotent: value.idempotent,
|
|
createdAt: value.createdAt,
|
|
}
|
|
}
|
|
|
|
export function mapContentReportRpcError(error: { message?: string; code?: string }): ContentReportRpcError {
|
|
const message = error.message ?? ''
|
|
if (message.includes('content_report_source_not_found')) {
|
|
return { status: 404, code: 'source_not_found' }
|
|
}
|
|
if (message.includes('content_report_rate_limited') || error.code === 'PT429') {
|
|
return { status: 429, code: 'report_rate_limited' }
|
|
}
|
|
if (message.includes('content_report_source_already_reported')) {
|
|
return { status: 409, code: 'source_already_reported' }
|
|
}
|
|
if (message.includes('content_report_idempotency_conflict') || error.code === 'PT409') {
|
|
return { status: 409, code: 'idempotency_conflict' }
|
|
}
|
|
if (error.code === '22023' || message.includes('invalid_content_report')) {
|
|
return { status: 400, code: 'invalid_request' }
|
|
}
|
|
return { status: 500, code: 'internal_error' }
|
|
}
|