Merge branch 'worktree-agent-acd4fe7096dc8d6e4'

This commit is contained in:
MythEclipse
2026-05-30 01:04:43 +07:00
15 changed files with 2104 additions and 27 deletions
+14
View File
@@ -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;
}
@@ -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) {
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{displayContent || "(empty message)"}
</p>
<div className="flex flex-wrap gap-2 text-xs">
{message.ai_severity ? <Badge variant="outline">severity: {message.ai_severity}</Badge> : null}
{message.ai_recommended_action ? <Badge variant="outline">action: {message.ai_recommended_action}</Badge> : null}
{confidence != null ? <Badge variant="outline">confidence: {Math.round(confidence * 100)}%</Badge> : null}
{message.ai_policy_version ? <Badge variant="outline">policy: {message.ai_policy_version}</Badge> : null}
{categories.slice(0, 6).map((category) => (
<Badge key={category} variant="secondary">{category}</Badge>
))}
</div>
{message.ai_analysis ? (
<div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">
{message.ai_analysis}
</div>
) : null}
{evidence.length > 0 ? (
<div className="rounded-xl border border-border bg-background/50 p-3 text-xs text-muted-foreground">
<div className="mb-1 font-medium text-foreground/80">Evidence</div>
<ul className="list-disc space-y-1 pl-4">
{evidence.slice(0, 4).map((item, index) => (
<li key={`${message.id}-evidence-${index}`}>{item}</li>
))}
</ul>
</div>
) : null}
{message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
AI error: {message.ai_error}
+162 -15
View File
@@ -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<ReviewStatus | null>(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 (
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">AI Eval Decision</span>
{savedStatus ? <Badge variant="success">saved: {savedStatus}</Badge> : null}
</div>
<div className="grid gap-2 md:grid-cols-[160px_1fr]">
<Input
value={reviewerId}
onChange={(event) => setReviewerId(event.target.value)}
placeholder="reviewer label"
/>
<Input
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="reason / evaluation note"
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("approved")}>
Approve AI
</Button>
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("rejected")}>
False Positive + Reanalyze
</Button>
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("escalated")}>
Escalate
</Button>
</div>
{error ? <div className="mt-2 text-xs text-destructive">{error}</div> : null}
</div>
);
}
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const flaggedItems = messages.filter(
const [statusFilter, setStatusFilter] = useState<ReviewFilter>("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<string>();
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) => (
<div className="space-y-3">
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
items.map((message) => (
<div key={message.id} className="space-y-2">
<MessageCard message={message} onReanalyze={onReanalyze} />
<ReviewDecisionControls message={message} onReanalyze={onReanalyze} />
</div>
))
)}
</div>
);
return (
<Card>
<CardHeader>
<CardTitle>Needs Review</CardTitle>
<CardTitle>Moderation Review & AI Eval</CardTitle>
<CardDescription>
{flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
Public AI evaluation queue: {reviewable.length} reviewable messages, {errorItems.length} analysis errors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 grid gap-2 md:grid-cols-3">
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as ReviewFilter)}
options={statusOptions}
/>
<Select
value={severityFilter}
onChange={(event) => setSeverityFilter(event.target.value)}
placeholder="All severities"
options={["none", "low", "medium", "high", "critical"].map((severity) => ({ value: severity, label: severity }))}
/>
<Select
value={categoryFilter}
onChange={(event) => setCategoryFilter(event.target.value)}
placeholder="All categories"
options={categories.map((category) => ({ value: category, label: category }))}
/>
</div>
<Tabs defaultValue="flags">
<TabsList>
<TabsTrigger value="flags">Flags ({flaggedItems.length})</TabsTrigger>
<TabsTrigger value="errors">Errors ({errorItems.length})</TabsTrigger>
</TabsList>
<TabsContent value="flags">
<MessageFeed
messages={flaggedItems}
onReanalyze={onReanalyze}
emptyText="No warned or flagged messages."
/>
{renderList(flaggedItems, "No warned or flagged messages match the filters.")}
</TabsContent>
<TabsContent value="errors">
<MessageFeed
messages={errorItems}
onReanalyze={onReanalyze}
emptyText="No analysis errors."
/>
{renderList(errorItems, "No analysis errors match the filters.")}
</TabsContent>
</Tabs>
</CardContent>
+221
View File
@@ -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<T> {
data: T[];
nextCursor: string | null;
}
export function useReview() {
const [reviews, setReviews] = useState<MessageReview[]>([]);
const [actions, setActions] = useState<ModerationAction[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(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<MessageReview>;
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<MessageReview, "id" | "created_at">) => {
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<Omit<MessageReview, "id" | "created_at">>,
) => {
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<ReviewQuery, "channelId">) => {
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<ModerationAction>;
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<ModerationAction, "id" | "created_at">,
) => {
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<Omit<ModerationAction, "id" | "created_at">>,
) => {
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,
};
}
+18 -1
View File
@@ -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"),
+249
View File
@@ -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;
+2
View File
@@ -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());
+303
View File
@@ -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;
}
+12
View File
@@ -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,
},
+125 -5
View File
@@ -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;
}
}
+84 -2
View File
@@ -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.01.0>,
"categories": [<kategori kebijakan, kosong jika clean>],
"severity": "none" | "low" | "medium" | "high" | "critical",
"confidence": <float 0.01.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: [],
}));
}
+365 -4
View File
@@ -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;
}
}
+179
View File
@@ -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;
}
+67
View File
@@ -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;
}
+268
View File
@@ -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<MessageReview> = {};
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<ModerationAction> = {};
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;
}