Capture team chat and expose moderation match archives

This commit is contained in:
2026-10-17 20:11:49 -04:00
parent b678de2f6f
commit 870f13b150
28 changed files with 943 additions and 81 deletions
+50 -6
View File
@@ -212,10 +212,14 @@ interface ChatReadState {
entries: ContextualChatEntry[];
contextVersion?: string;
wasOccupied: boolean;
needsDrain: boolean;
bytesRead: number;
}
const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000;
const MAX_IDLE_STALLED_READS = 3;
const idleStalls = new Map<string, number>();
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>();
@@ -249,6 +253,7 @@ export function collectServerChat(
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
let progressed = false;
const work = (async () => {
const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) throw new Error('Invalid server log path');
@@ -263,7 +268,9 @@ export function collectServerChat(
partial: Buffer.alloc(0),
skipLine: false,
entries: [],
wasOccupied: false
wasOccupied: false,
needsDrain: false,
bytesRead: 0
};
const tail = await readLogChunk(path, state.cursor);
const current = getServer(server.name);
@@ -290,7 +297,10 @@ export function collectServerChat(
state.entries = [];
}
state.cursor = tail.cursor;
const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]);
const content = Buffer.from(tail.contentBase64, 'base64');
progressed = content.length > 0;
state.bytesRead += content.length;
const bytes = Buffer.concat([state.partial, content]);
const end = bytes.lastIndexOf(10) + 1;
if (end) {
const lines = bytes
@@ -318,14 +328,17 @@ export function collectServerChat(
state.skipLine = true;
}
// File mtime anchors the final line only after the backlog has been read.
if (tail.cursor.offset < tail.size || state.partial.length) {
state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0;
if (state.needsDrain) {
state.signature = undefined;
readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false };
}
} else {
// Older agents remain readable during a rolling update.
state.needsDrain = false;
changed = state.signature !== `${tail.size}:${tail.mtime}`;
progressed = changed;
if (changed) state.lines = tail.lines;
}
const signature = `${tail.size}:${tail.mtime}`;
@@ -354,6 +367,9 @@ export function collectServerChat(
})()
.then(
(result) => {
const state = readers.get(server.name);
if (progressed || result.missing || (state?.key === key && !state.needsDrain))
idleStalls.delete(key);
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result;
},
@@ -377,25 +393,53 @@ export async function collectFleetChat(): Promise<void> {
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key);
const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
return false;
const players = observe(server).last_players ?? server.last_players;
// One final read after players leave retains messages written at match end.
return players !== 0 || readers.get(server.name)?.wasOccupied;
const key = chatReadKey(server);
if (players !== 0) {
idleStalls.delete(key);
return true;
}
const stalls = idleStalls.get(key) ?? 0;
if (stalls >= MAX_IDLE_STALLED_READS) return false;
const last = lastReads.get(key);
if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS)
return false;
const state = readers.get(server.name);
// Pick up surviving final logs after a collector restart, then finish any
// unread chunks even after players leave. Errors and incomplete final lines
// have bounded idle retries rather than polling an empty server forever.
return (
!last ||
'error' in last ||
(state?.key === key && (state.wasOccupied || state.needsDrain))
);
});
// Bound agent traffic and isolate failures to their own server.
async function collect() {
for (let server = servers.shift(); server; server = servers.shift()) {
const key = chatReadKey(server);
const before = readers.get(server.name);
try {
await collectServerChat(server);
const state = readers.get(server.name);
if (state)
if (state?.key === key) {
state.wasOccupied =
(observe(server).last_players ?? server.last_players ?? 0) > 0;
if (!state.wasOccupied && state.needsDrain) {
const progressed =
before?.key !== key || state.bytesRead > before.bytesRead;
idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1);
} else idleStalls.delete(key);
}
failures.delete(server.name);
} catch (error) {
if ((observe(server).last_players ?? server.last_players) === 0)
idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1);
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
console.error(`[chat] Could not collect chat for ${server.name}`, error);
failures.set(server.name, Date.now());
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { AuthUser } from '../../auth.js';
import { AuthError } from '../auth/index.js';
import { moderationActor } from '../moderation/core.js';
export type MatchArchiveLocals = { user: AuthUser | null };
/** Recheck current account privileges before returning the expanded moderation archive. */
export function requireMatchArchiveAccess(user: Pick<AuthUser, 'id'> | null | undefined): void {
try {
moderationActor(user?.id ?? '');
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
}
+9 -5
View File
@@ -32,7 +32,7 @@ export function serverMatchHistory(
now = Date.now()
): MatchHistoryResponse {
const handle = db();
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`];
const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: Array<string | number> = [];
if (server !== undefined) {
clauses.push('m.server = ?');
@@ -93,14 +93,18 @@ export function serverMatchHistory(
}
/** Read durable match records without depending on a current server or scoreboard. */
export function getMatchDetail(id: number): MatchDetail | null {
export function getMatchDetail(id: number, archive = false): MatchDetail | null {
if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db();
const match = handle
.prepare(
`SELECT ${matchSummary}, m.start_observed AS startObserved
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
FROM leaderboard_matches m WHERE m.id = ?${
archive
? ''
: ` AND m.status = 'completed'
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
}`
)
.get(id) as
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
@@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
(SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats,
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
@@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
)
.all(match.game, match.reporterMatchKey, id) as Array<
.all(match.game, match.status, match.reporterMatchKey, id) as Array<
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number;
presentAtEnd: number;
+1
View File
@@ -81,6 +81,7 @@ export function archivedEvidenceSnapshots(
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!);