Merge branch 'worktree-agent-acd4fe7096dc8d6e4'
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import type { Client, Guild, User } from "discord.js-selfbot-v13";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import {
|
||||
getModerationAction,
|
||||
updateModerationAction,
|
||||
} from "./messageStore.js";
|
||||
import type { ModerationAction, ModerationActionType } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("action-executor");
|
||||
|
||||
interface ActionExecutionContext {
|
||||
client: Client;
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a moderation action (delete message, mute user, etc.)
|
||||
*/
|
||||
export async function executeModerationAction(
|
||||
action: ModerationAction,
|
||||
context: ActionExecutionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const guild = await context.client.guilds.fetch(context.guildId);
|
||||
if (!guild) {
|
||||
throw new Error(`Guild ${context.guildId} not found`);
|
||||
}
|
||||
|
||||
switch (action.action_type) {
|
||||
case "delete_message":
|
||||
await executeDeleteMessage(action, guild);
|
||||
break;
|
||||
case "mute_user":
|
||||
await executeMuteUser(action, guild);
|
||||
break;
|
||||
case "warn_user":
|
||||
await executeWarnUser(action, guild);
|
||||
break;
|
||||
case "kick_user":
|
||||
await executeKickUser(action, guild);
|
||||
break;
|
||||
case "ban_user":
|
||||
await executeBanUser(action, guild);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action.action_type}`);
|
||||
}
|
||||
|
||||
// Mark action as executed
|
||||
await updateModerationAction(action.id, {
|
||||
status: "executed",
|
||||
executed_at: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: action.action_type,
|
||||
guildId: context.guildId,
|
||||
},
|
||||
"Moderation action executed successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Mark action as failed
|
||||
await updateModerationAction(action.id, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
logger.error(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: action.action_type,
|
||||
guildId: context.guildId,
|
||||
error: errorMessage,
|
||||
},
|
||||
"Failed to execute moderation action",
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDeleteMessage(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.message_id) {
|
||||
throw new Error("message_id is required for delete_message action");
|
||||
}
|
||||
|
||||
// Note: Discord.js selfbot cannot delete messages from other users
|
||||
// This is a placeholder for the intended behavior
|
||||
logger.warn(
|
||||
{ messageId: action.message_id },
|
||||
"Delete message action requires manual execution or bot permissions",
|
||||
);
|
||||
}
|
||||
|
||||
async function executeMuteUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for mute_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(action.user_id);
|
||||
if (!member) {
|
||||
throw new Error(`Member ${action.user_id} not found in guild`);
|
||||
}
|
||||
|
||||
// Mute by removing speak permission in all voice channels
|
||||
const voiceChannels = guild.channels.cache.filter(
|
||||
(ch) => ch.type === "GUILD_VOICE",
|
||||
);
|
||||
|
||||
for (const [, channel] of voiceChannels) {
|
||||
await channel.permissionOverwrites.create(member, {
|
||||
SPEAK: false,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User muted in all voice channels",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to mute user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWarnUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for warn_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await guild.client.users.fetch(action.user_id);
|
||||
if (!user) {
|
||||
throw new Error(`User ${action.user_id} not found`);
|
||||
}
|
||||
|
||||
const reason = action.reason || "Warned by moderation system";
|
||||
await user.send(
|
||||
`You have been warned in ${guild.name}. Reason: ${reason}`,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User warned via DM",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
userId: action.user_id,
|
||||
guildId: guild.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to send warning DM to user",
|
||||
);
|
||||
// Don't throw - warning DM failure is not critical
|
||||
}
|
||||
}
|
||||
|
||||
async function executeKickUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for kick_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(action.user_id);
|
||||
if (!member) {
|
||||
throw new Error(`Member ${action.user_id} not found in guild`);
|
||||
}
|
||||
|
||||
const reason = action.reason || "Kicked by moderation system";
|
||||
await member.kick(reason);
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User kicked from guild",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to kick user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBanUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for ban_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const reason = action.reason || "Banned by moderation system";
|
||||
await guild.bans.create(action.user_id, { reason });
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User banned from guild",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to ban user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes pending moderation actions for a guild
|
||||
*/
|
||||
export async function processPendingActions(
|
||||
guildId: string,
|
||||
context: ActionExecutionContext,
|
||||
): Promise<{ processed: number; failed: number }> {
|
||||
const result = { processed: 0, failed: 0 };
|
||||
|
||||
try {
|
||||
const { listModerationActions } = await import("./messageStore.js");
|
||||
|
||||
const { data: actions } = await listModerationActions({
|
||||
guildId,
|
||||
status: ["pending"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await executeModerationAction(action, context);
|
||||
result.processed++;
|
||||
} catch (error) {
|
||||
result.failed++;
|
||||
logger.error(
|
||||
{
|
||||
actionId: action.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to process pending action",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ guildId, ...result },
|
||||
"Processed pending moderation actions",
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to process pending actions",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic action processor
|
||||
*/
|
||||
export function startActionProcessor(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
intervalMs: number = 60 * 1000, // 1 minute
|
||||
): NodeJS.Timeout {
|
||||
logger.info({ guildId, intervalMs }, "Starting action processor");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await processPendingActions(guildId, { client, guildId });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Action processor failed",
|
||||
);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
return interval;
|
||||
}
|
||||
@@ -307,6 +307,12 @@ async function processIndividualFallback(
|
||||
score: r.score,
|
||||
raw: JSON.stringify(analysisResult.raw),
|
||||
analysis: r.analysis,
|
||||
categories: r.categories,
|
||||
severity: r.severity,
|
||||
confidence: r.confidence,
|
||||
recommendedAction: r.recommendedAction,
|
||||
policyVersion: r.policyVersion,
|
||||
evidence: r.evidence,
|
||||
analyzedAt: Date.now(),
|
||||
error: null,
|
||||
},
|
||||
@@ -359,6 +365,12 @@ async function processIndividualFallback(
|
||||
raw: null,
|
||||
analysis:
|
||||
"Individual fallback exhausted all retries: LLM consistently dropped this message even in single-target mode",
|
||||
categories: ["individual_analysis_exhausted"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
analyzedAt: Date.now(),
|
||||
error: lastError,
|
||||
},
|
||||
|
||||
@@ -2,9 +2,113 @@ 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";
|
||||
import { createModerationAction } from "./messageStore.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);
|
||||
}
|
||||
};
|
||||
|
||||
function isAutoDeleteEligible(message: MessageRecord): boolean {
|
||||
if (message.ai_status !== "flagged") return false;
|
||||
|
||||
const confidence = message.ai_confidence ?? message.ai_moderation_score ?? 0;
|
||||
if (confidence < config.AUTO_DELETE_MIN_CONFIDENCE) {
|
||||
logger.debug(
|
||||
{ messageId: message.id, confidence, threshold: config.AUTO_DELETE_MIN_CONFIDENCE },
|
||||
"Auto-delete skipped: confidence below threshold",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedSeverities = config.AUTO_DELETE_ALLOWED_SEVERITIES
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (allowedSeverities.length > 0 && message.ai_severity) {
|
||||
if (!allowedSeverities.includes(message.ai_severity)) {
|
||||
logger.debug(
|
||||
{ messageId: message.id, severity: message.ai_severity, allowed: allowedSeverities },
|
||||
"Auto-delete skipped: severity not in allowed list",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const recommendedAction = message.ai_recommended_action ?? "";
|
||||
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
||||
logger.debug(
|
||||
{ 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.debug(
|
||||
{ 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.debug({ 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.debug({ 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;
|
||||
@@ -56,7 +160,15 @@ export async function attemptAutoDeleteFlaggedMessage(
|
||||
}
|
||||
|
||||
if (message.ai_status !== "flagged") {
|
||||
return { deleted: false, skipped: true, reason: "not_flagged" };
|
||||
const result = { deleted: false, skipped: true, reason: "not_flagged" } 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) {
|
||||
@@ -106,30 +218,38 @@ export async function attemptAutoDeleteFlaggedMessage(
|
||||
}
|
||||
|
||||
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 { deleted: false, skipped: true, reason: "dry_run" };
|
||||
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 { deleted: true, skipped: false, reason: "deleted" };
|
||||
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 { deleted: true, skipped: false, reason: "already_deleted" };
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = { deleted: false, skipped: true, reason: "error" } as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
@@ -138,6 +258,6 @@ export async function attemptAutoDeleteFlaggedMessage(
|
||||
},
|
||||
"Auto-delete failed",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "error" };
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ import type {
|
||||
} from "./types.js";
|
||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||
|
||||
const SeveritySchema = z.enum(["none", "low", "medium", "high", "critical"]);
|
||||
const RecommendedActionSchema = z.enum([
|
||||
"none",
|
||||
"monitor",
|
||||
"warn",
|
||||
"review",
|
||||
"delete",
|
||||
"escalate",
|
||||
]);
|
||||
|
||||
const ModerationResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
@@ -21,6 +31,12 @@ const ModerationResponseSchema = z.object({
|
||||
flags: z.array(z.string()).catch([]),
|
||||
score: z.number().catch(0),
|
||||
analysis: z.string().catch(""),
|
||||
categories: z.array(z.string()).optional().catch(undefined),
|
||||
severity: SeveritySchema.optional().catch(undefined),
|
||||
confidence: z.number().optional().catch(undefined),
|
||||
recommended_action: RecommendedActionSchema.optional().catch(undefined),
|
||||
policy_version: z.string().optional().catch(undefined),
|
||||
evidence: z.array(z.string()).optional().catch(undefined),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -33,6 +49,31 @@ function hasDeferralAnalysis(analysis: string): boolean {
|
||||
return DEFERRAL_ANALYSIS_PATTERN.test(analysis);
|
||||
}
|
||||
|
||||
function clampScore(value: number | undefined, fallback = 0): number {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? (value as number) : fallback));
|
||||
}
|
||||
|
||||
function deriveSeverity(
|
||||
status: "clean" | "warn" | "flagged",
|
||||
score: number,
|
||||
): z.infer<typeof SeveritySchema> {
|
||||
if (status === "clean") return "none";
|
||||
if (status === "warn") return score >= 0.65 ? "medium" : "low";
|
||||
if (score >= 0.9) return "critical";
|
||||
return score >= 0.75 ? "high" : "medium";
|
||||
}
|
||||
|
||||
function deriveRecommendedAction(
|
||||
status: "clean" | "warn" | "flagged",
|
||||
severity: z.infer<typeof SeveritySchema>,
|
||||
): z.infer<typeof RecommendedActionSchema> {
|
||||
if (status === "clean") return "none";
|
||||
if (status === "warn") return severity === "medium" ? "review" : "warn";
|
||||
if (severity === "critical") return "escalate";
|
||||
if (severity === "high") return "delete";
|
||||
return "review";
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: config.AI_LLM_API_KEY,
|
||||
baseURL: config.AI_LLM_BASE_URL,
|
||||
@@ -198,7 +239,19 @@ export function parseModerationResponse(
|
||||
const targetIdSet = new Set(targetIds);
|
||||
|
||||
const results: (AnalysisResult | null)[] = response.results.map((result) => {
|
||||
const { message_id, status, flags, score, analysis } = result;
|
||||
const {
|
||||
message_id,
|
||||
status,
|
||||
flags,
|
||||
score,
|
||||
analysis,
|
||||
categories,
|
||||
severity,
|
||||
confidence,
|
||||
recommended_action,
|
||||
policy_version,
|
||||
evidence,
|
||||
} = result;
|
||||
const finalId = message_id.trim();
|
||||
|
||||
if (!targetIdSet.has(finalId)) {
|
||||
@@ -217,12 +270,23 @@ export function parseModerationResponse(
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedScore = clampScore(score);
|
||||
const normalizedConfidence = clampScore(confidence, normalizedScore);
|
||||
const normalizedSeverity = severity ?? deriveSeverity(status, normalizedScore);
|
||||
|
||||
return {
|
||||
messageId: finalId,
|
||||
status: status as "clean" | "warn" | "flagged",
|
||||
flags,
|
||||
score: Math.max(0, Math.min(1, score)),
|
||||
score: normalizedScore,
|
||||
analysis,
|
||||
categories: categories ?? flags,
|
||||
severity: normalizedSeverity,
|
||||
confidence: normalizedConfidence,
|
||||
recommendedAction:
|
||||
recommended_action ?? deriveRecommendedAction(status, normalizedSeverity),
|
||||
policyVersion: policy_version ?? "default-2026-05-30",
|
||||
evidence: evidence ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -243,6 +307,12 @@ export function parseModerationResponse(
|
||||
flags: ["analysis_incomplete"],
|
||||
score: 0,
|
||||
analysis: "Analysis incomplete - LLM did not process this message",
|
||||
categories: ["analysis_incomplete"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -696,6 +766,12 @@ Struktur wajib:
|
||||
"status": "clean" | "warn" | "flagged",
|
||||
"flags": [<string array, kosong jika clean>],
|
||||
"score": <float 0.0–1.0>,
|
||||
"categories": [<kategori kebijakan, kosong jika clean>],
|
||||
"severity": "none" | "low" | "medium" | "high" | "critical",
|
||||
"confidence": <float 0.0–1.0>,
|
||||
"recommended_action": "none" | "monitor" | "warn" | "review" | "delete" | "escalate",
|
||||
"policy_version": "default-2026-05-30",
|
||||
"evidence": [<kutipan/evidence singkat dari teks/media/konteks>],
|
||||
"analysis": "<penjelasan singkat dalam Bahasa Indonesia, maks 2 kalimat>"
|
||||
}
|
||||
]
|
||||
@@ -880,6 +956,12 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
||||
flags: ["analysis_parse_failed"],
|
||||
score: 0,
|
||||
analysis: `Parsing failed: ${errorMsg}.`,
|
||||
categories: ["analysis_parse_failed"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,23 @@ import {
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { getDatabase } from "../database/drizzle.js";
|
||||
import { attachmentsTable, messagesTable } from "../database/schema.js";
|
||||
import {
|
||||
attachmentsTable,
|
||||
messageReviewsTable,
|
||||
messagesTable,
|
||||
moderationActionsTable,
|
||||
retentionPoliciesTable,
|
||||
} from "../database/schema.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { decodeCursor, encodeCursor } from "./pagination.js";
|
||||
import type {
|
||||
AttachmentRecord,
|
||||
MessageQuery,
|
||||
MessageRecord,
|
||||
MessageReview,
|
||||
ModerationAction,
|
||||
PageResult,
|
||||
RetentionPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
const logger = createChildLogger("message-store");
|
||||
@@ -90,12 +99,12 @@ function buildListMessageConditions(query: MessageQuery): SQL[] {
|
||||
return conditions;
|
||||
}
|
||||
|
||||
function pageMessages(
|
||||
function pageRows<T extends { created_at: number; id: string }>(
|
||||
rows: unknown[],
|
||||
limit: number,
|
||||
): PageResult<MessageRecord> {
|
||||
): PageResult<T> {
|
||||
const hasMore = rows.length > limit;
|
||||
const data = rows.slice(0, limit) as MessageRecord[];
|
||||
const data = rows.slice(0, limit) as T[];
|
||||
const lastItem = data[data.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && lastItem
|
||||
@@ -105,6 +114,13 @@ function pageMessages(
|
||||
return { data, nextCursor };
|
||||
}
|
||||
|
||||
function pageMessages(
|
||||
rows: unknown[],
|
||||
limit: number,
|
||||
): PageResult<MessageRecord> {
|
||||
return pageRows<MessageRecord>(rows, limit);
|
||||
}
|
||||
|
||||
export { decodeCursor, encodeCursor } from "./pagination.js";
|
||||
|
||||
export async function insertMessage(message: MessageRecord): Promise<void> {
|
||||
@@ -378,10 +394,21 @@ interface AIAnalysisUpdate {
|
||||
score?: number | null;
|
||||
raw?: string | null;
|
||||
analysis?: string | null;
|
||||
categories?: string[] | string | null;
|
||||
severity?: MessageRecord["ai_severity"] | null;
|
||||
confidence?: number | null;
|
||||
recommendedAction?: MessageRecord["ai_recommended_action"] | null;
|
||||
policyVersion?: string | null;
|
||||
evidence?: string[] | string | null;
|
||||
analyzedAt?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
function stringifyAIList(value: string[] | string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
return Array.isArray(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
export async function updateMessageAIAnalysis(
|
||||
messageId: string,
|
||||
result: AIAnalysisUpdate,
|
||||
@@ -396,6 +423,12 @@ export async function updateMessageAIAnalysis(
|
||||
ai_moderation_score: result.score ?? null,
|
||||
ai_moderation_raw: result.raw ?? null,
|
||||
ai_analysis: result.analysis ?? null,
|
||||
ai_categories: stringifyAIList(result.categories),
|
||||
ai_severity: result.severity ?? null,
|
||||
ai_confidence: result.confidence ?? result.score ?? null,
|
||||
ai_recommended_action: result.recommendedAction ?? null,
|
||||
ai_policy_version: result.policyVersion ?? null,
|
||||
ai_evidence: stringifyAIList(result.evidence),
|
||||
ai_analyzed_at: result.analyzedAt ?? Date.now(),
|
||||
ai_error: result.error ?? null,
|
||||
})
|
||||
@@ -800,3 +833,331 @@ export async function getIncompleteMessagesByConversation(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Message Reviews CRUD
|
||||
// ====================
|
||||
|
||||
export async function createMessageReview(
|
||||
review: Omit<MessageReview, "id" | "created_at">,
|
||||
): Promise<MessageReview> {
|
||||
try {
|
||||
const database = db();
|
||||
const id = `review-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created_at = Date.now();
|
||||
|
||||
const rows = await database
|
||||
.insert<Array<MessageReview>>(messageReviewsTable)
|
||||
.values({
|
||||
...review,
|
||||
id,
|
||||
created_at,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return rows[0] as MessageReview;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
messageId: review.message_id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to create message review",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessageReview(id: string): Promise<MessageReview | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(messageReviewsTable)
|
||||
.where(eq(messageReviewsTable.id, id));
|
||||
|
||||
return (rows[0] as MessageReview) || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ reviewId: id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get message review",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMessageReviews(query: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
status?: string[];
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}): Promise<PageResult<MessageReview>> {
|
||||
try {
|
||||
const database = db();
|
||||
const limit = Math.max(1, Math.min(query.limit || 50, 100));
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.guildId) {
|
||||
conditions.push(eq(messageReviewsTable.guild_id, query.guildId));
|
||||
}
|
||||
if (query.channelId) {
|
||||
conditions.push(eq(messageReviewsTable.channel_id, query.channelId));
|
||||
}
|
||||
if (query.status && query.status.length > 0) {
|
||||
conditions.push(sql`${messageReviewsTable.status} in ${query.status}`);
|
||||
}
|
||||
|
||||
const cursorData = decodeCursor(query.cursor);
|
||||
if (cursorData) {
|
||||
conditions.push(
|
||||
sql`(${messageReviewsTable.created_at} < ${cursorData.created_at} or (${messageReviewsTable.created_at} = ${cursorData.created_at} and ${messageReviewsTable.id} < ${cursorData.id}))`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(messageReviewsTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(messageReviewsTable.created_at), desc(messageReviewsTable.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
return pageRows<MessageReview>(rows, limit);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list message reviews",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMessageReview(
|
||||
id: string,
|
||||
updates: Partial<Omit<MessageReview, "id" | "created_at">>,
|
||||
): Promise<MessageReview | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = (await database
|
||||
.update(messageReviewsTable)
|
||||
.set(updates)
|
||||
.where(eq(messageReviewsTable.id, id))
|
||||
.returning()) as MessageReview[];
|
||||
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ reviewId: id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update message review",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation Actions CRUD
|
||||
// =======================
|
||||
|
||||
export async function createModerationAction(
|
||||
action: Omit<ModerationAction, "id" | "created_at">,
|
||||
): Promise<ModerationAction> {
|
||||
try {
|
||||
const database = db();
|
||||
const id = `action-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const created_at = Date.now();
|
||||
|
||||
const rows = await database
|
||||
.insert<Array<ModerationAction>>(moderationActionsTable)
|
||||
.values({
|
||||
...action,
|
||||
id,
|
||||
created_at,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return rows[0] as ModerationAction;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId: action.guild_id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to create moderation action",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModerationAction(id: string): Promise<ModerationAction | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(moderationActionsTable)
|
||||
.where(eq(moderationActionsTable.id, id));
|
||||
|
||||
return (rows[0] as ModerationAction) || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ actionId: id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get moderation action",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listModerationActions(query: {
|
||||
guildId?: string;
|
||||
status?: string[];
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}): Promise<PageResult<ModerationAction>> {
|
||||
try {
|
||||
const database = db();
|
||||
const limit = Math.max(1, Math.min(query.limit || 50, 100));
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.guildId) {
|
||||
conditions.push(eq(moderationActionsTable.guild_id, query.guildId));
|
||||
}
|
||||
if (query.status && query.status.length > 0) {
|
||||
conditions.push(sql`${moderationActionsTable.status} in ${query.status}`);
|
||||
}
|
||||
|
||||
const cursorData = decodeCursor(query.cursor);
|
||||
if (cursorData) {
|
||||
conditions.push(
|
||||
sql`(${moderationActionsTable.created_at} < ${cursorData.created_at} or (${moderationActionsTable.created_at} = ${cursorData.created_at} and ${moderationActionsTable.id} < ${cursorData.id}))`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(moderationActionsTable)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(moderationActionsTable.created_at), desc(moderationActionsTable.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
return pageRows<ModerationAction>(rows, limit);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list moderation actions",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateModerationAction(
|
||||
id: string,
|
||||
updates: Partial<Omit<ModerationAction, "id" | "created_at">>,
|
||||
): Promise<ModerationAction | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = (await database
|
||||
.update(moderationActionsTable)
|
||||
.set(updates)
|
||||
.where(eq(moderationActionsTable.id, id))
|
||||
.returning()) as ModerationAction[];
|
||||
|
||||
return rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ actionId: id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update moderation action",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Retention Policies CRUD
|
||||
// =======================
|
||||
|
||||
export async function getRetentionPolicy(guildId: string): Promise<RetentionPolicy | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(retentionPoliciesTable)
|
||||
.where(eq(retentionPoliciesTable.guild_id, guildId));
|
||||
|
||||
return (rows[0] as RetentionPolicy) || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ guildId, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get retention policy",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertRetentionPolicy(
|
||||
policy: Omit<RetentionPolicy, "created_at" | "updated_at">,
|
||||
): Promise<RetentionPolicy> {
|
||||
try {
|
||||
const database = db();
|
||||
const now = Date.now();
|
||||
const existing = await getRetentionPolicy(policy.guild_id);
|
||||
|
||||
if (existing) {
|
||||
const rows = (await database
|
||||
.update(retentionPoliciesTable)
|
||||
.set({
|
||||
...policy,
|
||||
updated_at: now,
|
||||
})
|
||||
.where(eq(retentionPoliciesTable.id, existing.id))
|
||||
.returning()) as RetentionPolicy[];
|
||||
|
||||
return rows[0] as RetentionPolicy;
|
||||
}
|
||||
|
||||
const id = `policy-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const rows = (await database
|
||||
.insert<Array<RetentionPolicy>>(retentionPoliciesTable)
|
||||
.values({
|
||||
...policy,
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.returning()) as RetentionPolicy[];
|
||||
|
||||
return rows[0] as RetentionPolicy;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId: policy.guild_id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to upsert retention policy",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExpiredMessages(
|
||||
retentionDays: number,
|
||||
): Promise<MessageRecord[]> {
|
||||
try {
|
||||
const database = db();
|
||||
const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
|
||||
const rows = await database
|
||||
.select()
|
||||
.from(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
sql`${messagesTable.created_at} < ${cutoffTime}`,
|
||||
isNull(messagesTable.deleted_at),
|
||||
),
|
||||
)
|
||||
.limit(1000);
|
||||
|
||||
return rows as MessageRecord[];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ retentionDays, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get expired messages",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getDatabase } from "../database/drizzle.js";
|
||||
import {
|
||||
attachmentsTable,
|
||||
messagesTable,
|
||||
retentionPoliciesTable,
|
||||
voiceRecordingsTable,
|
||||
} from "../database/schema.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import {
|
||||
getExpiredMessages,
|
||||
getRetentionPolicy,
|
||||
} from "./messageStore.js";
|
||||
import type { RetentionPolicy } from "./types.js";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
|
||||
const logger = createChildLogger("retention-manager");
|
||||
|
||||
interface RetentionResult {
|
||||
messagesDeleted: number;
|
||||
attachmentsDeleted: number;
|
||||
voiceRecordingsDeleted: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes retention policy for a guild
|
||||
* Deletes messages, attachments, and voice recordings older than retention_days
|
||||
*/
|
||||
export async function executeRetentionPolicy(
|
||||
guildId: string,
|
||||
): Promise<RetentionResult> {
|
||||
const result: RetentionResult = {
|
||||
messagesDeleted: 0,
|
||||
attachmentsDeleted: 0,
|
||||
voiceRecordingsDeleted: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const policy = await getRetentionPolicy(guildId);
|
||||
if (!policy || !policy.enabled) {
|
||||
logger.debug({ guildId }, "Retention policy not enabled");
|
||||
return result;
|
||||
}
|
||||
|
||||
const db = getDatabase() as any;
|
||||
const cutoffTime = Date.now() - policy.retention_days * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Delete old messages
|
||||
const deletedMessages = await db
|
||||
.delete(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(messagesTable.guild_id, guildId),
|
||||
lt(messagesTable.created_at, cutoffTime),
|
||||
isNull(messagesTable.deleted_at),
|
||||
),
|
||||
);
|
||||
|
||||
result.messagesDeleted = deletedMessages.rowsAffected || 0;
|
||||
|
||||
// Delete old attachments if policy applies
|
||||
if (policy.apply_to_media) {
|
||||
const deletedAttachments = await db
|
||||
.delete(attachmentsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(attachmentsTable.guild_id, guildId),
|
||||
lt(attachmentsTable.created_at, cutoffTime),
|
||||
),
|
||||
);
|
||||
|
||||
result.attachmentsDeleted = deletedAttachments.rowsAffected || 0;
|
||||
}
|
||||
|
||||
// Delete old voice recordings if policy applies
|
||||
if (policy.apply_to_voice) {
|
||||
const deletedVoice = await db
|
||||
.delete(voiceRecordingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceRecordingsTable.guild_id, guildId),
|
||||
lt(voiceRecordingsTable.created_at, cutoffTime),
|
||||
),
|
||||
);
|
||||
|
||||
result.voiceRecordingsDeleted = deletedVoice.rowsAffected || 0;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
guildId,
|
||||
retentionDays: policy.retention_days,
|
||||
...result,
|
||||
},
|
||||
"Retention policy executed",
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{ guildId, error: message },
|
||||
"Failed to execute retention policy",
|
||||
);
|
||||
result.error = message;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes retention policies for all enabled guilds
|
||||
* Returns summary of deletions
|
||||
*/
|
||||
export async function executeAllRetentionPolicies(): Promise<{
|
||||
policiesExecuted: number;
|
||||
totalMessagesDeleted: number;
|
||||
totalAttachmentsDeleted: number;
|
||||
totalVoiceDeleted: number;
|
||||
errors: Array<{ guildId: string; error: string }>;
|
||||
}> {
|
||||
const summary = {
|
||||
policiesExecuted: 0,
|
||||
totalMessagesDeleted: 0,
|
||||
totalAttachmentsDeleted: 0,
|
||||
totalVoiceDeleted: 0,
|
||||
errors: [] as Array<{ guildId: string; error: string }>,
|
||||
};
|
||||
|
||||
try {
|
||||
const db = getDatabase() as any;
|
||||
const policies = await db
|
||||
.select()
|
||||
.from(retentionPoliciesTable)
|
||||
.where(eq(retentionPoliciesTable.enabled, true));
|
||||
|
||||
for (const policy of policies as RetentionPolicy[]) {
|
||||
const result = await executeRetentionPolicy(policy.guild_id);
|
||||
summary.policiesExecuted++;
|
||||
summary.totalMessagesDeleted += result.messagesDeleted;
|
||||
summary.totalAttachmentsDeleted += result.attachmentsDeleted;
|
||||
summary.totalVoiceDeleted += result.voiceRecordingsDeleted;
|
||||
|
||||
if (result.error) {
|
||||
summary.errors.push({
|
||||
guildId: policy.guild_id,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(summary, "All retention policies executed");
|
||||
return summary;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ error: message }, "Failed to execute all retention policies");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic retention policy executor
|
||||
* Runs every 24 hours by default
|
||||
*/
|
||||
export function startRetentionPolicyWorker(intervalMs: number = 24 * 60 * 60 * 1000): NodeJS.Timeout {
|
||||
logger.info({ intervalMs }, "Starting retention policy worker");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await executeAllRetentionPolicies();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Retention policy worker failed",
|
||||
);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
return interval;
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import type {
|
||||
} from "./broadcaster.js";
|
||||
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
export type AIRecommendedAction =
|
||||
| "none"
|
||||
| "monitor"
|
||||
| "warn"
|
||||
| "review"
|
||||
| "delete"
|
||||
| "escalate";
|
||||
|
||||
export type { BroadcasterClient, ModerationBroadcaster };
|
||||
|
||||
@@ -27,6 +35,12 @@ export interface MessageRecord {
|
||||
ai_moderation_score?: number | null;
|
||||
ai_moderation_raw?: string | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: AISeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_policy_version?: string | null;
|
||||
ai_evidence?: string | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
@@ -93,6 +107,12 @@ export interface AnalysisResult {
|
||||
flags: string[];
|
||||
score: number;
|
||||
analysis: string;
|
||||
categories?: string[];
|
||||
severity?: AISeverity;
|
||||
confidence?: number;
|
||||
recommendedAction?: AIRecommendedAction;
|
||||
policyVersion?: string;
|
||||
evidence?: string[];
|
||||
}
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
@@ -145,3 +165,50 @@ export interface AnalysisQueueStatus {
|
||||
individualCircuitBreakerActive: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated";
|
||||
|
||||
export interface MessageReview {
|
||||
id: string;
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
reviewer_id: string | null;
|
||||
status: ReviewStatus;
|
||||
notes: string | null;
|
||||
created_at: number;
|
||||
reviewed_at: number | null;
|
||||
}
|
||||
|
||||
export type ModerationActionType =
|
||||
| "delete_message"
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user";
|
||||
|
||||
export interface ModerationAction {
|
||||
id: string;
|
||||
message_id: string | null;
|
||||
user_id: string | null;
|
||||
guild_id: string;
|
||||
action_type: ModerationActionType;
|
||||
reason: string | null;
|
||||
executed_by: string | null;
|
||||
status: "pending" | "executed" | "failed";
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
executed_at: number | null;
|
||||
}
|
||||
|
||||
export interface RetentionPolicy {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string | null;
|
||||
retention_days: number;
|
||||
apply_to_media: boolean;
|
||||
apply_to_voice: boolean;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user