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:
@@ -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
|
||||
|
||||
@@ -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<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 ────────────────────────────────────────
|
||||
|
||||
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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -615,6 +615,7 @@ export const pgModerationActionsTable = pgTable(
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
"reset_nickname",
|
||||
],
|
||||
}).notNull(),
|
||||
reason: pgText("reason"),
|
||||
|
||||
@@ -198,7 +198,8 @@ export type ModerationActionType =
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user";
|
||||
| "ban_user"
|
||||
| "reset_nickname";
|
||||
|
||||
export interface ModerationAction {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user