diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index c18a888..efe2d5b 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -149,10 +149,19 @@ the required permission and reason, including after a case closes. Reopening a case also leaves its restrictions in place. Check enforcement coverage after applying or revoking a restriction. -Staff notes are explicitly labeled as notes. Selecting archived chat when opening -a case, or attaching a match ID and exact message within an existing case, verifies -the target GUID and original message against the recorded match and saves an -independent snapshot, including the original timing context. Unknown or estimated +Staff notes are explicitly labeled as notes. In match chat, select up to 100 +messages and choose **Attach to case** to open a case or add them to an existing +case for the selected subject. Select at least one message from that subject; +messages from other participants can be selected alongside it. Each attachment +preserves selected messages and, by default, three surrounding messages on either side, with +names, GUIDs, original timing, and highlighted subject messages. The selection +preview shows the excerpt before saving. Server verification uses stable archived +message identifiers; repeated text remains separate occurrences, and attaching +the same selected messages again does not duplicate evidence. Exact-text +attachments remain available and include every matching occurrence, without the +former ten-occurrence limit. + +Snapshots remain readable after source-match cleanup. Unknown or estimated timestamps remain such; the snapshot does not turn them into exact observations. Unarchived or already deleted chat cannot be attached as verified evidence. diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts index 04c7864..c57dc8c 100644 --- a/src/lib/game-chat.ts +++ b/src/lib/game-chat.ts @@ -17,7 +17,12 @@ export interface ChatContext { round: number | null; } -export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number }; +export type ContextualChatEntry = ChatEntry & { + context: ChatContext; + matchId?: number; + /** Stable archive identity, independent of estimated wall-clock timestamps. */ + messageId?: string; +}; export interface ChatMatch { id?: number; diff --git a/src/lib/server/fleet/chat.ts b/src/lib/server/fleet/chat.ts index dfd8710..5a3d2b1 100644 --- a/src/lib/server/fleet/chat.ts +++ b/src/lib/server/fleet/chat.ts @@ -123,9 +123,15 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context export function matchChatTimeline(matchId: number): ContextualChatEntry[] { return ( db() - .prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid') - .all(matchId) as { entry: string }[] - ).map((row) => JSON.parse(row.entry)); + .prepare( + 'SELECT message_key,occurrence,entry FROM match_chat WHERE match_id = ? ORDER BY rowid' + ) + .all(matchId) as { message_key: string; occurrence: number; entry: string }[] + ).map((row) => ({ + ...JSON.parse(row.entry), + matchId, + messageId: `${matchId}:${row.message_key}:${row.occurrence}` + })); } /** Save before filtering the live view so completed matches retain their messages. */ diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 0cd0eb6..302a7d4 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { db } from '../db/index.js'; +import { archivedEvidenceSnapshots } from './evidence.js'; import { AuthError } from '../auth/index.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { @@ -7,7 +8,6 @@ import { isModerationCaseOpen, moderationCaseOutcomes, moderationCaseStatusLabels, - moderationGuid, type ModerationCaseStatus, type ModerationCasePriority, type ModerationGame, @@ -404,42 +404,10 @@ function archivedEvidence( value: unknown, now: string ): number { - const requested = value as { matchId: unknown; message: unknown }; - if (!requested || !Number.isSafeInteger(requested.matchId)) - throw new AuthError('Choose a recorded chat message.'); - textField(requested.message, 'Chat message', 4096); - // Evidence must match the archived bytes, including intentional whitespace. - const message = requested.message as string; - const match = db() - .prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?') - .get(requested.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 = db() - .prepare('SELECT entry FROM match_chat WHERE match_id=?') - .all(requested.matchId) as { entry: string }[]; - const found = entries - .filter((row) => { - const entry = JSON.parse(row.entry); - return ( - entry.type === 'message' && - moderationGuid(target.game, entry.message.guid) === target.guid && - entry.message.message === message - ); - }) - .slice(0, 10); - if (!found.length) - throw new AuthError('The original chat message could not be verified in the archive.', 409); - for (const row of found) - evidence( - actor, - id, - message, - `Archived chat · ${match.server} · match ${requested.matchId}`, - JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }), - now - ); - return found.length; + const snapshots = archivedEvidenceSnapshots(id, target, value); + for (const snapshot of snapshots) + evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now); + return snapshots.length; } function updateCase(actor: AuthUser, input: Record, now: string) { const row = caseRow(input.id); @@ -548,6 +516,7 @@ export function mutateModeration(actorId: string, input: Record const row = caseRow(input.id); checkCaseRevision(row, input.revision); const count = archivedEvidence(actor, row.id, row, input.evidence, now); + if (!count) return { id: row.id }; touchCase(row.id, now); moderationEvent( actor, diff --git a/src/lib/server/moderation/evidence.ts b/src/lib/server/moderation/evidence.ts new file mode 100644 index 0000000..a50104d --- /dev/null +++ b/src/lib/server/moderation/evidence.ts @@ -0,0 +1,127 @@ +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.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) + }) + }; + }); +} diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index 015e0d0..671e914 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -1,8 +1,8 @@ + +

+ Selected messages and surrounding context in archive order. Messages between separate selections + may be omitted. +

+
    + {#each entries as entry} + {#if entry.type === 'message'} + {@const highlighted = + !!entry.messageId && highlightedMessageIds.includes(entry.messageId)} + {@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)} +
  1. +
    + {entry.message.player} + {#if highlighted}Subject message + {:else if selected}Selected context + {:else}Surrounding context{/if} +
    + +

    {entry.message.message}

    + {#if entry.messageId}
    + Message identifier{entry.messageId} +
    {/if} +
  2. + {/if} + {/each} +
+ + diff --git a/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte new file mode 100644 index 0000000..51c5e30 --- /dev/null +++ b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte @@ -0,0 +1,305 @@ + + + +
+
+ + ({ + value: item.id, + label: `${item.title} · ${moderationCaseStatusLabels[item.status]}` + })) + ]} + /> + + {#if !destination} + +