diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 713377d..e52ef39 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -62,7 +62,7 @@ export async function initializeApp() { client.on("ready", async () => { logger.info({ user: client.user?.tag }, "Bot logged in"); registerMessageCapture(client); - startPendingAIAnalysisWorker(); + startPendingAIAnalysisWorker(client); syncBacklogMessages(client).catch((error) => { logger.warn({ error }, "Backlog sync failed"); }); diff --git a/src/config.ts b/src/config.ts index 7fcf0b5..202f165 100644 --- a/src/config.ts +++ b/src/config.ts @@ -114,6 +114,17 @@ const configSchema = z .int() .positive() .default(10), + AUTO_DELETE_FLAGGED_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), + AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0), + AUTO_DELETE_FLAGGED_DRY_RUN: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), DATABASE_URL: z.string().optional(), POSTGRES_HOST: z.string().default("localhost"), diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index 9d330c2..0eebc41 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import type { Client } from "discord.js-selfbot-v13"; import { AbortError } from "p-retry"; import { Piscina } from "piscina"; import { config } from "../config.js"; import { createChildLogger } from "../logger.js"; import { retryWithBackoff } from "../retry.js"; +import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; import { buildConversationContext, estimateTokens, @@ -37,6 +39,27 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined { return (globalThis as ModerationGlobal).moderationBroadcaster; } +function scheduleAutoDelete(row: MessageRecord): void { + if (row.ai_status !== "flagged") return; + const run = () => { + attemptAutoDeleteFlaggedMessage(moderationClient, row).catch((error) => { + logger.error( + { + messageId: row.id, + error: error instanceof Error ? error.message : String(error), + }, + "Unexpected auto-delete error", + ); + }); + }; + + if (config.AUTO_DELETE_FLAGGED_DELAY_MS > 0) { + setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS); + return; + } + setImmediate(run); +} + // --------------------------------------------------------------------------- // Batch pipeline state // --------------------------------------------------------------------------- @@ -50,6 +73,7 @@ const conversationErrorCooldown = new Map(); let activeRequests = 0; let lastError: string | null = null; +let moderationClient: Client | undefined; // Batch circuit breaker let consecutiveErrors = 0; @@ -291,6 +315,7 @@ async function processIndividualFallback( const rows = await updateMessagesAIAnalysisBulk(updates); for (const row of rows) { getModerationBroadcaster()?.messageAnalyzed(row); + scheduleAutoDelete(row); } // Reset individual CB on success. @@ -472,6 +497,7 @@ async function processBatch( for (const row of result.rows) { getModerationBroadcaster()?.messageAnalyzed(row); + scheduleAutoDelete(row); } if (!result.ok) { @@ -741,7 +767,8 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus { * state (not just `pending`), and skips conversations that already have * individual fallback work in progress to avoid DB last-write-wins races. */ -export function startPendingAIAnalysisWorker(): void { +export function startPendingAIAnalysisWorker(client?: Client): void { + moderationClient = client; if (!config.AI_ANALYSIS_ENABLED) return; setInterval(() => { diff --git a/src/moderation/autoDeleteManager.ts b/src/moderation/autoDeleteManager.ts new file mode 100644 index 0000000..598c552 --- /dev/null +++ b/src/moderation/autoDeleteManager.ts @@ -0,0 +1,143 @@ +import type { Client, PermissionString } from "discord.js-selfbot-v13"; +import { config } from "../config.js"; +import { createChildLogger } from "../logger.js"; +import type { MessageRecord } from "./types.js"; + +const logger = createChildLogger("auto-delete-manager"); + +export interface AutoDeleteResult { + deleted: boolean; + skipped: boolean; + reason: string; +} + +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); + return code === 10008 || code === 404 || code === "10008" || code === "404"; +} + +function hasChannelMessagesApi( + channel: unknown, +): channel is { messages: { fetch: (id: string) => Promise<{ delete: () => Promise }> } } { + 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", + ); +} + +export async function attemptAutoDeleteFlaggedMessage( + client: Client | undefined, + message: MessageRecord, +): Promise { + if (!config.AUTO_DELETE_FLAGGED_ENABLED) { + return { deleted: false, skipped: true, reason: "disabled" }; + } + + if (message.ai_status !== "flagged") { + return { deleted: false, skipped: true, reason: "not_flagged" }; + } + + if (!client?.user?.id) { + logger.warn({ messageId: message.id }, "Auto-delete skipped: client user missing"); + return { deleted: false, skipped: true, reason: "client_user_missing" }; + } + + 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" }; + } + + if (config.AUTO_DELETE_FLAGGED_DRY_RUN) { + logger.info( + { messageId: message.id, channelId }, + "Auto-delete dry-run: would delete flagged message", + ); + return { deleted: false, skipped: true, reason: "dry_run" }; + } + + const discordMessage = await channel.messages.fetch(message.id); + await discordMessage.delete(); + + logger.info( + { messageId: message.id, channelId }, + "Auto-deleted AI-flagged message", + ); + return { deleted: true, skipped: false, reason: "deleted" }; + } catch (error) { + if (isAlreadyDeletedError(error)) { + logger.info( + { messageId: message.id, code: getErrorCode(error) }, + "Auto-delete skipped: message already deleted", + ); + return { deleted: true, skipped: false, reason: "already_deleted" }; + } + + logger.error( + { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + code: getErrorCode(error), + }, + "Auto-delete failed", + ); + return { deleted: false, skipped: true, reason: "error" }; + } +} diff --git a/tests/moderation/autoDeleteManager.test.ts b/tests/moderation/autoDeleteManager.test.ts new file mode 100644 index 0000000..8119747 --- /dev/null +++ b/tests/moderation/autoDeleteManager.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { attemptAutoDeleteFlaggedMessage } from "../../src/moderation/autoDeleteManager"; +import type { MessageRecord } from "../../src/moderation/types"; + +vi.mock("../../src/config", () => ({ + config: { + AUTO_DELETE_FLAGGED_ENABLED: true, + AUTO_DELETE_FLAGGED_DRY_RUN: false, + AUTO_DELETE_FLAGGED_DELAY_MS: 0, + }, +})); + +vi.mock("../../src/logger", () => ({ + createChildLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +function createMessage(overrides: Partial = {}): MessageRecord { + return { + id: "m1", + guild_id: "g1", + channel_id: "c1", + thread_id: null, + user_id: "u1", + username: "user", + avatar_url: null, + content: "bad", + edited_content: null, + created_at: Date.now(), + edited_at: null, + deleted_at: null, + type: "text", + metadata: null, + ai_status: "flagged", + ...overrides, + }; +} + +function createClient(options: { + canManageMessages?: boolean; + fetchError?: unknown; + deleteError?: unknown; +} = {}) { + const deleteMock = vi.fn(async () => { + if (options.deleteError) throw options.deleteError; + }); + const fetchMessageMock = vi.fn(async () => { + if (options.fetchError) throw options.fetchError; + return { delete: deleteMock }; + }); + const permissionsForMock = vi.fn(() => ({ + has: vi.fn(() => options.canManageMessages ?? true), + })); + const channel = { + permissionsFor: permissionsForMock, + messages: { fetch: fetchMessageMock }, + }; + const guild = { + channels: { cache: new Map([["c1", channel]]) }, + members: { fetch: vi.fn(async () => ({ id: "self" })) }, + }; + const client = { + user: { id: "self" }, + guilds: { cache: new Map([["g1", guild]]) }, + }; + + return { client, guild, channel, fetchMessageMock, deleteMock }; +} + +describe("attemptAutoDeleteFlaggedMessage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips non-flagged messages", async () => { + const { client, deleteMock } = createClient(); + const result = await attemptAutoDeleteFlaggedMessage( + client as any, + createMessage({ ai_status: "clean" }), + ); + + expect(result.reason).toBe("not_flagged"); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + it("skips when current user lacks Manage Messages", async () => { + const { client, deleteMock } = createClient({ canManageMessages: false }); + const result = await attemptAutoDeleteFlaggedMessage( + client as any, + createMessage(), + ); + + expect(result.reason).toBe("missing_manage_messages"); + expect(deleteMock).not.toHaveBeenCalled(); + }); + + it("deletes when message is flagged and permission exists", async () => { + const { client, fetchMessageMock, deleteMock } = createClient({ + canManageMessages: true, + }); + const result = await attemptAutoDeleteFlaggedMessage( + client as any, + createMessage(), + ); + + expect(result).toEqual({ deleted: true, skipped: false, reason: "deleted" }); + expect(fetchMessageMock).toHaveBeenCalledWith("m1"); + expect(deleteMock).toHaveBeenCalledTimes(1); + }); + + it("treats unknown message as already deleted", async () => { + const { client } = createClient({ fetchError: { code: 10008 } }); + const result = await attemptAutoDeleteFlaggedMessage( + client as any, + createMessage(), + ); + + expect(result).toEqual({ + deleted: true, + skipped: false, + reason: "already_deleted", + }); + }); +});