2026-06-01 21:44:29 +07:00
import type { Client , PermissionString } from "discord.js-selfbot-v13" ;
2026-08-10 11:48:27 +07:00
import { LRUCache } from "lru-cache" ;
2026-08-01 22:09:02 +07:00
import { createChildLogger } from "@/shared/logger/index" ;
2026-06-01 21:44:29 +07:00
import { config } from "../../shared/config/config.js" ;
2026-08-27 13:50:37 +07:00
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js" ;
2026-07-27 21:54:31 +07:00
import { messageStore } from "../message-capture/messageStore.js" ;
2026-06-01 21:44:29 +07:00
import type { MessageRecord } from "../message-capture/types.js" ;
2026-08-10 11:48:27 +07:00
import {
isEligibleForAutoDelete ,
isNicknameOnlyViolation ,
} from "./autoDeleteEligibility.js" ;
2026-06-09 17:34:18 +07:00
import { logDeletionToChannel } from "./autoDeleteLogger.js" ;
import { sendDeletionNotification } from "./autoDeleteNotify.js" ;
2026-08-27 13:20:20 +07:00
import { llmChat } from "./llmClient.js" ;
2026-08-18 15:11:01 +07:00
import { verdictToActionFields } from "./verdictToActionFields.js" ;
2026-06-09 13:07:01 +07:00
2026-06-01 21:44:29 +07:00
const logger = createChildLogger ( "auto-delete-manager" );
export interface AutoDeleteResult {
deleted : boolean ;
skipped : boolean ;
reason : string ;
}
2026-08-10 11:48:27 +07:00
// 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 );
2026-08-17 20:52:09 +07:00
// 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 ;
}
2026-08-10 11:48:27 +07:00
// setNickname(null) = remove nickname → Discord shows global username
await member . setNickname ( null , "[auto] nickname melanggar aturan server" );
2026-08-27 12:46:33 +07:00
// ── 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 ?? "" ;
2026-08-27 13:20:20 +07:00
if ( await isGlobalUsernameOffensiveAI ( globalUsername )) {
2026-08-27 12:46:33 +07:00
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" ,
);
}
2026-08-10 11:48:27 +07:00
recentNicknameResets . set ( cooldownKey , Date . now ());
logger . info (
{ messageId , guildId , userId },
"Offensive nickname reset to default username" ,
);
return true ;
} catch ( error ) {
2026-08-17 20:52:09 +07:00
const errCode =
error instanceof Error && "code" in error
? ( error as { code? : number | string }). code
: undefined ;
2026-08-10 11:48:27 +07:00
logger . warn (
{
messageId ,
guildId ,
userId ,
error : error instanceof Error ? error.message : String ( error ),
2026-08-17 20:52:09 +07:00
code : errCode ,
2026-08-10 11:48:27 +07:00
},
"Nick reset failed" ,
);
return false ;
}
}
2026-08-27 13:20:20 +07:00
// ─── AI-Based Global Username Check ──────────────────────────────────
2026-08-27 12:46:33 +07:00
/**
2026-08-27 13:20:20 +07:00
* 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.
2026-08-27 12:46:33 +07:00
*/
2026-08-27 13:20:20 +07:00
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 ;
}
2026-08-27 12:46:33 +07:00
}
/**
* 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 } ` ;
}
2026-06-09 17:34:18 +07:00
// ─── Error Handling Utilities ────────────────────────────────────────
2026-06-01 21:44:29 +07:00
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 );
2026-08-26 17:31:37 +07:00
// 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 =
2026-08-26 18:12:02 +07:00
error instanceof Error
? error.message
: typeof error === "string"
? error
: "" ;
2026-08-26 17:31:37 +07:00
return msg . includes ( "Unknown Message" ) || msg . includes ( "Unknown Channel" );
2026-06-01 21:44:29 +07:00
}
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" ,
);
}
2026-06-09 17:34:18 +07:00
// ─── Database Action Log ─────────────────────────────────────────────
2026-08-27 13:50:37 +07:00
/**
* 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 ;
}
}
2026-06-09 17:34:18 +07:00
async function logAutoDeleteAttempt (
message : MessageRecord ,
result : AutoDeleteResult ,
) : Promise < void > {
try {
2026-07-27 21:54:31 +07:00
await messageStore . createModerationAction ({
2026-06-09 17:34:18 +07:00
message_id : message.id ,
user_id : message.user_id ,
guild_id : message.guild_id ,
action_type : "delete_message" ,
reason : result.reason ,
2026-08-18 15:11:01 +07:00
... verdictToActionFields ( message ),
2026-08-27 12:15:06 +07:00
username : message.username ,
2026-08-27 13:50:37 +07:00
server_nick : resolveServerNick ( message ),
2026-06-09 17:34:18 +07:00
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 ───────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
export async function attemptAutoDeleteFlaggedMessage (
client : Client | undefined ,
message : MessageRecord ,
) : Promise < AutoDeleteResult > {
2026-06-09 17:34:18 +07:00
logger . debug ({ messageId : message.id }, "Processing message for auto-delete" );
// ── Config gate ──────────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
if ( ! config . AUTO_DELETE_FLAGGED_ENABLED ) {
2026-06-09 17:34:18 +07:00
logger . debug ({ messageId : message.id }, "Auto-delete disabled by config" );
2026-06-01 21:44:29 +07:00
return { deleted : false , skipped : true , reason : "disabled" };
}
2026-08-10 11:48:27 +07:00
// ── 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" ,
2026-08-18 15:11:01 +07:00
... verdictToActionFields ( message ),
2026-08-27 12:15:06 +07:00
username : message.username ,
2026-08-27 13:50:37 +07:00
server_nick : resolveServerNick ( message ),
2026-08-10 11:48:27 +07:00
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" };
}
2026-06-09 17:34:18 +07:00
// ── Status gate ──────────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
if ( message . ai_status !== "flagged" && message . ai_status !== "warn" ) {
2026-06-04 13:13:55 +07:00
logger . debug (
{ messageId : message.id , status : message.ai_status },
"Auto-delete skipped: message not flagged or warned" ,
);
2026-06-09 17:34:18 +07:00
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : false ,
skipped : true ,
reason : "not_flagged_or_warn" ,
2026-06-09 17:34:18 +07:00
};
2026-06-01 21:44:29 +07:00
await logAutoDeleteAttempt ( message , result );
return result ;
}
2026-06-09 17:34:18 +07:00
// ── Eligibility gate ─────────────────────────────────────────────
if ( ! isEligibleForAutoDelete ( message )) {
2026-06-04 13:13:55 +07:00
logger . debug (
{ messageId : message.id },
"Auto-delete skipped: not eligible (confidence/severity/action/category filter)" ,
);
2026-06-09 17:34:18 +07:00
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : false ,
skipped : true ,
reason : "not_eligible" ,
2026-06-09 17:34:18 +07:00
};
2026-06-01 21:44:29 +07:00
await logAutoDeleteAttempt ( message , result );
return result ;
}
2026-06-09 17:34:18 +07:00
// ── Client check ─────────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
if ( ! client ? . user ? . id ) {
logger . warn (
{ messageId : message.id },
"Auto-delete skipped: client user missing" ,
);
return { deleted : false , skipped : true , reason : "client_user_missing" };
}
2026-06-09 17:34:18 +07:00
// ── Deletion flow ────────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
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" ,
};
}
2026-06-09 17:34:18 +07:00
// ── Dry run mode ───────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
if ( config . AUTO_DELETE_FLAGGED_DRY_RUN ) {
2026-06-09 17:34:18 +07:00
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : false ,
skipped : true ,
reason : "dry_run" ,
2026-06-09 17:34:18 +07:00
};
2026-08-27 13:50:37 +07:00
await logAutoDeleteAttempt ( message , result );
2026-06-01 21:44:29 +07:00
logger . info (
{ messageId : message.id , channelId },
"Auto-delete dry-run: would delete flagged message" ,
);
return result ;
}
2026-06-09 17:34:18 +07:00
// ── Perform API deletion ───────────────────────────────────────
2026-06-01 21:44:29 +07:00
const discordMessage = await channel . messages . fetch ( message . id );
await discordMessage . delete ();
2026-06-09 17:34:18 +07:00
logger . info (
{ messageId : message.id , channelId },
"Message deleted from Discord" ,
);
2026-06-01 21:44:29 +07:00
2026-06-09 17:34:18 +07:00
// ── Notify user via DM ─────────────────────────────────────────
2026-06-04 17:34:54 +07:00
2026-06-09 17:34:18 +07:00
await sendDeletionNotification ( client , message , guild . name );
2026-06-04 17:34:54 +07:00
2026-06-09 17:34:18 +07:00
// ── Log to moderation channel ──────────────────────────────────
await logDeletionToChannel ( guild , message , channelId );
// ── Success ────────────────────────────────────────────────────
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : true ,
skipped : false ,
reason : "deleted" ,
2026-06-09 17:34:18 +07:00
};
2026-08-27 13:50:37 +07:00
await logAutoDeleteAttempt ( message , result );
2026-06-01 21:44:29 +07:00
logger . info (
{ messageId : message.id , channelId },
"Auto-deleted AI-flagged message" ,
);
return result ;
} catch ( error ) {
2026-06-09 17:34:18 +07:00
// ── Already deleted ──────────────────────────────────────────────
2026-06-01 21:44:29 +07:00
if ( isAlreadyDeletedError ( error )) {
2026-06-09 17:34:18 +07:00
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : true ,
skipped : false ,
reason : "already_deleted" ,
2026-06-09 17:34:18 +07:00
};
2026-06-01 21:44:29 +07:00
await logAutoDeleteAttempt ( message , result );
logger . info (
{ messageId : message.id , code : getErrorCode ( error ) },
"Auto-delete skipped: message already deleted" ,
);
return result ;
}
2026-06-09 17:34:18 +07:00
// ── Unexpected error ─────────────────────────────────────────────
const result : AutoDeleteResult = {
2026-06-01 21:44:29 +07:00
deleted : false ,
skipped : true ,
reason : "error" ,
2026-06-09 17:34:18 +07:00
};
2026-06-01 21:44:29 +07:00
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 ;
}
}