Preserve conversation excerpts and deduplicate case evidence
This commit is contained in:
@@ -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)
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user