129 lines
5.7 KiB
TypeScript
129 lines
5.7 KiB
TypeScript
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.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<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)
|
|
})
|
|
};
|
|
});
|
|
}
|