import { db } from '../db/index.js'; import { AuthError } from '../auth/index.js'; import { matchChatTimeline } from '../fleet/chat.js'; import { moderationGuid, type ModerationGame } from '../../moderation.js'; import type { ContextualChatEntry } from '../../game-chat.js'; import { textField } from './core.js'; type Message = Extract; /** Verify against the archive, then preserve independent excerpts across all participants. */ export function archivedEvidenceSnapshots( caseId: string, target: { game: ModerationGame; guid: string }, value: unknown ) { const requested = value as Record | null; if (!requested || !Number.isSafeInteger(requested.matchId)) throw new AuthError('Choose a recorded chat message.'); const matchId = requested.matchId as number; const match = db() .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') .get(matchId) as { game: string; server: string; started_at: string } | undefined; if (!match || match.game !== target.game) throw new AuthError('The source match is unavailable.', 404); const entries = matchChatTimeline(matchId).filter( (entry): entry is Message => entry.type === 'message' ); const byId = new Map(entries.map((entry) => [entry.messageId!, entry])); const isSubject = (entry: Message) => moderationGuid(target.game, entry.message.guid) === target.guid; const legacy = requested.messageIds === undefined; let selected: Message[]; if (legacy) { textField(requested.message, 'Chat message', 4096); selected = entries.filter( (entry) => isSubject(entry) && entry.message.message === requested.message ); } else { if ( !Array.isArray(requested.messageIds) || !requested.messageIds.length || requested.messageIds.length > 100 || requested.messageIds.some((id) => typeof id !== 'string' || !byId.has(id)) ) throw new AuthError('Select between 1 and 100 archived message identifiers.'); const ids = new Set(requested.messageIds); selected = entries.filter((entry) => ids.has(entry.messageId)); } if (!selected.length || !selected.some(isSubject)) throw new AuthError( 'The original chat message could not be verified in the archive. Select a message from the case subject.', 409 ); const radius = (key: string) => { const count = requested[key] ?? 3; if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > 20) throw new AuthError('Choose between 0 and 20 surrounding messages.'); return count as number; }; const before = radius('contextBefore'), after = radius('contextAfter'); const attached = new Set(); const prior = db() .prepare( 'SELECT snapshot FROM moderation_evidence WHERE case_id=? AND snapshot IS NOT NULL' ) .all(caseId) as { snapshot: string }[]; for (const row of prior) { try { const snapshot = JSON.parse(row.snapshot); if (snapshot.matchId !== matchId) continue; if (Array.isArray(snapshot.selectedMessageIds)) { for (const id of snapshot.selectedMessageIds) if (typeof id === 'string') attached.add(id); } else if (snapshot.entry?.type === 'message') { // Older evidence predates IDs. Match each occurrence once without changing history. const old = snapshot.entry.message; const original = entries.find( (entry) => !attached.has(entry.messageId!) && entry.message.guid === old.guid && entry.message.time === old.time && entry.message.player === old.player && entry.message.channel === old.channel && entry.message.message === old.message ); if (original) attached.add(original.messageId!); } } catch { /* Keep malformed legacy snapshots readable without trusting them as identities. */ } } const fresh = selected.filter((entry) => !attached.has(entry.messageId!)); if (!fresh.length) return []; // Keep the original selected subject as context when only a newly selected reply is fresh. const groups = legacy ? fresh.map((entry) => [entry]) : [selected]; return groups.map((group) => { const indexes = new Set(); for (const entry of group) { const index = entries.indexOf(entry); for ( let i = Math.max(0, index - before); i <= Math.min(entries.length - 1, index + after); i++ ) indexes.add(i); } const primary = group.find(isSubject)!; return { text: group .filter(isSubject) .map((entry) => entry.message.message) .join('\n'), source: `Archived chat · ${match.server} · match ${matchId}`, snapshot: JSON.stringify({ version: 2, matchId, match, entry: primary, entries: [...indexes].sort((a, b) => a - b).map((index) => entries[index]), selectedMessageIds: group .filter((entry) => !attached.has(entry.messageId!)) .map((entry) => entry.messageId), highlightedMessageIds: group.filter(isSubject).map((entry) => entry.messageId) }) }; }); }