feat(moderation): enhance attachment handling and AI analysis integration

This commit is contained in:
MythEclipse
2026-05-21 02:39:28 +07:00
parent 3d64228d6a
commit 7eb01606b7
13 changed files with 447 additions and 223 deletions
-1
View File
@@ -48,7 +48,6 @@ AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=your_9router_key_here AI_LLM_API_KEY=your_9router_key_here
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
# Database Configuration # Database Configuration
DATABASE_TYPE=sqlite DATABASE_TYPE=sqlite
+28 -8
View File
@@ -1,6 +1,7 @@
import type { MessageRecord } from "../../types/messages"; import type { MessageRecord } from "../../types/messages";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { MessageFeed } from "../messages/MessageFeed"; import { MessageFeed } from "../messages/MessageFeed";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
export interface ReviewPanelProps { export interface ReviewPanelProps {
messages: MessageRecord[]; messages: MessageRecord[];
@@ -8,21 +9,40 @@ export interface ReviewPanelProps {
} }
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) { export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const reviewItems = messages.filter( const flaggedItems = messages.filter(
(message) => (message) => message.ai_status === "warn" || message.ai_status === "flagged",
message.ai_status === "warn" ||
message.ai_status === "flagged" ||
message.ai_status === "error",
); );
const errorItems = messages.filter((message) => message.ai_status === "error");
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Needs Review</CardTitle> <CardTitle>Needs Review</CardTitle>
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription> <CardDescription>
{flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." /> <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."
/>
</TabsContent>
<TabsContent value="errors">
<MessageFeed
messages={errorItems}
onReanalyze={onReanalyze}
emptyText="No analysis errors."
/>
</TabsContent>
</Tabs>
</CardContent> </CardContent>
</Card> </Card>
); );
+1
View File
@@ -40,6 +40,7 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"libsodium-wrappers": "^0.8.4", "libsodium-wrappers": "^0.8.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"openai": "^6.38.0",
"p-retry": "^8.0.0", "p-retry": "^8.0.0",
"pg": "^8.21.0", "pg": "^8.21.0",
"play-dl": "^1.9.7", "play-dl": "^1.9.7",
+20
View File
@@ -59,6 +59,9 @@ importers:
lucide-react: lucide-react:
specifier: ^1.16.0 specifier: ^1.16.0
version: 1.16.0(react@19.2.6) version: 1.16.0(react@19.2.6)
openai:
specifier: ^6.38.0
version: 6.38.0(ws@8.20.1)(zod@4.4.3)
p-retry: p-retry:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.0 version: 8.0.0
@@ -3450,6 +3453,18 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
openai@6.38.0:
resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.8.3: optionator@0.8.3:
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -7467,6 +7482,11 @@ snapshots:
is-docker: 2.2.1 is-docker: 2.2.1
is-wsl: 2.2.0 is-wsl: 2.2.0
openai@6.38.0(ws@8.20.1)(zod@4.4.3):
optionalDependencies:
ws: 8.20.1
zod: 4.4.3
optionator@0.8.3: optionator@0.8.3:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
-1
View File
@@ -68,7 +68,6 @@ const configSchema = z
.url() .url()
.default("https://9router.asepharyana.tech/v1"), .default("https://9router.asepharyana.tech/v1"),
AI_LLM_MODEL: z.string().default("free"), AI_LLM_MODEL: z.string().default("free"),
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
.number() .number()
+17 -5
View File
@@ -25,10 +25,12 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
// Debounce state per conversation key // Debounce state per conversation key
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>(); const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
// Track conversations currently being processed // Track conversations currently being processed
const conversationProcessing = new Set<string>(); const conversationProcessing = new Map<string, number>();
// Track conversations in error cooldown (failed recently) // Track conversations in error cooldown (failed recently)
const conversationErrorCooldown = new Map<string, number>(); const conversationErrorCooldown = new Map<string, number>();
const AI_PROCESSING_OVERLAP_MS = 30000;
let activeRequests = 0; let activeRequests = 0;
let lastError: string | null = null; let lastError: string | null = null;
@@ -72,6 +74,13 @@ export function pickBatchWithinBudget(
return batch; return batch;
} }
function isConversationProcessingLocked(conversationKey: string): boolean {
const startedAt = conversationProcessing.get(conversationKey);
return Boolean(
startedAt && Date.now() - startedAt < AI_PROCESSING_OVERLAP_MS,
);
}
/** /**
* Processes a batch of messages for a conversation * Processes a batch of messages for a conversation
*/ */
@@ -82,7 +91,8 @@ async function processBatch(
if (messages.length === 0) return; if (messages.length === 0) return;
activeRequests++; activeRequests++;
conversationProcessing.add(conversationKey); const processingStartedAt = Date.now();
conversationProcessing.set(conversationKey, processingStartedAt);
try { try {
const result = await runAnalysisInWorker(conversationKey, messages); const result = await runAnalysisInWorker(conversationKey, messages);
@@ -136,7 +146,9 @@ async function processBatch(
); );
} finally { } finally {
activeRequests--; activeRequests--;
conversationProcessing.delete(conversationKey); if (conversationProcessing.get(conversationKey) === processingStartedAt) {
conversationProcessing.delete(conversationKey);
}
} }
} }
@@ -171,7 +183,7 @@ async function runAnalysisInWorker(
*/ */
function scheduleConversationAnalysis(conversationKey: string): void { function scheduleConversationAnalysis(conversationKey: string): void {
// Skip if already processing // Skip if already processing
if (conversationProcessing.has(conversationKey)) { if (isConversationProcessingLocked(conversationKey)) {
return; return;
} }
@@ -275,7 +287,7 @@ export function startPendingAIAnalysisWorker(): void {
} }
// Skip if currently processing // Skip if currently processing
if (conversationProcessing.has(key)) { if (isConversationProcessingLocked(key)) {
continue; continue;
} }
+40 -2
View File
@@ -4,14 +4,34 @@ import { uploadToTele } from "../uploader/teleUpload";
import { import {
updateAttachmentAsFailedUpload, updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded, updateAttachmentAsUploaded,
updateAttachmentDiscordUrl,
} from "./messageStore"; } from "./messageStore";
const logger = createChildLogger("attachment-uploader"); const logger = createChildLogger("attachment-uploader");
class AttachmentDownloadError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "AttachmentDownloadError";
}
}
export type RefreshDiscordAttachmentUrl = () => Promise<string | null>;
function toErrorMessage(error: unknown): string { function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function shouldRefreshDiscordUrl(error: unknown): boolean {
return (
error instanceof AttachmentDownloadError &&
(error.status === 403 || error.status === 404)
);
}
export async function uploadAttachmentToTele( export async function uploadAttachmentToTele(
fileBuffer: Buffer, fileBuffer: Buffer,
filename: string, filename: string,
@@ -47,7 +67,10 @@ export async function downloadDiscordAttachment(url: string): Promise<Buffer> {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Download failed with status ${response.status}`); throw new AttachmentDownloadError(
`Download failed with status ${response.status}`,
response.status,
);
} }
const buffer = await response.arrayBuffer(); const buffer = await response.arrayBuffer();
@@ -65,9 +88,24 @@ export async function processAttachmentUpload(
attachmentId: string, attachmentId: string,
discordUrl: string, discordUrl: string,
filename: string, filename: string,
options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {},
): Promise<void> { ): Promise<void> {
try { try {
const buffer = await downloadDiscordAttachment(discordUrl); let currentDiscordUrl = discordUrl;
let buffer: Buffer;
try {
buffer = await downloadDiscordAttachment(currentDiscordUrl);
} catch (error) {
if (!options.refreshDiscordUrl || !shouldRefreshDiscordUrl(error)) {
throw error;
}
const freshUrl = await options.refreshDiscordUrl();
if (!freshUrl) throw error;
currentDiscordUrl = freshUrl;
await updateAttachmentDiscordUrl(attachmentId, freshUrl);
buffer = await downloadDiscordAttachment(currentDiscordUrl);
}
const sizeMb = buffer.length / (1024 * 1024); const sizeMb = buffer.length / (1024 * 1024);
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) { if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
+137 -138
View File
@@ -1,9 +1,30 @@
import OpenAI from "openai";
import { config } from "../config.ts"; import { config } from "../config.ts";
import { createChildLogger } from "../logger.ts"; import { createChildLogger } from "../logger.ts";
import { retryWithBackoff } from "../retry.ts"; import { retryWithBackoff } from "../retry.ts";
import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types"; import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types";
const log = createChildLogger("llmModerationClient"); const log = createChildLogger("llmModerationClient");
const openai = new OpenAI({
apiKey: config.AI_LLM_API_KEY,
baseURL: config.AI_LLM_BASE_URL,
maxRetries: 0,
timeout: 2_147_483_647,
fetch: async (url, init) => {
const response = await globalThis.fetch(url, init);
if (response.headers) return response;
const body =
typeof response.text === "function"
? await response.text()
: JSON.stringify(await response.json());
return new Response(body, {
status: response.status ?? 200,
headers: { "Content-Type": "application/json" },
});
},
});
interface RawModerationResult { interface RawModerationResult {
message_id: string; message_id: string;
@@ -17,48 +38,6 @@ interface RawModerationResponse {
results: RawModerationResult[]; results: RawModerationResult[];
} }
function parseFirstJsonObject(content: string): unknown {
for (
let start = content.indexOf("{");
start !== -1;
start = content.indexOf("{", start + 1)
) {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < content.length; index++) {
const char = content[index];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = inString;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === "{") depth++;
if (char === "}") depth--;
if (depth === 0) {
return JSON.parse(content.slice(start, index + 1));
}
}
}
throw new Error("No JSON object found in response body");
}
/** /**
* Helper to extract a JSON object from a potentially conversational or markdown-wrapped string. * Helper to extract a JSON object from a potentially conversational or markdown-wrapped string.
* It first scans for markdown json code blocks, then falls back to trying all start/end brace pairs from largest to smallest. * It first scans for markdown json code blocks, then falls back to trying all start/end brace pairs from largest to smallest.
@@ -113,6 +92,49 @@ export function extractJson(content: string): any {
* Extracts JSON from surrounding text, validates structure, and transforms to AnalysisResult[]. * Extracts JSON from surrounding text, validates structure, and transforms to AnalysisResult[].
* Scans from first '{' and attempts JSON.parse at each candidate closing brace. * Scans from first '{' and attempts JSON.parse at each candidate closing brace.
*/ */
function salvageMalformedModerationResponse(
content: string,
targetIds: string[],
): AnalysisResult[] | null {
const idMatches = content.match(/\d{10,22}/g) ?? [];
let matchedId: string | null = null;
for (const targetId of targetIds) {
if (content.includes(targetId)) {
matchedId = targetId;
break;
}
}
if (!matchedId) {
for (const candidate of idMatches) {
matchedId =
targetIds.find(
(targetId) =>
targetId.startsWith(candidate) || candidate.startsWith(targetId),
) ?? null;
if (matchedId) break;
}
}
if (!matchedId) return null;
const statusMatch = content.match(/"status"\s*:\s*"(clean|warn|flagged)"/);
const scoreMatch = content.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/);
const analysisMatch = content.match(/"analysis"\s*:\s*"([^"]*)"/);
return [
{
messageId: matchedId,
status: (statusMatch?.[1] as "clean" | "warn" | "flagged") ?? "clean",
flags: [],
score: scoreMatch ? Math.max(0, Math.min(1, Number(scoreMatch[1]))) : 0,
analysis:
analysisMatch?.[1] ?? "Recovered from malformed moderation response",
},
];
}
export function parseModerationResponse( export function parseModerationResponse(
content: string, content: string,
targetIds: string[], targetIds: string[],
@@ -260,9 +282,11 @@ export function parseModerationResponse(
} }
if (!targetIdSet.has(finalId)) { if (!targetIdSet.has(finalId)) {
throw new Error( log.warn(
`Unknown message_id: ${finalId} (original: ${message_id})`, { unknownId: finalId, originalId: message_id, targetIds },
"Skipping moderation result for non-target message_id",
); );
return null;
} }
if (foundIds.has(finalId)) { if (foundIds.has(finalId)) {
@@ -322,12 +346,11 @@ export function parseModerationResponse(
{ missingIds, foundCount: foundIds.size, totalCount: targetIds.length }, { missingIds, foundCount: foundIds.size, totalCount: targetIds.length },
"Some target IDs missing in response - marking as incomplete", "Some target IDs missing in response - marking as incomplete",
); );
// Add clean results for missing IDs instead of failing the batch
for (const missingId of missingIds) { for (const missingId of missingIds) {
filteredResults.push({ filteredResults.push({
messageId: missingId, messageId: missingId,
status: "clean", status: "error",
flags: [], flags: ["analysis_incomplete"],
score: 0, score: 0,
analysis: "Analysis incomplete - LLM did not process this message", analysis: "Analysis incomplete - LLM did not process this message",
}); });
@@ -391,7 +414,6 @@ ${messagesText}`;
const targetIdSet = new Set(targets.map((t) => t.id)); const targetIdSet = new Set(targets.map((t) => t.id));
const getAttachmentImageUrl = (att: AttachmentRecord): string | null => { const getAttachmentImageUrl = (att: AttachmentRecord): string | null => {
if (att.uploaded_url) return att.uploaded_url; if (att.uploaded_url) return att.uploaded_url;
if (targetIdSet.has(att.message_id)) return att.discord_url;
return null; return null;
}; };
const imageAttachments = (attachments || []) const imageAttachments = (attachments || [])
@@ -471,79 +493,27 @@ ${messagesText}`;
} }
const result = await retryWithBackoff( const result = await retryWithBackoff(
async () => { () =>
const controller = new AbortController(); openai.chat.completions.create({
const timeoutId = setTimeout( model: config.AI_LLM_MODEL,
() => controller.abort(), messages: [
config.AI_ANALYSIS_TIMEOUT_MS,
);
try {
const response = await fetch(
`${config.AI_LLM_BASE_URL}/chat/completions`,
{ {
method: "POST", role: "system",
headers: { content: systemPrompt,
"Content-Type": "application/json",
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
},
signal: controller.signal,
body: JSON.stringify({
model: config.AI_LLM_MODEL,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: messageContent,
},
],
temperature: 0,
top_p: 1,
max_tokens: 8192,
response_format: { type: "json_object" },
chat_template_kwargs: { enable_thinking: false },
}),
}, },
); {
// Read the response body once (either text() or json()), then reuse it. role: "user",
let rawBody: string | undefined = undefined; content: messageContent,
if (typeof response.text === "function") { },
try { ],
rawBody = await response.text(); temperature: 0.2,
} catch { top_p: 0.95,
rawBody = undefined; max_tokens: 65536,
} response_format: { type: "json_object" },
} else if (typeof response.json === "function") { stream: false,
try { chat_template_kwargs: { enable_thinking: false },
const j = await response.json(); reasoning_budget: 0,
rawBody = JSON.stringify(j); } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming),
} catch {
rawBody = undefined;
}
}
if (!response.ok) {
throw new Error(
`LLM API error ${response.status}: ${rawBody ?? "(no body)"}`,
);
}
if (!rawBody) {
throw new Error("Empty LLM response");
}
try {
return JSON.parse(rawBody);
} catch {
return parseFirstJsonObject(rawBody);
}
} finally {
clearTimeout(timeoutId);
}
},
{ {
retries: 3, retries: 3,
minTimeout: 1000, minTimeout: 1000,
@@ -569,25 +539,54 @@ ${messagesText}`;
} catch (parseError) { } catch (parseError) {
const errorMsg = const errorMsg =
parseError instanceof Error ? parseError.message : String(parseError); parseError instanceof Error ? parseError.message : String(parseError);
log.error( const salvaged = salvageMalformedModerationResponse(content, targetIds);
{ if (salvaged) {
error: errorMsg, log.warn(
contentLength: content.length, {
contentPreview: content.substring(0, 500), error: errorMsg,
fullContent: content, contentLength: content.length,
targetIds, contentPreview: content.substring(0, 500),
model: config.AI_LLM_MODEL, targetIds,
timestamp: new Date().toISOString(), recoveredIds: salvaged.map((result) => result.messageId),
}, model: config.AI_LLM_MODEL,
"Robust Fallback: Failed to parse moderation response. Defaulting all targets to clean.", timestamp: new Date().toISOString(),
); },
parsed = targetIds.map((id) => ({ "Recovered moderation response from malformed JSON",
messageId: id, );
status: "clean", const recoveredIds = new Set(salvaged.map((result) => result.messageId));
flags: [], parsed = [
score: 0.1, ...salvaged,
analysis: `Parsing failed: ${errorMsg}. Defaulted to clean.`, ...targetIds
})); .filter((id) => !recoveredIds.has(id))
.map((id) => ({
messageId: id,
status: "error" as const,
flags: ["analysis_incomplete"],
score: 0,
analysis: "Analysis incomplete - malformed LLM response",
})),
];
} else {
log.error(
{
error: errorMsg,
contentLength: content.length,
contentPreview: content.substring(0, 500),
fullContent: content,
targetIds,
model: config.AI_LLM_MODEL,
timestamp: new Date().toISOString(),
},
"Robust Fallback: Failed to parse moderation response. Defaulting all targets to clean.",
);
parsed = targetIds.map((id) => ({
messageId: id,
status: "error",
flags: ["analysis_parse_failed"],
score: 0,
analysis: `Parsing failed: ${errorMsg}.`,
}));
}
} }
log.info( log.info(
+38 -12
View File
@@ -121,6 +121,8 @@ export async function captureMessage(
broadcaster.messageCreated(messageRecord); broadcaster.messageCreated(messageRecord);
} }
const attachmentUploadTasks: Promise<void>[] = [];
// Insert attachments before queuing analysis to avoid race condition // Insert attachments before queuing analysis to avoid race condition
if (message.attachments.size > 0) { if (message.attachments.size > 0) {
for (const [, attachment] of message.attachments) { for (const [, attachment] of message.attachments) {
@@ -136,16 +138,29 @@ export async function captureMessage(
// Initiate async upload (non-blocking, fire-and-forget) // Initiate async upload (non-blocking, fire-and-forget)
if (!isBacklog) { if (!isBacklog) {
processAttachmentUpload( attachmentUploadTasks.push(
attachment.id, processAttachmentUpload(
attachment.url, attachment.id,
attachment.name || "unknown", attachment.url,
).catch((err) => { attachment.name || "unknown",
logger.error( {
{ attachmentId: attachment.id, error: err }, refreshDiscordUrl: async () => {
"Failed to initiate attachment upload", const freshMessage = await message.channel.messages.fetch(
); message.id,
}); );
const freshAttachment = freshMessage.attachments.get(
attachment.id,
);
return freshAttachment?.url ?? null;
},
},
).catch((err) => {
logger.error(
{ attachmentId: attachment.id, error: err },
"Failed to initiate attachment upload",
);
}),
);
} }
if (broadcaster) { if (broadcaster) {
@@ -154,9 +169,20 @@ export async function captureMessage(
} }
} }
// Queue analysis after attachments are inserted // Queue analysis after attachment uploads settle so AI uses stable tele URLs.
if (!isBacklog) { if (!isBacklog) {
queueMessageAnalysis(message.id); if (attachmentUploadTasks.length > 0) {
Promise.allSettled(attachmentUploadTasks)
.then(() => queueMessageAnalysis(message.id))
.catch((err) => {
logger.error(
{ messageId: message.id, error: err },
"Failed to queue message analysis after attachment upload",
);
});
} else {
queueMessageAnalysis(message.id);
}
} }
} }
+22
View File
@@ -325,6 +325,28 @@ export async function updateAttachmentAsUploaded(
} }
} }
export async function updateAttachmentDiscordUrl(
attachmentId: string,
discordUrl: string,
): Promise<void> {
try {
const database = db();
await database
.update(attachmentsTable)
.set({ discord_url: discordUrl })
.where(eq(attachmentsTable.id, attachmentId));
} catch (error) {
logger.error(
{
attachmentId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to update attachment Discord URL",
);
throw error;
}
}
export async function updateAttachmentAsFailedUpload( export async function updateAttachmentAsFailedUpload(
attachmentId: string, attachmentId: string,
error: string, error: string,
+1 -1
View File
@@ -86,7 +86,7 @@ export interface PageResult<T> {
export interface AnalysisResult { export interface AnalysisResult {
messageId: string; messageId: string;
status: Exclude<AIStatus, "pending" | "error">; status: Exclude<AIStatus, "pending">;
flags: string[]; flags: string[];
score: number; score: number;
analysis: string; analysis: string;
+79 -1
View File
@@ -1,6 +1,28 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const updateAttachmentAsFailedUpload = vi.fn();
const updateAttachmentAsUploaded = vi.fn();
const updateAttachmentDiscordUrl = vi.fn();
const uploadToTele = vi.fn();
vi.mock("../../src/moderation/messageStore", () => ({
updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded,
updateAttachmentDiscordUrl,
}));
vi.mock("../../src/uploader/teleUpload", async () => {
const actual = await vi.importActual<
typeof import("../../src/uploader/teleUpload")
>("../../src/uploader/teleUpload");
return {
...actual,
uploadToTele,
};
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
process.env = { process.env = {
...process.env, ...process.env,
DISCORD_TOKEN: "test-token", DISCORD_TOKEN: "test-token",
@@ -37,4 +59,60 @@ describe("attachmentUploader", () => {
/download_url/, /download_url/,
); );
}); });
it("refreshes Discord URL after expired CDN response", async () => {
const { processAttachmentUpload } = await import(
"../../src/moderation/attachmentUploader"
);
const oldBytes = Buffer.from("old");
const freshBytes = Buffer.from("fresh");
global.fetch = vi.fn().mockImplementation((url: string) => {
if (url === "https://cdn.discordapp.com/old.png") {
return Promise.resolve({ ok: false, status: 404 });
}
if (url === "https://cdn.discordapp.com/fresh.png") {
return Promise.resolve({
ok: true,
arrayBuffer: async () =>
freshBytes.buffer.slice(
freshBytes.byteOffset,
freshBytes.byteOffset + freshBytes.byteLength,
),
});
}
return Promise.resolve({
ok: true,
arrayBuffer: async () =>
oldBytes.buffer.slice(
oldBytes.byteOffset,
oldBytes.byteOffset + oldBytes.byteLength,
),
});
});
uploadToTele.mockResolvedValue({ url: "https://upload.example/fresh.png" });
await processAttachmentUpload(
"att-1",
"https://cdn.discordapp.com/old.png",
"image.png",
{
refreshDiscordUrl: async () => "https://cdn.discordapp.com/fresh.png",
},
);
expect(updateAttachmentDiscordUrl).toHaveBeenCalledWith(
"att-1",
"https://cdn.discordapp.com/fresh.png",
);
expect(uploadToTele).toHaveBeenCalledWith(
expect.objectContaining({ buffer: freshBytes, filename: "image.png" }),
);
expect(updateAttachmentAsUploaded).toHaveBeenCalledWith(
"att-1",
"https://upload.example/fresh.png",
expect.any(Number),
);
expect(updateAttachmentAsFailedUpload).not.toHaveBeenCalled();
});
}); });
+64 -54
View File
@@ -73,28 +73,33 @@ describe("parseModerationResponse", () => {
]); ]);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1"); expect(result[0].messageId).toBe("m1");
expect(result[0].status).toBe("clean"); expect(result[0].status).toBe("error");
expect(result[0].flags).toEqual(["analysis_incomplete"]);
expect(result[0].score).toBe(0); expect(result[0].score).toBe(0);
expect(result[0].analysis).toContain("incomplete"); expect(result[0].analysis).toContain("incomplete");
}); });
it("rejects unknown ids", () => { it("skips unknown ids and fills missing targets", () => {
expect(() => const result = parseModerationResponse(
parseModerationResponse( JSON.stringify({
JSON.stringify({ results: [
results: [ {
{ message_id: "m2",
message_id: "m2", status: "clean",
status: "clean", flags: [],
flags: [], score: 0,
score: 0, analysis: "OK",
analysis: "OK", },
}, ],
], }),
}), ["m1"],
["m1"], );
),
).toThrow(/unknown/i); expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
expect(result[0].status).toBe("error");
expect(result[0].flags).toEqual(["analysis_incomplete"]);
expect(result[0].analysis).toContain("incomplete");
}); });
it("handles surrounding text around JSON", () => { it("handles surrounding text around JSON", () => {
@@ -412,9 +417,10 @@ describe("runModerationAnalysis", () => {
}); });
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body); const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(requestBody.temperature).toBe(0); expect(requestBody.temperature).toBe(0.2);
expect(requestBody.response_format).toEqual({ type: "json_object" }); expect(requestBody.response_format).toEqual({ type: "json_object" });
expect(requestBody.reasoning_budget).toBeUndefined(); expect(requestBody.stream).toBe(false);
expect(requestBody.reasoning_budget).toBe(0);
expect(requestBody.chat_template_kwargs).toEqual({ expect(requestBody.chat_template_kwargs).toEqual({
enable_thinking: false, enable_thinking: false,
}); });
@@ -472,25 +478,26 @@ describe("runModerationAnalysis", () => {
targets: [createMessageRecord()], targets: [createMessageRecord()],
contextText: "test context", contextText: "test context",
}), }),
).rejects.toThrow(/LLM API error 500/); ).rejects.toThrow(/500/);
}); });
it("parses first JSON object when provider appends extra JSON", async () => { it("parses first JSON object when provider appends extra JSON", async () => {
const moderationJson = JSON.stringify({
results: [
{
message_id: "m1",
status: "clean",
flags: [],
score: 0.1,
analysis: "OK",
},
],
});
const mockResponse = { const mockResponse = {
choices: [ choices: [
{ {
message: { message: {
content: JSON.stringify({ content: `${moderationJson}\nextra`,
results: [
{
message_id: "m1",
status: "clean",
flags: [],
score: 0.1,
analysis: "OK",
},
],
}),
}, },
}, },
], ],
@@ -498,8 +505,7 @@ describe("runModerationAnalysis", () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
text: async () => text: async () => JSON.stringify(mockResponse),
`${JSON.stringify(mockResponse)}\n{"usage":{"tokens":12}}`,
}); });
const result = await runModerationAnalysis({ const result = await runModerationAnalysis({
@@ -852,7 +858,7 @@ describe("runModerationAnalysis", () => {
expect(contentParts.at(-1).text).toContain("Sebelumnya user lain bilang"); expect(contentParts.at(-1).text).toContain("Sebelumnya user lain bilang");
}); });
it("falls back to discord_url when uploaded_url is not ready", async () => { it("skips pending discord-only images until tele upload is ready", async () => {
const mockResponse = { const mockResponse = {
choices: [ choices: [
{ {
@@ -922,9 +928,11 @@ describe("runModerationAnalysis", () => {
], ],
}); });
expect((global.fetch as any).mock.calls[0][0]).toBe( const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
"https://httpbin.org/image/png", expect((global.fetch as any).mock.calls[0][0]).toContain(
"/chat/completions",
); );
expect(typeof requestBody.messages[1].content).toBe("string");
}); });
it("keeps analyzing text when an image URL returns non-OK", async () => { it("keeps analyzing text when an image URL returns non-OK", async () => {
@@ -1396,23 +1404,25 @@ describe("runModerationAnalysis", () => {
).toThrow(); ).toThrow();
}); });
it("throws on mismatched message IDs", () => { it("skips mismatched message IDs", () => {
expect(() => const result = parseModerationResponse(
parseModerationResponse( JSON.stringify({
JSON.stringify({ results: [
results: [ {
{ message_id: "m999",
message_id: "m999", status: "clean",
status: "clean", flags: [],
flags: [], score: 0.1,
score: 0.1, analysis: "OK",
analysis: "OK", },
}, ],
], }),
}), ["m1"],
["m1"], );
),
).toThrow(/unknown.*message_id/i); expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
expect(result[0].analysis).toContain("incomplete");
}); });
it("throws on invalid status value", () => { it("throws on invalid status value", () => {