285 lines
8.4 KiB
TypeScript
285 lines
8.4 KiB
TypeScript
import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js';
|
|
|
|
export type ModerationGame = 't5' | 't6';
|
|
export type ModerationScope = 'server' | 'game' | 'fleet';
|
|
export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
|
export type ModerationState =
|
|
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
|
|
|
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
|
|
export const moderationCaseOutcomes = [
|
|
'resolved',
|
|
'dismissed',
|
|
'warning_issued',
|
|
'restriction_applied',
|
|
'no_action',
|
|
'duplicate',
|
|
'appeal_upheld',
|
|
'appeal_denied'
|
|
] as const;
|
|
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
|
|
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
|
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
|
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
|
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
|
|
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
|
|
same_incident: 'Same incident',
|
|
appeal_of: 'Appeal of',
|
|
duplicate_of: 'Duplicate of'
|
|
};
|
|
export const moderationCaseStatusLabels: Record<
|
|
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
|
|
string
|
|
> = {
|
|
open: 'Open',
|
|
in_review: 'In progress',
|
|
closed: 'Closed',
|
|
escalated: 'Escalated',
|
|
resolved: 'Resolved',
|
|
dismissed: 'Dismissed',
|
|
warning_issued: 'Warning issued',
|
|
restriction_applied: 'Restriction applied',
|
|
no_action: 'No action needed',
|
|
duplicate: 'Duplicate',
|
|
appeal_upheld: 'Appeal upheld',
|
|
appeal_denied: 'Appeal denied'
|
|
};
|
|
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
|
return status === 'open' || status === 'in_review';
|
|
}
|
|
|
|
export const moderationConcernCategories = [
|
|
'hate_speech',
|
|
'harassment',
|
|
'threats',
|
|
'safeguarding',
|
|
'spam',
|
|
'cheating',
|
|
'other'
|
|
] as const;
|
|
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
|
|
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
|
|
hate_speech: 'Hate speech',
|
|
harassment: 'Harassment',
|
|
threats: 'Threats',
|
|
safeguarding: 'Safeguarding',
|
|
spam: 'Spam',
|
|
cheating: 'Cheating',
|
|
other: 'Other'
|
|
};
|
|
export const moderationFindingOutcomes = [
|
|
'substantiated',
|
|
'inconclusive',
|
|
'unsubstantiated'
|
|
] as const;
|
|
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
|
|
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
|
|
substantiated: 'Substantiated',
|
|
inconclusive: 'Inconclusive',
|
|
unsubstantiated: 'Unsubstantiated'
|
|
};
|
|
export interface ModerationCaseAssessment {
|
|
revision: number;
|
|
summary: string;
|
|
rules: string[];
|
|
findings: {
|
|
category: ModerationConcernCategory;
|
|
finding: ModerationFindingOutcome;
|
|
detail: string;
|
|
}[];
|
|
actor: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ModerationPlayer {
|
|
game: ModerationGame;
|
|
guid: string;
|
|
name: string;
|
|
aliases: string[];
|
|
firstSeen: string | null;
|
|
lastSeen: string | null;
|
|
servers: string[];
|
|
restrictions: number;
|
|
}
|
|
export interface ModerationSanction {
|
|
id: string;
|
|
caseId?: string | null;
|
|
game: ModerationGame;
|
|
guid: string;
|
|
kind: 'ban' | 'mute';
|
|
scope: ModerationScope;
|
|
server: string | null;
|
|
reason: string;
|
|
note: string;
|
|
createdAt: string;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
actorId: string;
|
|
actor: string;
|
|
revision: number;
|
|
active: boolean;
|
|
}
|
|
|
|
export function canRevokeModerationRestriction(
|
|
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
|
|
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
|
|
): boolean {
|
|
if (actor?.role === 'admin') return true;
|
|
if (
|
|
actor?.role !== 'moderator' ||
|
|
actor.id !== restriction.actorId ||
|
|
(restriction.scope !== 'server' && restriction.scope !== 'game') ||
|
|
!restriction.expiresAt
|
|
)
|
|
return false;
|
|
const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt);
|
|
return duration > 0 && duration <= moderatorMaximumRestrictionMs;
|
|
}
|
|
|
|
export interface ModerationAction {
|
|
id: string;
|
|
game: ModerationGame;
|
|
guid: string | null;
|
|
server: string;
|
|
kind: ModerationActionKind;
|
|
reason: string;
|
|
actor: string;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
state: ModerationState;
|
|
detail: string | null;
|
|
}
|
|
export interface ModerationCase {
|
|
id: string;
|
|
game: ModerationGame;
|
|
guid: string;
|
|
title: string;
|
|
status: ModerationCaseStatus;
|
|
priority: ModerationCasePriority;
|
|
assigneeId: string | null;
|
|
assignee: string | null;
|
|
actor: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
revision: number;
|
|
assessment?: ModerationCaseAssessment | null;
|
|
reviewAt?: string | null;
|
|
reviewReason?: string;
|
|
reviewSanctionId?: string | null;
|
|
unreadUpdates?: number;
|
|
evidence: {
|
|
id: string;
|
|
text: string;
|
|
source: string;
|
|
snapshot?: string | null;
|
|
createdAt: string;
|
|
actor: string;
|
|
}[];
|
|
}
|
|
export interface ModerationCaseDetail {
|
|
case: ModerationCase;
|
|
player: ModerationPlayer;
|
|
sanctions: ModerationSanction[];
|
|
linkedActions?: ModerationCaseAction[];
|
|
availableActions?: ModerationCaseAction[];
|
|
assessment?: ModerationCaseAssessment | null;
|
|
assessmentHistory?: ModerationCaseAssessment[];
|
|
relatedCases?: ModerationCaseRelationship[];
|
|
candidateCases?: ModerationCaseCandidate[];
|
|
notifications?: ModerationCaseNotification[];
|
|
events: ModerationEvent[];
|
|
coverage: ModerationCoverage[];
|
|
assignees: { id: string; username: string }[];
|
|
checkedAt: string;
|
|
}
|
|
export type ModerationCaseReference = Pick<
|
|
ModerationCase,
|
|
'id' | 'title' | 'status' | 'game' | 'guid'
|
|
>;
|
|
export interface ModerationCaseRelationship {
|
|
id: string;
|
|
relation: ModerationCaseRelation;
|
|
direction: 'outgoing' | 'incoming';
|
|
case: ModerationCaseReference;
|
|
actor: string;
|
|
createdAt: string;
|
|
}
|
|
export interface ModerationCaseCandidate extends ModerationCaseReference {
|
|
sharedMatch: boolean;
|
|
}
|
|
export interface ModerationCaseNotification {
|
|
id: number;
|
|
caseId: string;
|
|
caseTitle: string;
|
|
kind: 'evidence' | 'assignment';
|
|
createdAt: string;
|
|
readAt: string | null;
|
|
}
|
|
export interface ModerationCaseAction {
|
|
id: string;
|
|
type: 'restriction' | 'action';
|
|
kind: string;
|
|
reason: string;
|
|
actor: string;
|
|
createdAt: string;
|
|
state: string;
|
|
expiresAt: string | null;
|
|
}
|
|
export interface ModerationEvent {
|
|
id: number;
|
|
game: ModerationGame | null;
|
|
guid: string | null;
|
|
action: string;
|
|
subjectId: string | null;
|
|
actor: string;
|
|
detail: string;
|
|
createdAt: string;
|
|
}
|
|
export interface ModerationCoverage {
|
|
server: string;
|
|
game: ModerationGame;
|
|
connectedAt: string | null;
|
|
revision: number | null;
|
|
desiredRevision: number;
|
|
state: 'current' | 'pending' | 'offline' | 'unavailable';
|
|
capabilities: string[];
|
|
}
|
|
export interface ModerationQuery {
|
|
view: 'players' | 'restrictions' | 'cases' | 'history';
|
|
q: string;
|
|
game: '' | ModerationGame;
|
|
guid: string;
|
|
offset: number;
|
|
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
|
|
priority?: '' | ModerationCasePriority;
|
|
assignedTo?: string;
|
|
review?: '' | 'due' | 'scheduled' | 'updates';
|
|
}
|
|
export interface ModerationOverview {
|
|
query: ModerationQuery;
|
|
players: ModerationPlayer[];
|
|
sanctions: ModerationSanction[];
|
|
cases: ModerationCase[];
|
|
events: ModerationEvent[];
|
|
actions: ModerationAction[];
|
|
coverage: ModerationCoverage[];
|
|
total: number;
|
|
limit: number;
|
|
checkedAt: string;
|
|
notifications?: ModerationCaseNotification[];
|
|
unreadCount?: number;
|
|
}
|
|
|
|
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
|
export function moderationGuid(
|
|
game: ModerationGame,
|
|
guid: string,
|
|
source: 'game' | 'rcon' = 'game'
|
|
): string {
|
|
const value = guid.trim();
|
|
if (game === 't6' && source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(value))
|
|
return BigInt(`0x${value.replace(/^0x/i, '')}`).toString();
|
|
if (/^\d+$/.test(value)) return BigInt(value).toString();
|
|
return value;
|
|
}
|