Gateway: - Remove dead barrels (ai-moderation/index, attachment-upload/index, message-capture/index) — all consumers import files directly - Remove orphaned schema/ split dir (analytics/cache/messages/meta) — schema.ts is monolithic - Merge duplicate config singleton: delete shared/config/config.ts, point all 44 imports at shared/config/index Backend: - Remove dead commandHelper.ts (voice-era fallback), ws/index.ts barrel, health.schema.ts, moderationMetrics.ts, analysis.schema.ts (0 importers; metrics/handlers route directly) Frontend: - Remove orphaned CategoryDrilldown/CoverageTiles/TopicTrends, primitives/slot, use-mobile, use-mounted - Remove unused charts donut/sparkline (TopicTrends was only consumer) Kept (verified active): shared/database/index.ts facade (11 importers), hooks/index + lib/api/index barrels (10 importers), orpc/ws.ts, charts/index.ts barrel. Verified: tsc + biome + vitest per service (backend e2e 3 failures pre-existing on main); frontend next build 8 routes.
563 lines
19 KiB
TypeScript
563 lines
19 KiB
TypeScript
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
|
import { LRUCache } from "lru-cache";
|
|
import { createChildLogger } from "@/shared/logger/index";
|
|
import { config } from "../../shared/config/index.js";
|
|
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
|
|
import { messageStore } from "../message-capture/messageStore.js";
|
|
import type { MessageRecord } from "../message-capture/types.js";
|
|
import {
|
|
isEligibleForAutoDelete,
|
|
isNicknameOnlyViolation,
|
|
} from "./autoDeleteEligibility.js";
|
|
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
|
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
|
import { llmChat } from "./llmClient.js";
|
|
import { verdictToActionFields } from "./verdictToActionFields.js";
|
|
|
|
const logger = createChildLogger("auto-delete-manager");
|
|
|
|
export interface AutoDeleteResult {
|
|
deleted: boolean;
|
|
skipped: boolean;
|
|
reason: string;
|
|
}
|
|
|
|
// Cooldown per guild:user — a nick violation fires per message, but the
|
|
// Discord PATCH is idempotent; hammering it on every message by the same
|
|
// member is wasteful and risks rate limits.
|
|
const recentNicknameResets = new LRUCache<string, number>({
|
|
max: 200,
|
|
ttl: config.AUTO_NICKNAME_RESET_COOLDOWN_MS ?? 10 * 60 * 1000,
|
|
});
|
|
|
|
export function isNicknameResetInCooldown(
|
|
guildId: string,
|
|
userId: string,
|
|
): boolean {
|
|
return recentNicknameResets.has(`${guildId}:${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Resets a member's server nickname to the default (global username) —
|
|
* Discord's `setNickname(null)` removes the custom nick so the member is
|
|
* shown under their default username. Non-blocking; failures are logged
|
|
* but never throw into the moderation pipeline.
|
|
*/
|
|
export async function resetOffensiveNickname(
|
|
client: Client | undefined,
|
|
guildId: string,
|
|
userId: string,
|
|
messageId: string,
|
|
): Promise<boolean> {
|
|
const cooldownKey = `${guildId}:${userId}`;
|
|
try {
|
|
if (!client?.user?.id) {
|
|
logger.warn(
|
|
{ messageId, guildId, userId },
|
|
"Nick reset skipped: client missing",
|
|
);
|
|
return false;
|
|
}
|
|
if (userId === client.user.id) {
|
|
logger.debug({ userId }, "Nick reset skipped: operator's own account");
|
|
return false;
|
|
}
|
|
if (recentNicknameResets.has(cooldownKey)) {
|
|
logger.debug({ guildId, userId }, "Nick reset skipped: cooldown active");
|
|
return false;
|
|
}
|
|
if (config.AUTO_NICKNAME_RESET_ENABLED === false) return false;
|
|
|
|
const guild = client.guilds.cache.get(guildId);
|
|
if (!guild) {
|
|
logger.warn(
|
|
{ messageId, guildId },
|
|
"Nick reset skipped: guild not found",
|
|
);
|
|
return false;
|
|
}
|
|
const member = await guild.members.fetch(userId);
|
|
// Discord rejects setNickname with "Missing Permissions" (code 50013)
|
|
// whenever the target's top role sits above the bot in the role
|
|
// hierarchy — even when the bot has MANAGE_NICKNAMES. Guard on
|
|
// `manageable` (hierarchy-aware) so we skip with a clear reason
|
|
// instead of hammering a doomed PATCH on every message.
|
|
if (!member.manageable) {
|
|
logger.debug(
|
|
{
|
|
messageId,
|
|
guildId,
|
|
userId,
|
|
reason: "target role above bot in hierarchy (Discord 50013)",
|
|
},
|
|
"Nick reset skipped: member not manageable by bot",
|
|
);
|
|
return false;
|
|
}
|
|
// setNickname(null) = remove nickname → Discord shows global username
|
|
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
|
|
|
// ── Check if the global username is ALSO offensive ──────────────
|
|
// After reset, the member shows their global username. If that is
|
|
// also offensive (gambling/scam keywords), replace it with a random
|
|
// generated username so the user can't circumvent the filter by
|
|
// having an offensive global username.
|
|
const refreshedMember = await guild.members.fetch(userId);
|
|
const globalUsername = refreshedMember.user?.username ?? "";
|
|
if (await isGlobalUsernameOffensiveAI(globalUsername)) {
|
|
const randomName = generateRandomUsername();
|
|
await refreshedMember.setNickname(
|
|
randomName,
|
|
"[auto] global username juga melanggar, diganti random",
|
|
);
|
|
logger.info(
|
|
{ messageId, guildId, userId, globalUsername, randomName },
|
|
"Global username also offensive — replaced with random username",
|
|
);
|
|
}
|
|
|
|
recentNicknameResets.set(cooldownKey, Date.now());
|
|
logger.info(
|
|
{ messageId, guildId, userId },
|
|
"Offensive nickname reset to default username",
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
const errCode =
|
|
error instanceof Error && "code" in error
|
|
? (error as { code?: number | string }).code
|
|
: undefined;
|
|
logger.warn(
|
|
{
|
|
messageId,
|
|
guildId,
|
|
userId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
code: errCode,
|
|
},
|
|
"Nick reset failed",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── AI-Based Global Username Check ──────────────────────────────────
|
|
|
|
/**
|
|
* Ask the AI moderation LLM whether a global username is offensive
|
|
* (gambling, scam, spam, NSFW, etc.). Returns `true` if the LLM
|
|
* judges the username as violating server rules.
|
|
*
|
|
* Fail-open: if the LLM call fails or times out, returns `false`
|
|
* so the nickname reset still completes — we don't want a broken LLM
|
|
* to block enforcement.
|
|
*/
|
|
async function isGlobalUsernameOffensiveAI(username: string): Promise<boolean> {
|
|
try {
|
|
const completion = await llmChat({
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content:
|
|
'Kamu adalah moderator Discord. Tentukan apakah username berikut melanggar aturan server (judi, togel, scam, spam, NSFW, SARA, atau ofensif). Jawab HANYA dengan JSON: {"offensive": true} atau {"offensive": false}. Jangan penjelasan tambahan.',
|
|
},
|
|
{
|
|
role: "user",
|
|
content: `Username: "${username}"`,
|
|
},
|
|
],
|
|
max_tokens: 50,
|
|
temperature: 0,
|
|
stream: false,
|
|
timeout: 10_000,
|
|
});
|
|
|
|
const content = completion?.choices?.[0]?.message?.content?.trim() ?? "";
|
|
// Parse JSON response — handle both strict JSON and markdown-wrapped
|
|
const jsonMatch = content.match(
|
|
/\{[^}]*"offensive"\s*:\s*(true|false)[^}]*\}/,
|
|
);
|
|
if (jsonMatch) {
|
|
const parsed = JSON.parse(jsonMatch[0]) as { offensive: boolean };
|
|
return parsed.offensive === true;
|
|
}
|
|
// Fallback: if response contains "true" anywhere, treat as offensive
|
|
return content.toLowerCase().includes("true");
|
|
} catch (error) {
|
|
logger.warn(
|
|
{
|
|
username,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"AI username check failed — failing open (not offensive)",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a random fallback username: "User" + 5-digit number.
|
|
* Guaranteed unique per call (random suffix).
|
|
*/
|
|
function generateRandomUsername(): string {
|
|
const suffix = Math.floor(10000 + Math.random() * 90000);
|
|
return `User${suffix}`;
|
|
}
|
|
|
|
// ─── Error Handling Utilities ────────────────────────────────────────
|
|
|
|
function getErrorCode(error: unknown): number | string | undefined {
|
|
if (!error || typeof error !== "object") return undefined;
|
|
const maybeCode = (error as { code?: number | string }).code;
|
|
const maybeStatus = (error as { status?: number | string }).status;
|
|
return maybeCode ?? maybeStatus;
|
|
}
|
|
|
|
function isAlreadyDeletedError(error: unknown): boolean {
|
|
const code = getErrorCode(error);
|
|
// Discord REST error codes for "message not found":
|
|
// 10008 = Unknown Message, 10003 = Unknown Channel,
|
|
// 50001 = Missing Access (channel deleted/hidden), 404 = HTTP
|
|
if (
|
|
code === 10008 ||
|
|
code === 10003 ||
|
|
code === 50001 ||
|
|
code === 404 ||
|
|
code === "10008" ||
|
|
code === "10003" ||
|
|
code === "50001" ||
|
|
code === "404"
|
|
)
|
|
return true;
|
|
// Fallback: check the message text for the Discord "Unknown Message" string
|
|
const msg =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "string"
|
|
? error
|
|
: "";
|
|
return msg.includes("Unknown Message") || msg.includes("Unknown Channel");
|
|
}
|
|
|
|
function hasChannelMessagesApi(channel: unknown): channel is {
|
|
messages: {
|
|
fetch: (id: string) => Promise<{ delete: () => Promise<unknown> }>;
|
|
};
|
|
} {
|
|
return Boolean(
|
|
channel &&
|
|
typeof channel === "object" &&
|
|
"messages" in channel &&
|
|
(channel as { messages?: unknown }).messages &&
|
|
typeof (channel as { messages: { fetch?: unknown } }).messages.fetch ===
|
|
"function",
|
|
);
|
|
}
|
|
|
|
function hasPermissionApi(channel: unknown): channel is {
|
|
permissionsFor: (
|
|
member: unknown,
|
|
) => { has: (permission: string) => boolean } | null;
|
|
} {
|
|
return Boolean(
|
|
channel &&
|
|
typeof channel === "object" &&
|
|
"permissionsFor" in channel &&
|
|
typeof (channel as { permissionsFor?: unknown }).permissionsFor ===
|
|
"function",
|
|
);
|
|
}
|
|
|
|
// ─── Database Action Log ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract the member's server-specific display name (nickname) from a
|
|
* captured message's metadata. Falls back to the global username when no
|
|
* server nickname exists (member.displayName defaults to the guild nickname
|
|
* when set, otherwise the global username).
|
|
*/
|
|
function resolveServerNick(message: MessageRecord): string | null {
|
|
try {
|
|
const parsed = parseRichMessageMetadata(message.metadata);
|
|
return parsed?.member?.displayName ?? message.username ?? null;
|
|
} catch {
|
|
return message.username ?? null;
|
|
}
|
|
}
|
|
|
|
async function logAutoDeleteAttempt(
|
|
message: MessageRecord,
|
|
result: AutoDeleteResult,
|
|
): Promise<void> {
|
|
try {
|
|
await messageStore.createModerationAction({
|
|
message_id: message.id,
|
|
user_id: message.user_id,
|
|
guild_id: message.guild_id,
|
|
action_type: "delete_message",
|
|
reason: result.reason,
|
|
...verdictToActionFields(message),
|
|
username: message.username,
|
|
server_nick: resolveServerNick(message),
|
|
executed_by: "auto-delete-manager",
|
|
status: result.deleted
|
|
? "executed"
|
|
: result.reason === "dry_run"
|
|
? "executed"
|
|
: "failed",
|
|
error: result.reason === "error" ? result.reason : null,
|
|
executed_at:
|
|
result.deleted || result.reason === "dry_run" ? Date.now() : null,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to persist auto-delete action log",
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Main Orchestrator ───────────────────────────────────────────────
|
|
|
|
export async function attemptAutoDeleteFlaggedMessage(
|
|
client: Client | undefined,
|
|
message: MessageRecord,
|
|
): Promise<AutoDeleteResult> {
|
|
logger.debug({ messageId: message.id }, "Processing message for auto-delete");
|
|
|
|
// ── Config gate ──────────────────────────────────────────────────
|
|
|
|
if (!config.AUTO_DELETE_FLAGGED_ENABLED) {
|
|
logger.debug({ messageId: message.id }, "Auto-delete disabled by config");
|
|
return { deleted: false, skipped: true, reason: "disabled" };
|
|
}
|
|
|
|
// ── Nickname-only violation: reset nick, DO NOT delete ─────────────
|
|
// When the only flag is offensive_username (message content is clean),
|
|
// the problem is the server nickname, not the message. Enforcement is
|
|
// removing the nickname back to the default username — the message stays.
|
|
if (isNicknameOnlyViolation(message)) {
|
|
if (
|
|
!config.AUTO_DELETE_FLAGGED_DRY_RUN &&
|
|
config.AUTO_NICKNAME_RESET_ENABLED !== false
|
|
) {
|
|
const inCooldown = isNicknameResetInCooldown(
|
|
message.guild_id,
|
|
message.user_id,
|
|
);
|
|
if (!inCooldown) {
|
|
const resetOk = await resetOffensiveNickname(
|
|
client,
|
|
message.guild_id,
|
|
message.user_id,
|
|
message.id,
|
|
);
|
|
try {
|
|
await messageStore.createModerationAction({
|
|
message_id: message.id,
|
|
user_id: message.user_id,
|
|
guild_id: message.guild_id,
|
|
action_type: "reset_nickname",
|
|
reason:
|
|
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
|
...verdictToActionFields(message),
|
|
username: message.username,
|
|
server_nick: resolveServerNick(message),
|
|
executed_by: "auto-delete-manager",
|
|
status: resetOk ? "executed" : "failed",
|
|
error: resetOk ? null : "nickname_reset_failed",
|
|
executed_at: resetOk ? Date.now() : null,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to persist nickname reset action log",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
logger.info(
|
|
{ messageId: message.id, userId: message.user_id },
|
|
"Nickname-only violation: message kept, nickname reset attempted",
|
|
);
|
|
return { deleted: false, skipped: true, reason: "nickname_only_violation" };
|
|
}
|
|
|
|
// ── Status gate ──────────────────────────────────────────────────
|
|
|
|
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
|
logger.debug(
|
|
{ messageId: message.id, status: message.ai_status },
|
|
"Auto-delete skipped: message not flagged or warned",
|
|
);
|
|
const result: AutoDeleteResult = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "not_flagged_or_warn",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
return result;
|
|
}
|
|
|
|
// ── Eligibility gate ─────────────────────────────────────────────
|
|
|
|
if (!isEligibleForAutoDelete(message)) {
|
|
logger.debug(
|
|
{ messageId: message.id },
|
|
"Auto-delete skipped: not eligible (confidence/severity/action/category filter)",
|
|
);
|
|
const result: AutoDeleteResult = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "not_eligible",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
return result;
|
|
}
|
|
|
|
// ── Client check ─────────────────────────────────────────────────
|
|
|
|
if (!client?.user?.id) {
|
|
logger.warn(
|
|
{ messageId: message.id },
|
|
"Auto-delete skipped: client user missing",
|
|
);
|
|
return { deleted: false, skipped: true, reason: "client_user_missing" };
|
|
}
|
|
|
|
// ── Deletion flow ────────────────────────────────────────────────
|
|
|
|
try {
|
|
const guild = client.guilds.cache.get(message.guild_id);
|
|
if (!guild) {
|
|
logger.warn(
|
|
{ messageId: message.id, guildId: message.guild_id },
|
|
"Auto-delete skipped: guild not found",
|
|
);
|
|
return { deleted: false, skipped: true, reason: "guild_not_found" };
|
|
}
|
|
|
|
const channelId = message.thread_id ?? message.channel_id;
|
|
const channel = guild.channels.cache.get(channelId);
|
|
if (!channel) {
|
|
logger.warn(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-delete skipped: channel not found",
|
|
);
|
|
return { deleted: false, skipped: true, reason: "channel_not_found" };
|
|
}
|
|
|
|
if (!hasPermissionApi(channel) || !hasChannelMessagesApi(channel)) {
|
|
logger.warn(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-delete skipped: channel cannot delete messages",
|
|
);
|
|
return { deleted: false, skipped: true, reason: "unsupported_channel" };
|
|
}
|
|
|
|
const selfMember = await guild.members.fetch(client.user.id);
|
|
const permissions = channel.permissionsFor(selfMember);
|
|
const canManageMessages =
|
|
permissions?.has("MANAGE_MESSAGES" as PermissionString) ?? false;
|
|
|
|
if (!canManageMessages) {
|
|
logger.warn(
|
|
{ messageId: message.id, channelId, userId: client.user.id },
|
|
"Auto-delete skipped: current user lacks Manage Messages",
|
|
);
|
|
return {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "missing_manage_messages",
|
|
};
|
|
}
|
|
|
|
// ── Dry run mode ───────────────────────────────────────────────
|
|
|
|
if (config.AUTO_DELETE_FLAGGED_DRY_RUN) {
|
|
const result: AutoDeleteResult = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "dry_run",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-delete dry-run: would delete flagged message",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// ── Perform API deletion ───────────────────────────────────────
|
|
|
|
const discordMessage = await channel.messages.fetch(message.id);
|
|
await discordMessage.delete();
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Message deleted from Discord",
|
|
);
|
|
|
|
// ── Notify user via DM ─────────────────────────────────────────
|
|
|
|
await sendDeletionNotification(client, message, guild.name);
|
|
|
|
// ── Log to moderation channel ──────────────────────────────────
|
|
|
|
await logDeletionToChannel(guild, message, channelId);
|
|
|
|
// ── Success ────────────────────────────────────────────────────
|
|
|
|
const result: AutoDeleteResult = {
|
|
deleted: true,
|
|
skipped: false,
|
|
reason: "deleted",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-deleted AI-flagged message",
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
// ── Already deleted ──────────────────────────────────────────────
|
|
|
|
if (isAlreadyDeletedError(error)) {
|
|
const result: AutoDeleteResult = {
|
|
deleted: true,
|
|
skipped: false,
|
|
reason: "already_deleted",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, code: getErrorCode(error) },
|
|
"Auto-delete skipped: message already deleted",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// ── Unexpected error ─────────────────────────────────────────────
|
|
|
|
const result: AutoDeleteResult = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "error",
|
|
};
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.error(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
code: getErrorCode(error),
|
|
},
|
|
"Auto-delete failed",
|
|
);
|
|
return result;
|
|
}
|
|
}
|