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