From 185d81f0e0224e8200934183d302df770ab8dfdd Mon Sep 17 00:00:00 2001 From: asepharyana Date: Mon, 10 Aug 2026 11:48:27 +0700 Subject: [PATCH] feat(ai-moderation): reset offensive nickname instead of deleting message When the ONLY violation is offensive_username (message content clean): - Message is NOT deleted (nickname-only violation bypasses auto-delete) - Member's server nickname is reset to default username via setNickname(null) (Discord shows the global username again) - Action 'reset_nickname' logged to moderation_actions; cooldown 10min per guild:user (LRU) so repeated messages by same member don't hammer the Discord PATCH - Config: AUTO_NICKNAME_RESET_ENABLED / AUTO_NICKNAME_RESET_COOLDOWN_MS --- .../ai-moderation/autoDeleteEligibility.ts | 34 +++++ .../ai-moderation/autoDeleteManager.ts | 134 +++++++++++++++++- .../src/shared/config/index.ts | 13 ++ .../src/shared/database/schema.ts | 1 + .../src/shared/moderation-types.ts | 3 +- .../tests/nicknameEnforcement.test.ts | 77 ++++++++++ 6 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 services/discord-gateway/tests/nicknameEnforcement.test.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/autoDeleteEligibility.ts b/services/discord-gateway/src/modules/ai-moderation/autoDeleteEligibility.ts index 5e048d9..18f7739 100644 --- a/services/discord-gateway/src/modules/ai-moderation/autoDeleteEligibility.ts +++ b/services/discord-gateway/src/modules/ai-moderation/autoDeleteEligibility.ts @@ -47,6 +47,40 @@ export function deriveRecommendedAction(msg: MessageRecord): string { return "none"; } +/** Parse the flag list from a structured result or the stored column. */ +export function parseModerationFlags( + message: MessageRecord, + analysisResult?: AnalysisResult, +): string[] { + const flags = analysisResult?.flags ?? null; + if (flags && flags.length > 0) return flags; + const stored = message.ai_moderation_flags; + if (!stored) return []; + try { + const parsed = JSON.parse(stored) as unknown; + return Array.isArray(parsed) + ? parsed.filter((f): f is string => typeof f === "string") + : []; + } catch { + return []; + } +} + +/** + * True when the ONLY violation is the member's server nickname — the message + * content itself is clean. Such messages must NOT be auto-deleted; the + * correct enforcement is resetting the nickname to the default username. + * Any other flag (sara, harassment, vulgar_language, ...) keeps the normal + * delete path. + */ +export function isNicknameOnlyViolation( + message: MessageRecord, + analysisResult?: AnalysisResult, +): boolean { + const flags = parseModerationFlags(message, analysisResult); + return flags.length > 0 && flags.every((f) => f === "offensive_username"); +} + /** * Check whether a message qualifies for auto-deletion. * Uses the structured `analysisResult` fields when provided, falling back diff --git a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts index a4abfbe..a803cb0 100644 --- a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts +++ b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts @@ -1,9 +1,13 @@ 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 { messageStore } from "../message-capture/messageStore.js"; import type { MessageRecord } from "../message-capture/types.js"; -import { isEligibleForAutoDelete } from "./autoDeleteEligibility.js"; +import { + isEligibleForAutoDelete, + isNicknameOnlyViolation, +} from "./autoDeleteEligibility.js"; import { logDeletionToChannel } from "./autoDeleteLogger.js"; import { sendDeletionNotification } from "./autoDeleteNotify.js"; @@ -15,6 +19,83 @@ export interface AutoDeleteResult { 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({ + 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 { + 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); + // setNickname(null) = remove nickname → Discord shows global username + await member.setNickname(null, "[auto] nickname melanggar aturan server"); + recentNicknameResets.set(cooldownKey, Date.now()); + logger.info( + { messageId, guildId, userId }, + "Offensive nickname reset to default username", + ); + return true; + } catch (error) { + logger.warn( + { + messageId, + guildId, + userId, + error: error instanceof Error ? error.message : String(error), + }, + "Nick reset failed", + ); + return false; + } +} + // ─── Error Handling Utilities ──────────────────────────────────────── function getErrorCode(error: unknown): number | string | undefined { @@ -107,6 +188,57 @@ export async function attemptAutoDeleteFlaggedMessage( 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", + 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") { diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts index a13b557..4496818 100644 --- a/services/discord-gateway/src/shared/config/index.ts +++ b/services/discord-gateway/src/shared/config/index.ts @@ -253,6 +253,19 @@ export const configSchema = z .default(false), AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""), + // ── Nickname Reset (offensive_username enforcement) ──────────────── + // When the only violation is the member's server nickname, reset the + // nickname to the default username instead of deleting the message. + AUTO_NICKNAME_RESET_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), + AUTO_NICKNAME_RESET_COOLDOWN_MS: z.coerce + .number() + .positive() + .default(10 * 60 * 1000), + // ── Retention ─────────────────────────────────────────────────────── RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 477034a..e372464 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -615,6 +615,7 @@ export const pgModerationActionsTable = pgTable( "warn_user", "kick_user", "ban_user", + "reset_nickname", ], }).notNull(), reason: pgText("reason"), diff --git a/services/discord-gateway/src/shared/moderation-types.ts b/services/discord-gateway/src/shared/moderation-types.ts index ab33481..113603d 100644 --- a/services/discord-gateway/src/shared/moderation-types.ts +++ b/services/discord-gateway/src/shared/moderation-types.ts @@ -198,7 +198,8 @@ export type ModerationActionType = | "mute_user" | "warn_user" | "kick_user" - | "ban_user"; + | "ban_user" + | "reset_nickname"; export interface ModerationAction { id: string; diff --git a/services/discord-gateway/tests/nicknameEnforcement.test.ts b/services/discord-gateway/tests/nicknameEnforcement.test.ts new file mode 100644 index 0000000..630ce6c --- /dev/null +++ b/services/discord-gateway/tests/nicknameEnforcement.test.ts @@ -0,0 +1,77 @@ +// ═══════════════════════════════════════════════════════════════════════════ +// Nickname-only enforcement — offensive username flag handling (pure, no DB) +// ═══════════════════════════════════════════════════════════════════════════ +import { describe, expect, it } from "vitest"; +import { + isNicknameOnlyViolation, + parseModerationFlags, +} from "../src/modules/ai-moderation/autoDeleteEligibility.js"; +import type { + AnalysisResult, + MessageRecord, +} from "../src/modules/message-capture/types.js"; + +function msg(flagsJson: string | null): MessageRecord { + return { + id: "m1", + guild_id: "g1", + channel_id: "c1", + thread_id: null, + user_id: "u1", + username: "user1", + avatar_url: null, + content: "halo semua", + edited_content: null, + created_at: Date.now(), + edited_at: null, + deleted_at: null, + type: "text", + is_reply: null, + is_forward: null, + is_crosspost: null, + reference_message_id: null, + reference_channel_id: null, + reference_guild_id: null, + metadata: null, + ai_moderation_flags: flagsJson, + }; +} + +describe("parseModerationFlags", () => { + it("parses JSON array from stored column", () => { + expect(parseModerationFlags(msg('["offensive_username","sara"]'))).toEqual([ + "offensive_username", + "sara", + ]); + }); + + it("returns [] for null / malformed values", () => { + expect(parseModerationFlags(msg(null))).toEqual([]); + expect(parseModerationFlags(msg("not-json"))).toEqual([]); + }); + + it("prefers structured analysisResult flags", () => { + const result = { flags: ["vulgar_language"] } as AnalysisResult; + expect(parseModerationFlags(msg('["old_flag"]'), result)).toEqual([ + "vulgar_language", + ]); + }); +}); + +describe("isNicknameOnlyViolation", () => { + it("true when the ONLY flag is offensive_username", () => { + expect(isNicknameOnlyViolation(msg('["offensive_username"]'))).toBe(true); + }); + + it("false when other flags ride along (message itself violated)", () => { + expect(isNicknameOnlyViolation(msg('["offensive_username","sara"]'))).toBe( + false, + ); + expect(isNicknameOnlyViolation(msg('["harassment"]'))).toBe(false); + }); + + it("false when no flags at all", () => { + expect(isNicknameOnlyViolation(msg(null))).toBe(false); + expect(isNicknameOnlyViolation(msg("[]"))).toBe(false); + }); +});