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(CONTENT_REPORT_REASONS) const SOURCE_TYPE_SET = new Set(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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } function codePointLength(value: string): number { return Array.from(value).length } function assertExactKeys( value: Record, 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' } }