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
This commit is contained in:
asepharyana
2026-08-10 11:48:27 +07:00
parent ecbb538c9f
commit 185d81f0e0
6 changed files with 260 additions and 2 deletions
@@ -47,6 +47,40 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
return "none"; 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. * Check whether a message qualifies for auto-deletion.
* Uses the structured `analysisResult` fields when provided, falling back * Uses the structured `analysisResult` fields when provided, falling back
@@ -1,9 +1,13 @@
import type { Client, PermissionString } from "discord.js-selfbot-v13"; import type { Client, PermissionString } from "discord.js-selfbot-v13";
import { LRUCache } from "lru-cache";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { messageStore } from "../message-capture/messageStore.js"; import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.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 { logDeletionToChannel } from "./autoDeleteLogger.js";
import { sendDeletionNotification } from "./autoDeleteNotify.js"; import { sendDeletionNotification } from "./autoDeleteNotify.js";
@@ -15,6 +19,83 @@ export interface AutoDeleteResult {
reason: string; 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);
// 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 ──────────────────────────────────────── // ─── Error Handling Utilities ────────────────────────────────────────
function getErrorCode(error: unknown): number | string | undefined { function getErrorCode(error: unknown): number | string | undefined {
@@ -107,6 +188,57 @@ export async function attemptAutoDeleteFlaggedMessage(
return { deleted: false, skipped: true, reason: "disabled" }; 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 ────────────────────────────────────────────────── // ── Status gate ──────────────────────────────────────────────────
if (message.ai_status !== "flagged" && message.ai_status !== "warn") { if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
@@ -253,6 +253,19 @@ export const configSchema = z
.default(false), .default(false),
AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""), 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 ───────────────────────────────────────────────────────
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
@@ -615,6 +615,7 @@ export const pgModerationActionsTable = pgTable(
"warn_user", "warn_user",
"kick_user", "kick_user",
"ban_user", "ban_user",
"reset_nickname",
], ],
}).notNull(), }).notNull(),
reason: pgText("reason"), reason: pgText("reason"),
@@ -198,7 +198,8 @@ export type ModerationActionType =
| "mute_user" | "mute_user"
| "warn_user" | "warn_user"
| "kick_user" | "kick_user"
| "ban_user"; | "ban_user"
| "reset_nickname";
export interface ModerationAction { export interface ModerationAction {
id: string; id: string;
@@ -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);
});
});