Rename server_name (guild name) to server_nick and populate it from the member's server-specific display name (metadata.member.displayName) at write time. This is what the moderation dashboard should show as TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global username '.nichiyobi'. Backfilled 210 existing actions from messages metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas Minoritas', etc). Frontend TARGET shows server nick with global username as secondary context.
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/config.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;
|
|
}
|
|
}
|