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";
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user