Preserve conversation excerpts and deduplicate case evidence

This commit is contained in:
2026-10-17 19:35:52 -04:00
parent 83f1c91877
commit ef96a2c308
13 changed files with 1222 additions and 50 deletions
+13 -4
View File
@@ -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.
+6 -1
View File
@@ -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;
+9 -3
View File
@@ -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. */
+6 -37
View File
@@ -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<string, unknown>, now: string) {
const row = caseRow(input.id);
@@ -548,6 +516,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
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,
+127
View File
@@ -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<ContextualChatEntry, { type: 'message' }>;
/** 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<string, unknown> | 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<string>();
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<number>();
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)
})
};
});
}
+137 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import { canModerate, canReadModeration } from '$lib/auth.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
@@ -10,6 +10,7 @@
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
let {
server,
@@ -40,6 +41,41 @@
let follow = $state(true);
let paused = $state(false);
let controller: AbortController | null = null;
const selectableMessages = $derived(
history !== undefined &&
canModerate($page.data?.user?.role) &&
(moderationGame === 't5' || moderationGame === 't6')
? entries.filter(
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
)
: []
);
let selectedMessageIds = $state<string[]>([]);
let evidenceOpen = $state(false);
let attachedCaseId = $state('');
const selectedMatchId = $derived(
selectableMessages.find(
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
)?.matchId
);
const selectionCandidates = $derived(
selectableMessages.filter(
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
)
);
$effect(() => {
void history;
selectedMessageIds = [];
evidenceOpen = false;
attachedCaseId = '';
});
function toggleEvidence(messageId: string) {
selectedMessageIds = selectedMessageIds.includes(messageId)
? selectedMessageIds.filter((id) => id !== messageId)
: [...selectedMessageIds, messageId];
}
async function refresh(manual = false) {
if (!server || hostOffline) return;
@@ -175,6 +211,36 @@
</div>
{/if}
</div>
{#if selectableMessages.length}
<div class="evidence-toolbar" aria-label="Chat evidence selection">
<span>{selectedMessageIds.length} selected</span>
<Button
size="sm"
onclick={() =>
(selectedMessageIds = selectionCandidates
.slice(0, 100)
.map((entry) => entry.messageId!))}
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
>
<Button
size="sm"
disabled={!selectedMessageIds.length}
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
>
<Button
size="sm"
variant="primary"
disabled={!selectedMessageIds.length}
onclick={() => (evidenceOpen = true)}>Attach to case</Button
>
{#if selectedMessageIds.length >= 100}<span
>Attach up to 100 selected messages at a time.</span
>{/if}
</div>
{/if}
{#if attachedCaseId}<p class="evidence-notice" role="status">
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
</p>{/if}
{#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.'
@@ -236,7 +302,11 @@
{@render matchBoundary(item.entries[item.entries.length - 1])}
</li>
{:else}
<li class="message">
<li
class="message"
class:selected={!!item.entry.messageId &&
selectedMessageIds.includes(item.entry.messageId)}
>
<span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime
@@ -246,6 +316,27 @@
</span>
<div class="message-content">
<div class="message-head">
{#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
<label class="evidence-select">
<input
type="checkbox"
checked={selectedMessageIds.includes(
item.entry.messageId
)}
disabled={!selectedMessageIds.includes(
item.entry.messageId
) &&
(selectedMessageIds.length >= 100 ||
(!!selectedMatchId &&
selectedMatchId !==
item.entry.matchId))}
onchange={() =>
toggleEvidence(item.entry.messageId!)}
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
/>
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
@@ -271,6 +362,20 @@
{/if}
</section>
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
<MatchEvidenceDialog
bind:open={evidenceOpen}
game={moderationGame}
matchId={selectedMatchId}
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
{selectedMessageIds}
onsubmitted={(id) => {
attachedCaseId = id;
selectedMessageIds = [];
}}
/>
{/if}
<style>
section {
container-type: inline-size;
@@ -283,6 +388,31 @@
gap: var(--space-3);
flex-wrap: wrap;
}
.evidence-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-notice {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-select {
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
}
.evidence-select input {
accent-color: var(--accent-primary);
}
.message.selected {
background: var(--surface-raised);
}
.chat-head {
justify-content: space-between;
}
@@ -488,5 +618,9 @@
grid-column: 1 / -1;
grid-row: 4;
}
.evidence-select {
grid-column: 1 / -1;
grid-row: 5;
}
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
} = $props();
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each}
</ol>
<style>
.excerpt {
display: grid;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
background: var(--surface-sunken);
min-width: 0;
}
li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.metadata {
margin-top: var(--space-1);
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
}
details {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ContextualChatEntry } from '$lib/game-chat.js';
import {
moderationGuid,
moderationCaseStatusLabels,
type ModerationCase,
type ModerationGame,
type ModerationOverview
} from '$lib/moderation.js';
import { conversationExcerpt } from '$ui/moderation-evidence.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import EvidenceMessages from './EvidenceMessages.svelte';
let {
open = $bindable(false),
game,
matchId,
entries,
selectedMessageIds,
onsubmitted
}: {
open?: boolean;
game: ModerationGame;
matchId: number;
entries: ContextualChatEntry[];
selectedMessageIds: string[];
onsubmitted: (id: string) => void;
} = $props();
const selected = $derived(
entries.filter(
(entry) =>
entry.type === 'message' &&
!!entry.messageId &&
selectedMessageIds.includes(entry.messageId)
)
);
const participants = $derived.by(() => {
const players = new Map<string, string>();
for (const entry of selected) {
if (entry.type === 'message')
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
}
return [...players].map(([guid, name]) => ({
value: guid,
label: `${name} · GUID ${guid}`
}));
});
let guid = $state(untrack(() => participants[0]?.value ?? ''));
let destination = $state('');
let title = $state('');
let note = $state('');
let contextBefore = $state<number | undefined>(3);
let contextAfter = $state<number | undefined>(3);
let cases = $state<ModerationCase[]>([]);
let loading = $state(false);
let loadError = $state('');
let reload = $state(0);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
const excerpt = $derived(
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
);
const highlightedMessageIds = $derived(
selected.flatMap((entry) =>
entry.type === 'message' &&
entry.messageId &&
moderationGuid(game, entry.message.guid) === guid
? [entry.messageId]
: []
)
);
$effect(() => {
const selectedGuid = guid;
const selectedGame = game;
void reload;
const controller = new AbortController();
cases = [];
destination = '';
loadError = '';
loading = true;
async function loadCases() {
try {
const loaded: ModerationCase[] = [];
let offset = 0;
while (true) {
const params = new URLSearchParams({
view: 'cases',
game: selectedGame,
guid: selectedGuid,
offset: String(offset)
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok)
throw new Error(
'Could not load existing cases. Retry before attaching evidence.'
);
const report: ModerationOverview = await response.json();
if (controller.signal.aborted) return;
loaded.push(...report.cases);
offset += report.cases.length;
if (!report.cases.length || offset >= report.total) break;
}
cases = loaded;
} catch (cause) {
if (!controller.signal.aborted)
loadError =
cause instanceof Error ? cause.message : 'Could not load existing cases.';
} finally {
if (!controller.signal.aborted) loading = false;
}
}
void loadCases();
return () => controller.abort();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || (!pending && (loading || loadError))) return;
if (!pending) {
const target = cases.find((item) => item.id === destination);
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
error = 'Choose a case subject with at least one selected message.';
return;
}
if (
![contextBefore, contextAfter].every(
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
)
) {
error = 'Choose between 0 and 20 surrounding messages on each side.';
return;
}
pending = {
...(target
? { op: 'attach-evidence', id: target.id, revision: target.revision }
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
if (response.status === 409) reload++;
throw new Error(result?.error || 'Could not attach the selected evidence.');
}
open = false;
onsubmitted(result.id);
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Attach to case"
{busy}
wide
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field
label="Case subject"
required
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
>
<Select bind:value={guid} options={participants} required />
</Field>
{#if loading}<p role="status">Loading this player's cases…</p>
{:else if loadError}<p class="ui-error" role="alert">
{loadError}
<Button size="sm" onclick={() => reload++}>Retry</Button>
</p>
{:else}
<Field
label="Destination"
hint={cases.length
? 'Review existing cases before opening another.'
: 'No existing cases for this player.'}
>
<Select
bind:value={destination}
options={[
{ value: '', label: 'Open a new case' },
...cases.map((item) => ({
value: item.id,
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
}))
]}
/>
</Field>
{#if !destination}
<Field label="Case title" required
><Input bind:value={title} maxlength={160} required /></Field
>
<Field label="Private staff note"
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
>
{/if}
{/if}
<div class="paired">
<Field label="Messages before" hint="Per selected message"
><Input
type="number"
bind:value={contextBefore}
min={0}
max={20}
step={1}
required
/></Field
>
<Field label="Messages after" hint="Per selected message"
><Input
type="number"
bind:value={contextAfter}
min={0}
max={20}
step={1}
required
/></Field
>
</div>
<p class="hint">
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry the same request to avoid duplicate evidence.
</p>{/if}
<div class="actions">
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={!uncertain && (loading || !!loadError)}
>{uncertain
? 'Retry same request'
: destination
? 'Attach evidence'
: 'Open case with evidence'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-body);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
</style>
+55
View File
@@ -0,0 +1,55 @@
import type { ContextualChatEntry } from '$lib/game-chat.js';
/** Select surrounding messages by archive order, preserving each message once. */
export function conversationExcerpt(
entries: readonly ContextualChatEntry[],
selectedMessageIds: readonly string[],
contextBefore = 3,
contextAfter = 3
): ContextualChatEntry[] {
const messages = entries.filter((entry) => entry.type === 'message');
const selected = new Set(selectedMessageIds);
const included = new Set<number>();
messages.forEach((entry, index) => {
if (!entry.messageId || !selected.has(entry.messageId)) return;
for (
let i = Math.max(0, index - contextBefore);
i <= Math.min(messages.length - 1, index + contextAfter);
i++
)
included.add(i);
});
return messages.filter((_, index) => included.has(index));
}
/** Old single-message snapshots remain readable alongside conversation excerpts. */
export function preservedConversation(value: unknown): {
entries: ContextualChatEntry[];
selectedMessageIds: string[];
highlightedMessageIds: string[];
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null;
const entries = snapshot.entries.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
typeof entry.message?.player === 'string' &&
typeof entry.message?.guid === 'string' &&
typeof entry.message?.message === 'string' &&
typeof entry.message?.time === 'string' &&
entry.context &&
typeof entry.context === 'object'
);
}
);
if (!entries.length) return null;
const strings = (input: unknown): string[] =>
Array.isArray(input) ? input.filter((id): id is string => typeof id === 'string') : [];
return {
entries,
selectedMessageIds: strings(snapshot.selectedMessageIds),
highlightedMessageIds: strings(snapshot.highlightedMessageIds)
};
}
@@ -28,6 +28,8 @@
import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
import EvidenceMessages from '$ui/components/moderation/EvidenceMessages.svelte';
import { preservedConversation } from '$ui/moderation-evidence.js';
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
let snapshot = $state<ModerationCaseDetail | null>(null);
@@ -179,7 +181,8 @@
round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number)
: null,
original: JSON.stringify(value, null, 2)
original: JSON.stringify(value, null, 2),
excerpt: preservedConversation(value)
};
} catch {
return null;
@@ -280,7 +283,9 @@
>Round {archive.round}</Badge
>{/if}
</div>
<blockquote>{evidence.text}</blockquote>
{#if archive?.excerpt}
<EvidenceMessages {...archive.excerpt} />
{:else}<blockquote>{evidence.text}</blockquote>{/if}
<p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context">
{#if archive.matchId}<a
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({ data: { user: { role: 'moderator' as UserRole } } });
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const history: ContextualChatEntry[] = [
['11', 'Subject', 'A concerning reply'],
['22', 'Other player', 'The question that explains it'],
['11', 'Subject', 'A later reply']
].map(([guid, player, message], index) => ({
type: 'message',
matchId: 17,
messageId: `17:message-${index}:1`,
message: { guid, player, message, time: `1:0${index}`, slot: index },
context: { realTime: null, matchElapsedMs: index * 1000, round: 2 }
}));
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const response = (value: unknown) => new Response(JSON.stringify(value));
function show(role: UserRole = 'moderator') {
currentPage.set({ data: { user: { role } } });
component = mount(ChatPanel, { target: document.body, props: { game: 't5', history } });
flushSync();
}
function select(label: string, value: string) {
const labelNode = [...document.querySelectorAll('dialog label')].find(
(node) => node.textContent?.replace('*', '').trim() === label
)!;
const node = document.querySelector<HTMLSelectElement>(`[aria-labelledby="${labelNode.id}"]`)!;
const originalQuery = node.querySelector.bind(node);
node.querySelector = ((selector: string) =>
selector === ':checked'
? (node.selectedOptions[0] ?? null)
: originalQuery(selector)) as typeof node.querySelector;
node.value = value;
node.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
}
const submit = () =>
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('selects messages across participants, previews their context, and attaches to an existing case', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
response({
cases: [{ id: 'case-1', title: 'Existing review', status: 'open', revision: 4 }],
total: 1
})
)
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }));
vi.stubGlobal('fetch', fetch);
show();
expect(button('Attach to case').disabled).toBe(true);
const checkboxes = document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
checkboxes[0].click();
checkboxes[1].click();
flushSync();
expect(document.body.textContent).toContain('2 selected');
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('Existing review')
);
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown');
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
expect(excerpt.textContent).toContain('Selected context');
select('Destination', 'case-1');
submit();
await vi.waitFor(() => expect(document.body.textContent).toContain('Selected evidence saved.'));
expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({
op: 'attach-evidence',
id: 'case-1',
revision: 4,
evidence: {
matchId: 17,
messageIds: ['17:message-0:1', '17:message-1:1'],
contextBefore: 3,
contextAfter: 3
},
idempotencyKey: expect.any(String)
});
expect(document.querySelector('a[href="/moderation/cases/case-1"]')).not.toBeNull();
expect(
[...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')].every(
(node) => !node.checked
)
).toBe(true);
});
it('opens a new case with all selected messages and retries an uncertain request unchanged', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(response({ cases: [], total: 0 }))
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(response({ ok: true, id: 'new-case' }));
vi.stubGlobal('fetch', fetch);
show();
button('Select all').click();
flushSync();
button('Attach to case').click();
await vi.waitFor(() =>
expect(document.querySelector('dialog')?.textContent).toContain('No existing cases')
);
const title = document.querySelector<HTMLInputElement>('dialog input:not([type="number"])')!;
title.value = 'Conversation review';
title.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
submit();
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(3));
expect(fetch.mock.calls[2][1].body).toBe(fetch.mock.calls[1][1].body);
expect(JSON.parse(fetch.mock.calls[1][1].body)).toMatchObject({
op: 'create-case',
game: 't5',
guid: '11',
title: 'Conversation review',
evidence: { messageIds: history.map((entry) => entry.messageId) }
});
await tick();
});
it('keeps evidence selection unavailable to read-only viewers', () => {
show('super_viewer');
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
expect(button('Attach to case')).toBeUndefined();
});
+78
View File
@@ -453,3 +453,81 @@ it('keeps decision history writable when a case has reached its evidence limit',
expect((await detail(id)).case).toMatchObject({ status: 'open', revision: 3 });
expect((await detail(id)).case.evidence).toHaveLength(102);
});
it('preserves all participants and selected authors, distinguishes repeated messages, and deduplicates attachments', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
for (let i = 0; i < 22; i++) {
const subject = i > 0 && i < 20;
insert.run(
matchId,
subject ? 'repeat' : `context-${i}`,
subject ? i : 1,
JSON.stringify({
type: 'message',
context: { realTime: null, round: null, matchElapsedMs: null },
message: {
time: '1:01',
guid: subject ? '123' : '456',
player: subject ? 'Subject' : 'Witness',
slot: subject ? 1 : 2,
message: subject ? 'Repeated message' : `Context ${i}`
}
})
);
}
const entries = matchChatTimeline(matchId);
expect(new Set(entries.map((entry) => entry.messageId)).size).toBe(22);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: entries.slice(1, 20).map((entry) => entry.messageId) }
});
let data = await detail(id);
expect(data.case.evidence).toHaveLength(1);
const snapshot = JSON.parse(data.case.evidence[0].snapshot!);
expect(snapshot.entries).toHaveLength(22);
expect(snapshot.selectedMessageIds).toHaveLength(19);
expect(snapshot.highlightedMessageIds).toHaveLength(19);
expect(snapshot.entries[0].message.player).toBe('Witness');
await mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, message: 'Repeated message' }
});
data = await detail(id);
expect(data.case.revision).toBe(2);
expect(data.case.evidence).toHaveLength(1);
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 2,
evidence: { matchId, messageIds: ['forged'] }
})
).rejects.toThrow('identifiers');
const legacy = await create();
await mutate({
op: 'attach-evidence',
id: legacy.id,
revision: 1,
evidence: { matchId, message: 'Repeated message' }
});
expect((await detail(legacy.id)).case.evidence).toHaveLength(19);
handle.prepare('DELETE FROM leaderboard_matches').run();
expect(JSON.parse((await detail(id)).case.evidence[0].snapshot!).entries).toHaveLength(22);
});
+220
View File
@@ -0,0 +1,220 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const clock = '2026-10-17T12:00:00.000Z';
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','moderator','unused','moderator',0,0)`
)
.run();
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>) {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration('moderator', { idempotencyKey: randomUUID(), ...input });
}
async function detail(id: string) {
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
return moderationCaseDetail('moderator', id);
}
async function create(overrides: Record<string, unknown> = {}) {
return mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review archived conversation',
...overrides
});
}
async function archive(messages: Array<{ guid?: string; message: string }>) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({
server,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Subject' }],
receivedAt: clock
});
const { id: matchId } = handle.prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const insert = handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)');
for (const [index, message] of messages.entries())
insert.run(
matchId,
`message-${index}`,
1,
JSON.stringify({
type: 'message',
context: { realTime: null, matchElapsedMs: null, round: null },
message: {
time: '1:01',
guid: message.guid ?? '123',
slot: message.guid ? 2 : 1,
player: message.guid ? 'Witness' : 'Subject',
message: message.message
}
})
);
return { matchId, entries: matchChatTimeline(matchId) };
}
it('rejects a witness-only selection, foreign archive IDs and another game without changing a case', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const { id } = await create();
const before = await detail(id);
for (const messageIds of [
[entries[1].messageId],
[`${matchId + 1}:message-0:1`],
[entries[0].messageId, `${matchId + 1}:message-1:1`]
])
await expect(
mutate({ op: 'attach-evidence', id, revision: 1, evidence: { matchId, messageIds } })
).rejects.toMatchObject({ status: expect.any(Number) });
expect(await detail(id)).toEqual(before);
const other = await create({ game: 't6' });
await expect(
mutate({
op: 'attach-evidence',
id: other.id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
})
).rejects.toMatchObject({ status: 404 });
expect((await detail(other.id)).case).toMatchObject({ revision: 1, evidence: [] });
});
it('allows a new witness reply alongside an attached subject and deduplicates later selections', async () => {
const { matchId, entries } = await archive([
{ message: 'Subject statement' },
{ guid: '456', message: 'Witness reply' }
]);
const [subjectId, witnessId] = entries.map((entry) => entry.messageId!);
const { id } = await create();
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [subjectId], contextBefore: 0, contextAfter: 0 }
});
const request = {
op: 'attach-evidence',
id,
revision: 2,
evidence: {
matchId,
messageIds: [witnessId, subjectId, witnessId],
contextBefore: 0,
contextAfter: 0
},
idempotencyKey: randomUUID()
};
await mutate(request);
await mutate(request);
const attached = await detail(id);
expect(attached.case.revision).toBe(3);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.selectedMessageIds).toEqual([witnessId]);
expect(snapshot.highlightedMessageIds).toEqual([subjectId]);
expect(snapshot.entries.map((entry: { messageId: string }) => entry.messageId)).toEqual([
subjectId,
witnessId
]);
expect(snapshot.entries[1].message.guid).toBe('456');
await mutate({ ...request, revision: 3, idempotencyKey: randomUUID() });
expect(await detail(id)).toEqual(attached);
});
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
const messageId = entries[0].messageId!;
const invalid = [
{ messageIds: [] },
{ messageIds: Array.from({ length: 101 }, () => messageId) },
{ messageIds: [messageId], contextBefore: -1 },
{ messageIds: [messageId], contextAfter: 21 },
{ messageIds: [messageId], contextBefore: 1.5 },
{ messageIds: [messageId], contextAfter: '3' }
];
for (const evidence of invalid)
await expect(create({ evidence: { matchId, ...evidence } })).rejects.toMatchObject({
status: 400
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_cases').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 0
});
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({
count: 0
});
});
it('rolls back a legacy bulk attachment if the case reaches its evidence limit partway through', async () => {
const { matchId } = await archive([
{ message: 'Repeated statement' },
{ message: 'Repeated statement' },
{ message: 'Repeated statement' }
]);
const { id } = await create();
const insert = handle.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)');
for (let index = 0; index < 99; index++)
insert.run(
randomUUID(),
id,
`Note ${index}`,
'Staff note',
null,
'moderator',
'moderator',
clock
);
const before = await detail(id);
const idempotencyKey = randomUUID();
await expect(
mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, message: 'Repeated statement' },
idempotencyKey
})
).rejects.toMatchObject({ status: 409 });
expect(await detail(id)).toEqual(before);
expect(
handle
.prepare('SELECT COUNT(*) AS count FROM moderation_requests WHERE request_key=?')
.get(idempotencyKey)
).toEqual({ count: 0 });
});