diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 7b18e69..443d667 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,4 +1,12 @@
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 interface MessageRecord {
id: string;
@@ -20,6 +28,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;
}
diff --git a/frontend/src/components/messages/MessageCard.tsx b/frontend/src/components/messages/MessageCard.tsx
index f74627f..1599eff 100644
--- a/frontend/src/components/messages/MessageCard.tsx
+++ b/frontend/src/components/messages/MessageCard.tsx
@@ -24,9 +24,25 @@ function getAiIcon(status: string) {
return null;
}
+function 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);
+ }
+}
+
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
+ const categories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
+ const evidence = parseStringList(message.ai_evidence);
+ const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null;
const [isReanalyzing, setIsReanalyzing] = useState(false);
const handleReanalyze = async () => {
@@ -62,11 +78,30 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
{displayContent || "(empty message)"}
+
+ {message.ai_severity ? severity: {message.ai_severity} : null}
+ {message.ai_recommended_action ? action: {message.ai_recommended_action} : null}
+ {confidence != null ? confidence: {Math.round(confidence * 100)}% : null}
+ {message.ai_policy_version ? policy: {message.ai_policy_version} : null}
+ {categories.slice(0, 6).map((category) => (
+ {category}
+ ))}
+
{message.ai_analysis ? (
{message.ai_analysis}
) : null}
+ {evidence.length > 0 ? (
+
+
Evidence
+
+ {evidence.slice(0, 4).map((item, index) => (
+ - {item}
+ ))}
+
+
+ ) : null}
{message.ai_error ? (
AI error: {message.ai_error}
diff --git a/frontend/src/components/review/ReviewPanel.tsx b/frontend/src/components/review/ReviewPanel.tsx
index 5ecaad3..ce8d2f6 100644
--- a/frontend/src/components/review/ReviewPanel.tsx
+++ b/frontend/src/components/review/ReviewPanel.tsx
@@ -1,6 +1,12 @@
+import { useMemo, useState } from "react";
import type { MessageRecord } from "../../types/messages";
-import { MessageFeed } from "../messages/MessageFeed";
+import { useReview, type ReviewStatus } from "../../hooks/useReview";
+import { MessageCard } from "../messages/MessageCard";
+import { Badge } from "../ui/badge";
+import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
+import { Input } from "../ui/input";
+import { Select } from "../ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
export interface ReviewPanelProps {
@@ -8,39 +14,180 @@ export interface ReviewPanelProps {
onReanalyze: (id: string) => void;
}
+type ReviewFilter = "all" | "warn" | "flagged" | "error";
+
+const statusOptions = [
+ { value: "all", label: "All reviewable" },
+ { value: "warn", label: "Warn" },
+ { value: "flagged", label: "Flagged" },
+ { value: "error", label: "Errors" },
+];
+
+function 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 ReviewDecisionControls({
+ message,
+ onReanalyze,
+}: {
+ message: MessageRecord;
+ onReanalyze: (id: string) => void;
+}) {
+ const { createReview, loading, error } = useReview();
+ const [notes, setNotes] = useState("");
+ const [reviewerId, setReviewerId] = useState("public-eval");
+ const [savedStatus, setSavedStatus] = useState
(null);
+
+ const submitDecision = async (status: ReviewStatus) => {
+ const review = await createReview({
+ message_id: message.id,
+ guild_id: message.guild_id,
+ channel_id: message.channel_id,
+ reviewer_id: reviewerId.trim() || "public-eval",
+ status,
+ notes: notes.trim() || null,
+ reviewed_at: Date.now(),
+ });
+ setSavedStatus(review.status);
+ if (status === "rejected") {
+ onReanalyze(message.id);
+ }
+ };
+
+ return (
+
+
+ AI Eval Decision
+ {savedStatus ? saved: {savedStatus} : null}
+
+
+ setReviewerId(event.target.value)}
+ placeholder="reviewer label"
+ />
+ setNotes(event.target.value)}
+ placeholder="reason / evaluation note"
+ />
+
+
+
+
+
+
+ {error ?
{error}
: null}
+
+ );
+}
+
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
- const flaggedItems = messages.filter(
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [severityFilter, setSeverityFilter] = useState("");
+ const [categoryFilter, setCategoryFilter] = useState("");
+
+ const reviewable = useMemo(
+ () => messages.filter((message) => message.ai_status === "warn" || message.ai_status === "flagged" || message.ai_status === "error"),
+ [messages],
+ );
+
+ const categories = useMemo(() => {
+ const set = new Set();
+ for (const message of reviewable) {
+ for (const category of parseStringList(message.ai_categories ?? message.ai_moderation_flags)) {
+ set.add(category);
+ }
+ }
+ return Array.from(set).sort();
+ }, [reviewable]);
+
+ const filtered = reviewable.filter((message) => {
+ if (statusFilter !== "all" && message.ai_status !== statusFilter) return false;
+ if (severityFilter && message.ai_severity !== severityFilter) return false;
+ if (categoryFilter) {
+ const messageCategories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
+ if (!messageCategories.includes(categoryFilter)) return false;
+ }
+ return true;
+ });
+
+ const flaggedItems = filtered.filter(
(message) => message.ai_status === "warn" || message.ai_status === "flagged",
);
- const errorItems = messages.filter((message) => message.ai_status === "error");
+ const errorItems = filtered.filter((message) => message.ai_status === "error");
+
+ const renderList = (items: MessageRecord[], emptyText: string) => (
+
+ {items.length === 0 ? (
+
+ {emptyText}
+
+ ) : (
+ items.map((message) => (
+
+
+
+
+ ))
+ )}
+
+ );
return (
- Needs Review
+ Moderation Review & AI Eval
- {flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
+ Public AI evaluation queue: {reviewable.length} reviewable messages, {errorItems.length} analysis errors.
+
+
Flags ({flaggedItems.length})
Errors ({errorItems.length})
-
+ {renderList(flaggedItems, "No warned or flagged messages match the filters.")}
-
+ {renderList(errorItems, "No analysis errors match the filters.")}
diff --git a/frontend/src/hooks/useReview.ts b/frontend/src/hooks/useReview.ts
new file mode 100644
index 0000000..931cf45
--- /dev/null
+++ b/frontend/src/hooks/useReview.ts
@@ -0,0 +1,221 @@
+import { useCallback, useState } from "react";
+
+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;
+}
+
+interface ReviewQuery {
+ guildId?: string;
+ channelId?: string;
+ status?: string[];
+ cursor?: string;
+ limit: number;
+}
+
+interface PageResult {
+ data: T[];
+ nextCursor: string | null;
+}
+
+export function useReview() {
+ const [reviews, setReviews] = useState([]);
+ const [actions, setActions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [nextCursor, setNextCursor] = useState(null);
+
+ const listReviews = useCallback(async (query: ReviewQuery) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams();
+ if (query.guildId) params.append("guildId", query.guildId);
+ if (query.channelId) params.append("channelId", query.channelId);
+ if (query.status?.length) params.append("status", query.status.join(","));
+ if (query.cursor) params.append("cursor", query.cursor);
+ params.append("limit", String(query.limit));
+
+ const response = await fetch(`/api/reviews?${params}`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const result = (await response.json()) as PageResult;
+ setReviews(result.data);
+ setNextCursor(result.nextCursor);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createReview = useCallback(
+ async (review: Omit) => {
+ try {
+ const response = await fetch("/api/reviews", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(review),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const newReview = (await response.json()) as MessageReview;
+ setReviews((prev) => [newReview, ...prev]);
+ return newReview;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setError(message);
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const updateReview = useCallback(
+ async (
+ id: string,
+ updates: Partial>,
+ ) => {
+ try {
+ const response = await fetch(`/api/reviews/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(updates),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const updated = (await response.json()) as MessageReview;
+ setReviews((prev) =>
+ prev.map((r) => (r.id === id ? updated : r)),
+ );
+ return updated;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setError(message);
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const listActions = useCallback(
+ async (query: Omit) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams();
+ if (query.guildId) params.append("guildId", query.guildId);
+ if (query.status?.length) params.append("status", query.status.join(","));
+ if (query.cursor) params.append("cursor", query.cursor);
+ params.append("limit", String(query.limit));
+
+ const response = await fetch(`/api/actions?${params}`);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const result = (await response.json()) as PageResult;
+ setActions(result.data);
+ setNextCursor(result.nextCursor);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ } finally {
+ setLoading(false);
+ }
+ },
+ [],
+ );
+
+ const createAction = useCallback(
+ async (
+ action: Omit,
+ ) => {
+ try {
+ const response = await fetch("/api/actions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(action),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const newAction = (await response.json()) as ModerationAction;
+ setActions((prev) => [newAction, ...prev]);
+ return newAction;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setError(message);
+ throw err;
+ }
+ },
+ [],
+ );
+
+ const updateAction = useCallback(
+ async (
+ id: string,
+ updates: Partial>,
+ ) => {
+ try {
+ const response = await fetch(`/api/actions/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(updates),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const updated = (await response.json()) as ModerationAction;
+ setActions((prev) =>
+ prev.map((a) => (a.id === id ? updated : a)),
+ );
+ return updated;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ setError(message);
+ throw err;
+ }
+ },
+ [],
+ );
+
+ return {
+ reviews,
+ actions,
+ loading,
+ error,
+ nextCursor,
+ listReviews,
+ createReview,
+ updateReview,
+ listActions,
+ createAction,
+ updateAction,
+ };
+}
diff --git a/src/config.ts b/src/config.ts
index 51b6846..6705a70 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -124,7 +124,24 @@ const configSchema = z
.string()
.optional()
.transform((v) => v === "true")
- .default(false),
+ .default(true),
+ AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.92),
+ AUTO_DELETE_ALLOWED_SEVERITIES: z.string().default("critical"),
+ AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""),
+ AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
+ AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""),
+ RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
+ RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
+ RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
+ RETENTION_CLEANUP_INTERVAL_MS: z.coerce
+ .number()
+ .positive()
+ .default(24 * 60 * 60 * 1000),
+ RETENTION_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/database/schema.ts b/src/database/schema.ts
index a332237..6320771 100644
--- a/src/database/schema.ts
+++ b/src/database/schema.ts
@@ -1,5 +1,6 @@
import {
bigint as pgBigint,
+ boolean as pgBoolean,
foreignKey as pgForeignKey,
index as pgIndex,
integer as pgInteger,
@@ -77,6 +78,16 @@ export const pgMessagesTable = pgTable(
ai_moderation_score: pgReal("ai_moderation_score"),
ai_moderation_raw: pgText("ai_moderation_raw"),
ai_analysis: pgText("ai_analysis"),
+ ai_categories: pgText("ai_categories"),
+ ai_severity: pgText("ai_severity", {
+ enum: ["none", "low", "medium", "high", "critical"],
+ }),
+ ai_confidence: pgReal("ai_confidence"),
+ ai_recommended_action: pgText("ai_recommended_action", {
+ enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
+ }),
+ ai_policy_version: pgText("ai_policy_version"),
+ ai_evidence: pgText("ai_evidence"),
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
ai_error: pgText("ai_error"),
},
@@ -297,6 +308,16 @@ export const sqliteMessagesTable = sqliteTable(
ai_moderation_score: sqliteReal("ai_moderation_score"),
ai_moderation_raw: sqliteText("ai_moderation_raw"),
ai_analysis: sqliteText("ai_analysis"),
+ ai_categories: sqliteText("ai_categories"),
+ ai_severity: sqliteText("ai_severity", {
+ enum: ["none", "low", "medium", "high", "critical"],
+ }),
+ ai_confidence: sqliteReal("ai_confidence"),
+ ai_recommended_action: sqliteText("ai_recommended_action", {
+ enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
+ }),
+ ai_policy_version: sqliteText("ai_policy_version"),
+ ai_evidence: sqliteText("ai_evidence"),
ai_analyzed_at: sqliteInteger("ai_analyzed_at"),
ai_error: sqliteText("ai_error"),
},
@@ -450,6 +471,210 @@ export const sqliteVoiceRecordingsTable = sqliteTable(
}),
);
+/**
+ * Message Reviews Table (PostgreSQL)
+ * Tracks manual reviews of messages flagged by AI moderation
+ */
+export const pgMessageReviewsTable = pgTable(
+ "message_reviews",
+ {
+ id: pgText("id").primaryKey(),
+ message_id: pgText("message_id").notNull(),
+ guild_id: pgText("guild_id").notNull(),
+ channel_id: pgText("channel_id").notNull(),
+ reviewer_id: pgText("reviewer_id"),
+ status: pgText("status", {
+ enum: ["pending", "approved", "rejected", "escalated"],
+ })
+ .notNull()
+ .default("pending"),
+ notes: pgText("notes"),
+ created_at: pgBigint("created_at", { mode: "number" }).notNull(),
+ reviewed_at: pgBigint("reviewed_at", { mode: "number" }),
+ },
+ (table) => ({
+ messageIdIdx: pgIndex("idx_message_reviews_message_id").on(table.message_id),
+ statusIdx: pgIndex("idx_message_reviews_status").on(table.status),
+ createdAtIdx: pgIndex("idx_message_reviews_created_at").on(table.created_at),
+ guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on(
+ table.guild_id,
+ table.status,
+ table.created_at,
+ ),
+ }),
+);
+
+/**
+ * Message Reviews Table (SQLite)
+ * Tracks manual reviews of messages flagged by AI moderation
+ */
+export const sqliteMessageReviewsTable = sqliteTable(
+ "message_reviews",
+ {
+ id: sqliteText("id").primaryKey(),
+ message_id: sqliteText("message_id").notNull(),
+ guild_id: sqliteText("guild_id").notNull(),
+ channel_id: sqliteText("channel_id").notNull(),
+ reviewer_id: sqliteText("reviewer_id"),
+ status: sqliteText("status", {
+ enum: ["pending", "approved", "rejected", "escalated"],
+ })
+ .notNull()
+ .default("pending"),
+ notes: sqliteText("notes"),
+ created_at: sqliteInteger("created_at").notNull(),
+ reviewed_at: sqliteInteger("reviewed_at"),
+ },
+ (table) => ({
+ messageIdIdx: sqliteIndex("idx_message_reviews_message_id").on(
+ table.message_id,
+ ),
+ statusIdx: sqliteIndex("idx_message_reviews_status").on(table.status),
+ createdAtIdx: sqliteIndex("idx_message_reviews_created_at").on(
+ table.created_at,
+ ),
+ guildStatusIdx: sqliteIndex("idx_message_reviews_guild_status").on(
+ table.guild_id,
+ table.status,
+ table.created_at,
+ ),
+ }),
+);
+
+/**
+ * Moderation Actions Table (PostgreSQL)
+ * Tracks actions taken on messages (delete, mute, etc.)
+ */
+export const pgModerationActionsTable = pgTable(
+ "moderation_actions",
+ {
+ id: pgText("id").primaryKey(),
+ message_id: pgText("message_id"),
+ user_id: pgText("user_id"),
+ guild_id: pgText("guild_id").notNull(),
+ action_type: pgText("action_type", {
+ enum: ["delete_message", "mute_user", "warn_user", "kick_user", "ban_user"],
+ })
+ .notNull(),
+ reason: pgText("reason"),
+ executed_by: pgText("executed_by"),
+ status: pgText("status", {
+ enum: ["pending", "executed", "failed"],
+ })
+ .notNull()
+ .default("pending"),
+ error: pgText("error"),
+ created_at: pgBigint("created_at", { mode: "number" }).notNull(),
+ executed_at: pgBigint("executed_at", { mode: "number" }),
+ },
+ (table) => ({
+ messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
+ table.message_id,
+ ),
+ userIdIdx: pgIndex("idx_moderation_actions_user_id").on(table.user_id),
+ statusIdx: pgIndex("idx_moderation_actions_status").on(table.status),
+ guildStatusIdx: pgIndex("idx_moderation_actions_guild_status").on(
+ table.guild_id,
+ table.status,
+ table.created_at,
+ ),
+ }),
+);
+
+/**
+ * Moderation Actions Table (SQLite)
+ * Tracks actions taken on messages (delete, mute, etc.)
+ */
+export const sqliteModerationActionsTable = sqliteTable(
+ "moderation_actions",
+ {
+ id: sqliteText("id").primaryKey(),
+ message_id: sqliteText("message_id"),
+ user_id: sqliteText("user_id"),
+ guild_id: sqliteText("guild_id").notNull(),
+ action_type: sqliteText("action_type", {
+ enum: ["delete_message", "mute_user", "warn_user", "kick_user", "ban_user"],
+ })
+ .notNull(),
+ reason: sqliteText("reason"),
+ executed_by: sqliteText("executed_by"),
+ status: sqliteText("status", {
+ enum: ["pending", "executed", "failed"],
+ })
+ .notNull()
+ .default("pending"),
+ error: sqliteText("error"),
+ created_at: sqliteInteger("created_at").notNull(),
+ executed_at: sqliteInteger("executed_at"),
+ },
+ (table) => ({
+ messageIdIdx: sqliteIndex("idx_moderation_actions_message_id").on(
+ table.message_id,
+ ),
+ userIdIdx: sqliteIndex("idx_moderation_actions_user_id").on(table.user_id),
+ statusIdx: sqliteIndex("idx_moderation_actions_status").on(table.status),
+ guildStatusIdx: sqliteIndex("idx_moderation_actions_guild_status").on(
+ table.guild_id,
+ table.status,
+ table.created_at,
+ ),
+ }),
+);
+
+/**
+ * Retention Policies Table (PostgreSQL)
+ * Defines data retention rules per guild/channel
+ */
+export const pgRetentionPoliciesTable = pgTable(
+ "retention_policies",
+ {
+ id: pgText("id").primaryKey(),
+ guild_id: pgText("guild_id").notNull(),
+ channel_id: pgText("channel_id"),
+ retention_days: pgInteger("retention_days").notNull().default(90),
+ apply_to_media: pgBoolean("apply_to_media").notNull().default(true),
+ apply_to_voice: pgBoolean("apply_to_voice").notNull().default(true),
+ enabled: pgBoolean("enabled").notNull().default(true),
+ created_at: pgBigint("created_at", { mode: "number" }).notNull(),
+ updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
+ },
+ (table) => ({
+ guildIdIdx: pgIndex("idx_retention_policies_guild_id").on(table.guild_id),
+ enabledIdx: pgIndex("idx_retention_policies_enabled").on(table.enabled),
+ }),
+);
+
+/**
+ * Retention Policies Table (SQLite)
+ * Defines data retention rules per guild/channel
+ */
+export const sqliteRetentionPoliciesTable = sqliteTable(
+ "retention_policies",
+ {
+ id: sqliteText("id").primaryKey(),
+ guild_id: sqliteText("guild_id").notNull(),
+ channel_id: sqliteText("channel_id"),
+ retention_days: sqliteInteger("retention_days").notNull().default(90),
+ apply_to_media: sqliteInteger("apply_to_media", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ apply_to_voice: sqliteInteger("apply_to_voice", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ enabled: sqliteInteger("enabled", { mode: "boolean" })
+ .notNull()
+ .default(true),
+ created_at: sqliteInteger("created_at").notNull(),
+ updated_at: sqliteInteger("updated_at").notNull(),
+ },
+ (table) => ({
+ guildIdIdx: sqliteIndex("idx_retention_policies_guild_id").on(
+ table.guild_id,
+ ),
+ enabledIdx: sqliteIndex("idx_retention_policies_enabled").on(table.enabled),
+ }),
+);
+
// Runtime table selection based on config
// ========================================
@@ -477,6 +702,21 @@ export const voiceRecordingsTable =
? pgVoiceRecordingsTable
: sqliteVoiceRecordingsTable;
+export const messageReviewsTable =
+ config.DATABASE_TYPE === "postgres"
+ ? pgMessageReviewsTable
+ : sqliteMessageReviewsTable;
+
+export const moderationActionsTable =
+ config.DATABASE_TYPE === "postgres"
+ ? pgModerationActionsTable
+ : sqliteModerationActionsTable;
+
+export const retentionPoliciesTable =
+ config.DATABASE_TYPE === "postgres"
+ ? pgRetentionPoliciesTable
+ : sqliteRetentionPoliciesTable;
+
// Export table types for use in queries
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
@@ -495,3 +735,12 @@ export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
+
+export type MessageReview = typeof messageReviewsTable.$inferSelect;
+export type MessageReviewInsert = typeof messageReviewsTable.$inferInsert;
+
+export type ModerationAction = typeof moderationActionsTable.$inferSelect;
+export type ModerationActionInsert = typeof moderationActionsTable.$inferInsert;
+
+export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
+export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
diff --git a/src/http/app.ts b/src/http/app.ts
index ce78485..4718d9e 100644
--- a/src/http/app.ts
+++ b/src/http/app.ts
@@ -16,6 +16,7 @@ import { createAnalyticsRoutes } from "../routes/analyticsRoutes.js";
import { createMediaRoutes } from "../routes/mediaRoutes.js";
import { createMessageRoutes } from "../routes/messageRoutes.js";
import { createRecordingsRoutes } from "../routes/recordingsRoutes.js";
+import { createReviewRoutes } from "../routes/reviewRoutes.js";
import { createSyncRoutes } from "../routes/syncRoutes.js";
import { createUIStateRoutes } from "../routes/uiStateRoutes.js";
import { createVoiceRoutes } from "../routes/voiceRoutes.js";
@@ -114,6 +115,7 @@ export function createHttpApp(options: CreateHttpAppOptions) {
);
app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes());
+ app.use("/api", createReviewRoutes());
app.use("/api", createAnalyticsRoutes());
app.use("/api", createSyncRoutes(options.client));
app.use("/api", createRecordingsRoutes());
diff --git a/src/moderation/actionExecutor.ts b/src/moderation/actionExecutor.ts
new file mode 100644
index 0000000..e9f89b0
--- /dev/null
+++ b/src/moderation/actionExecutor.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts
index 0eebc41..3bc5f71 100644
--- a/src/moderation/aiAnalyzer.ts
+++ b/src/moderation/aiAnalyzer.ts
@@ -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,
},
diff --git a/src/moderation/autoDeleteManager.ts b/src/moderation/autoDeleteManager.ts
index 598c552..0ea90af 100644
--- a/src/moderation/autoDeleteManager.ts
+++ b/src/moderation/autoDeleteManager.ts
@@ -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 {
+ 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;
}
}
diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts
index 1c2c074..1737274 100644
--- a/src/moderation/llmModerationClient.ts
+++ b/src/moderation/llmModerationClient.ts
@@ -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 {
+ 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,
+): z.infer {
+ 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": [],
"score": ,
+ "categories": [],
+ "severity": "none" | "low" | "medium" | "high" | "critical",
+ "confidence": ,
+ "recommended_action": "none" | "monitor" | "warn" | "review" | "delete" | "escalate",
+ "policy_version": "default-2026-05-30",
+ "evidence": [],
"analysis": ""
}
]
@@ -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: [],
}));
}
diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts
index 206ebfc..a3c6ce5 100644
--- a/src/moderation/messageStore.ts
+++ b/src/moderation/messageStore.ts
@@ -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(
rows: unknown[],
limit: number,
-): PageResult {
+): PageResult {
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 {
+ return pageRows(rows, limit);
+}
+
export { decodeCursor, encodeCursor } from "./pagination.js";
export async function insertMessage(message: MessageRecord): Promise {
@@ -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,
+): Promise {
+ 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>(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 {
+ 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> {
+ 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(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>,
+): Promise {
+ 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,
+): Promise {
+ 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>(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 {
+ 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> {
+ 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(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>,
+): Promise {
+ 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 {
+ 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,
+): Promise {
+ 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>(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 {
+ 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;
+ }
+}
diff --git a/src/moderation/retentionManager.ts b/src/moderation/retentionManager.ts
new file mode 100644
index 0000000..98cdd1d
--- /dev/null
+++ b/src/moderation/retentionManager.ts
@@ -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 {
+ 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;
+}
diff --git a/src/moderation/types.ts b/src/moderation/types.ts
index b8cdd54..8f87128 100644
--- a/src/moderation/types.ts
+++ b/src/moderation/types.ts
@@ -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;
+}
diff --git a/src/routes/reviewRoutes.ts b/src/routes/reviewRoutes.ts
new file mode 100644
index 0000000..6ab2bcb
--- /dev/null
+++ b/src/routes/reviewRoutes.ts
@@ -0,0 +1,268 @@
+import type { Router } from "express";
+import express from "express";
+import { AppError } from "../errors.js";
+import {
+ createMessageReview,
+ createModerationAction,
+ getMessageReview,
+ getModerationAction,
+ listMessageReviews,
+ listModerationActions,
+ updateMessageReview,
+ updateModerationAction,
+} from "../moderation/messageStore.js";
+import type {
+ MessageReview,
+ ModerationAction,
+ ReviewStatus,
+} from "../moderation/types.js";
+
+function parseLimit(value?: string): number {
+ return Math.max(1, Math.min(value ? parseInt(value) : 50, 100));
+}
+
+function parseStatuses(value?: string): string[] | undefined {
+ if (!value) return undefined;
+ return value.split(",").filter((s) => s.length > 0);
+}
+
+export function createReviewRoutes(): Router {
+ const router = express.Router();
+
+ // Message Reviews
+ // ===============
+
+ // GET /api/reviews - List message reviews
+ router.get("/reviews", async (req, res, next) => {
+ try {
+ const { guildId, channelId, status, cursor, limit } = req.query as {
+ guildId?: string;
+ channelId?: string;
+ status?: string;
+ cursor?: string;
+ limit?: string;
+ };
+
+ const result = await listMessageReviews({
+ guildId,
+ channelId,
+ status: parseStatuses(status),
+ cursor,
+ limit: parseLimit(limit),
+ });
+
+ res.json(result);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // GET /api/reviews/:id - Get a specific review
+ router.get("/reviews/:id", async (req, res, next) => {
+ try {
+ const review = await getMessageReview(req.params.id);
+ if (!review) {
+ throw new AppError("Review not found", "REVIEW_NOT_FOUND", 404);
+ }
+ res.json(review);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // POST /api/reviews - Create a new review
+ router.post("/reviews", async (req, res, next) => {
+ try {
+ const { message_id, guild_id, channel_id, reviewer_id, status, notes } =
+ req.body as {
+ message_id: string;
+ guild_id: string;
+ channel_id: string;
+ reviewer_id?: string;
+ status?: ReviewStatus;
+ notes?: string;
+ };
+
+ if (!message_id || !guild_id || !channel_id) {
+ throw new AppError(
+ "message_id, guild_id, and channel_id are required",
+ "MISSING_REVIEW_FIELDS",
+ 400,
+ );
+ }
+
+ const review = await createMessageReview({
+ message_id,
+ guild_id,
+ channel_id,
+ reviewer_id: reviewer_id || null,
+ status: status || "pending",
+ notes: notes || null,
+ reviewed_at: null,
+ });
+
+ res.status(201).json(review);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // PATCH /api/reviews/:id - Update a review
+ router.patch("/reviews/:id", async (req, res, next) => {
+ try {
+ const { status, notes, reviewer_id } = req.body as {
+ status?: ReviewStatus;
+ notes?: string;
+ reviewer_id?: string;
+ };
+
+ const updates: Partial = {};
+ if (status) updates.status = status;
+ if (notes !== undefined) updates.notes = notes;
+ if (reviewer_id !== undefined) updates.reviewer_id = reviewer_id;
+ if (status && status !== "pending") {
+ updates.reviewed_at = Date.now();
+ }
+
+ const review = await updateMessageReview(req.params.id, updates);
+ if (!review) {
+ throw new AppError("Review not found", "REVIEW_NOT_FOUND", 404);
+ }
+
+ res.json(review);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // Moderation Actions
+ // ==================
+
+ // GET /api/actions - List moderation actions
+ router.get("/actions", async (req, res, next) => {
+ try {
+ const { guildId, status, cursor, limit } = req.query as {
+ guildId?: string;
+ status?: string;
+ cursor?: string;
+ limit?: string;
+ };
+
+ const result = await listModerationActions({
+ guildId,
+ status: parseStatuses(status),
+ cursor,
+ limit: parseLimit(limit),
+ });
+
+ res.json(result);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // GET /api/actions/:id - Get a specific action
+ router.get("/actions/:id", async (req, res, next) => {
+ try {
+ const action = await getModerationAction(req.params.id);
+ if (!action) {
+ throw new AppError("Action not found", "ACTION_NOT_FOUND", 404);
+ }
+ res.json(action);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // POST /api/actions - Create a new moderation action
+ router.post("/actions", async (req, res, next) => {
+ try {
+ const {
+ message_id,
+ user_id,
+ guild_id,
+ action_type,
+ reason,
+ executed_by,
+ } = req.body as {
+ message_id?: string;
+ user_id?: string;
+ guild_id: string;
+ action_type: string;
+ reason?: string;
+ executed_by?: string;
+ };
+
+ if (!guild_id || !action_type) {
+ throw new AppError(
+ "guild_id and action_type are required",
+ "MISSING_ACTION_FIELDS",
+ 400,
+ );
+ }
+
+ const validTypes = [
+ "delete_message",
+ "mute_user",
+ "warn_user",
+ "kick_user",
+ "ban_user",
+ ];
+ if (!validTypes.includes(action_type)) {
+ throw new AppError(
+ `Invalid action_type. Must be one of: ${validTypes.join(", ")}`,
+ "INVALID_ACTION_TYPE",
+ 400,
+ );
+ }
+
+ const action = await createModerationAction({
+ message_id: message_id || null,
+ user_id: user_id || null,
+ guild_id,
+ action_type: action_type as any,
+ reason: reason || null,
+ executed_by: executed_by || null,
+ status: "pending",
+ error: null,
+ executed_at: null,
+ });
+
+ res.status(201).json(action);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ // PATCH /api/actions/:id - Update an action
+ router.patch("/actions/:id", async (req, res, next) => {
+ try {
+ const { status, error, executed_by } = req.body as {
+ status?: "pending" | "executed" | "failed";
+ error?: string;
+ executed_by?: string;
+ };
+
+ const updates: Partial = {};
+ if (status) {
+ updates.status = status;
+ if (status === "executed") {
+ updates.executed_at = Date.now();
+ }
+ }
+ if (error !== undefined) updates.error = error;
+ if (executed_by !== undefined) updates.executed_by = executed_by;
+
+ const action = await updateModerationAction(req.params.id, updates);
+ if (!action) {
+ throw new AppError("Action not found", "ACTION_NOT_FOUND", 404);
+ }
+
+ res.json(action);
+ } catch (error) {
+ next(error);
+ }
+ });
+
+ return router;
+}