- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
|
import { config } from "../../shared/config/config.js";
|
|
import { createChildLogger } from "../../shared/logger/logger.js";
|
|
import { createModerationAction } from "../message-capture/messageStore.js";
|
|
import type { MessageRecord } from "../message-capture/types.js";
|
|
|
|
const logger = createChildLogger("auto-delete-manager");
|
|
|
|
const parseStringList = (value?: string | null): string[] => {
|
|
if (!value) return [];
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
} catch {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
};
|
|
|
|
/** Derive severity from legacy messages that lack structured AI fields. */
|
|
function deriveSeverity(msg: MessageRecord): string {
|
|
if (msg.ai_severity) return msg.ai_severity;
|
|
const score = msg.ai_confidence ?? msg.ai_moderation_score ?? 0;
|
|
if (msg.ai_status === "flagged")
|
|
return score >= 0.9 ? "critical" : score >= 0.7 ? "high" : "medium";
|
|
if (msg.ai_status === "warn") return score >= 0.6 ? "medium" : "low";
|
|
return "none";
|
|
}
|
|
|
|
/** Derive recommended action from legacy messages that lack structured AI fields. */
|
|
function deriveRecommendedAction(msg: MessageRecord): string {
|
|
if (msg.ai_recommended_action) return msg.ai_recommended_action;
|
|
const severity = deriveSeverity(msg);
|
|
if (
|
|
msg.ai_status === "flagged" &&
|
|
(severity === "critical" || severity === "high")
|
|
)
|
|
return "delete";
|
|
if (msg.ai_status === "flagged") return "review";
|
|
if (msg.ai_status === "warn") return "warn";
|
|
return "none";
|
|
}
|
|
|
|
function isAutoDeleteEligible(message: MessageRecord): boolean {
|
|
if (message.ai_status !== "flagged" && message.ai_status !== "warn")
|
|
return false;
|
|
|
|
const confidence = message.ai_confidence ?? message.ai_moderation_score ?? 0;
|
|
if (confidence < config.AUTO_DELETE_MIN_CONFIDENCE) {
|
|
logger.info(
|
|
{
|
|
messageId: message.id,
|
|
confidence,
|
|
threshold: config.AUTO_DELETE_MIN_CONFIDENCE,
|
|
},
|
|
"Auto-delete skipped: confidence below threshold",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const severity = deriveSeverity(message);
|
|
const allowedSeverities = (config.AUTO_DELETE_ALLOWED_SEVERITIES || "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (allowedSeverities.length > 0 && !allowedSeverities.includes(severity)) {
|
|
logger.info(
|
|
{ messageId: message.id, severity, allowed: allowedSeverities },
|
|
"Auto-delete skipped: severity not in allowed list",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const recommendedAction = deriveRecommendedAction(message);
|
|
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
|
logger.info(
|
|
{ messageId: message.id, recommendedAction },
|
|
"Auto-delete skipped: recommended action is not delete/escalate",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const allowedCategories = parseStringList(
|
|
config.AUTO_DELETE_ALLOWED_CATEGORIES,
|
|
);
|
|
if (allowedCategories.length > 0) {
|
|
const messageCategories = parseStringList(
|
|
message.ai_categories ?? message.ai_moderation_flags,
|
|
);
|
|
const hasAllowedCategory = messageCategories.some((cat) =>
|
|
allowedCategories.includes(cat),
|
|
);
|
|
if (!hasAllowedCategory) {
|
|
logger.info(
|
|
{
|
|
messageId: message.id,
|
|
categories: messageCategories,
|
|
allowed: allowedCategories,
|
|
},
|
|
"Auto-delete skipped: no allowed categories match",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const excludedChannels = parseStringList(
|
|
config.AUTO_DELETE_EXCLUDED_CHANNEL_IDS,
|
|
);
|
|
if (excludedChannels.length > 0) {
|
|
const channelId = message.thread_id ?? message.channel_id;
|
|
if (excludedChannels.includes(channelId)) {
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-delete skipped: channel excluded",
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const excludedUsers = parseStringList(config.AUTO_DELETE_EXCLUDED_USER_IDS);
|
|
if (excludedUsers.length > 0 && excludedUsers.includes(message.user_id)) {
|
|
logger.info(
|
|
{ messageId: message.id, userId: message.user_id },
|
|
"Auto-delete skipped: user excluded",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function logAutoDeleteAttempt(
|
|
message: MessageRecord,
|
|
result: AutoDeleteResult,
|
|
): Promise<void> {
|
|
try {
|
|
await createModerationAction({
|
|
message_id: message.id,
|
|
user_id: message.user_id,
|
|
guild_id: message.guild_id,
|
|
action_type: "delete_message",
|
|
reason: result.reason,
|
|
executed_by: "auto-delete-manager",
|
|
status: result.deleted
|
|
? "executed"
|
|
: result.reason === "dry_run"
|
|
? "executed"
|
|
: "failed",
|
|
error: result.reason === "error" ? result.reason : null,
|
|
executed_at:
|
|
result.deleted || result.reason === "dry_run" ? Date.now() : null,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
"Failed to persist auto-delete action log",
|
|
);
|
|
}
|
|
}
|
|
|
|
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<unknown> }>;
|
|
};
|
|
} {
|
|
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<AutoDeleteResult> {
|
|
if (!config.AUTO_DELETE_FLAGGED_ENABLED) {
|
|
return { deleted: false, skipped: true, reason: "disabled" };
|
|
}
|
|
|
|
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
|
const result = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "not_flagged_or_warn",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
return result;
|
|
}
|
|
|
|
if (!isAutoDeleteEligible(message)) {
|
|
const result = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "not_eligible",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
return result;
|
|
}
|
|
|
|
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) {
|
|
const result = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "dry_run",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-delete dry-run: would delete flagged message",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
const discordMessage = await channel.messages.fetch(message.id);
|
|
await discordMessage.delete();
|
|
|
|
const result = {
|
|
deleted: true,
|
|
skipped: false,
|
|
reason: "deleted",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, channelId },
|
|
"Auto-deleted AI-flagged message",
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
if (isAlreadyDeletedError(error)) {
|
|
const result = {
|
|
deleted: true,
|
|
skipped: false,
|
|
reason: "already_deleted",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.info(
|
|
{ messageId: message.id, code: getErrorCode(error) },
|
|
"Auto-delete skipped: message already deleted",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
const result = {
|
|
deleted: false,
|
|
skipped: true,
|
|
reason: "error",
|
|
} as AutoDeleteResult;
|
|
await logAutoDeleteAttempt(message, result);
|
|
logger.error(
|
|
{
|
|
messageId: message.id,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
code: getErrorCode(error),
|
|
},
|
|
"Auto-delete failed",
|
|
);
|
|
return result;
|
|
}
|
|
}
|