diff --git a/.env.example b/.env.example
index 7f47c3f..f96d35c 100644
--- a/.env.example
+++ b/.env.example
@@ -48,7 +48,6 @@ AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=your_9router_key_here
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
-AI_ANALYSIS_TIMEOUT_MS=30000
# Database Configuration
DATABASE_TYPE=sqlite
diff --git a/frontend/src/components/review/ReviewPanel.tsx b/frontend/src/components/review/ReviewPanel.tsx
index 6ac0682..5ecaad3 100644
--- a/frontend/src/components/review/ReviewPanel.tsx
+++ b/frontend/src/components/review/ReviewPanel.tsx
@@ -1,6 +1,7 @@
import type { MessageRecord } from "../../types/messages";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
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 {
messages: MessageRecord[];
@@ -8,21 +9,40 @@ export interface ReviewPanelProps {
}
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
- const reviewItems = messages.filter(
- (message) =>
- message.ai_status === "warn" ||
- message.ai_status === "flagged" ||
- message.ai_status === "error",
+ const flaggedItems = messages.filter(
+ (message) => message.ai_status === "warn" || message.ai_status === "flagged",
);
+ const errorItems = messages.filter((message) => message.ai_status === "error");
return (
Needs Review
- {reviewItems.length} captured messages require attention.
+
+ {flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
+
-
+
+
+ Flags ({flaggedItems.length})
+ Errors ({errorItems.length})
+
+
+
+
+
+
+
+
);
diff --git a/package.json b/package.json
index d38a2f6..3ff5655 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"helmet": "^8.1.0",
"libsodium-wrappers": "^0.8.4",
"lucide-react": "^1.16.0",
+ "openai": "^6.38.0",
"p-retry": "^8.0.0",
"pg": "^8.21.0",
"play-dl": "^1.9.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4ad39b6..5ebdadd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,9 @@ importers:
lucide-react:
specifier: ^1.16.0
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:
specifier: ^8.0.0
version: 8.0.0
@@ -3450,6 +3453,18 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
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:
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
engines: {node: '>= 0.8.0'}
@@ -7467,6 +7482,11 @@ snapshots:
is-docker: 2.2.1
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:
dependencies:
deep-is: 0.1.4
diff --git a/src/config.ts b/src/config.ts
index 771ae27..8f132c5 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -68,7 +68,6 @@ const configSchema = z
.url()
.default("https://9router.asepharyana.tech/v1"),
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_RECOVERY_INTERVAL_MS: z.coerce
.number()
diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts
index eeb5cdc..e2c58db 100644
--- a/src/moderation/aiAnalyzer.ts
+++ b/src/moderation/aiAnalyzer.ts
@@ -25,10 +25,12 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
// Debounce state per conversation key
const conversationDebounceTimers = new Map();
// Track conversations currently being processed
-const conversationProcessing = new Set();
+const conversationProcessing = new Map();
// Track conversations in error cooldown (failed recently)
const conversationErrorCooldown = new Map();
+const AI_PROCESSING_OVERLAP_MS = 30000;
+
let activeRequests = 0;
let lastError: string | null = null;
@@ -72,6 +74,13 @@ export function pickBatchWithinBudget(
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
*/
@@ -82,7 +91,8 @@ async function processBatch(
if (messages.length === 0) return;
activeRequests++;
- conversationProcessing.add(conversationKey);
+ const processingStartedAt = Date.now();
+ conversationProcessing.set(conversationKey, processingStartedAt);
try {
const result = await runAnalysisInWorker(conversationKey, messages);
@@ -136,7 +146,9 @@ async function processBatch(
);
} finally {
activeRequests--;
- conversationProcessing.delete(conversationKey);
+ if (conversationProcessing.get(conversationKey) === processingStartedAt) {
+ conversationProcessing.delete(conversationKey);
+ }
}
}
@@ -171,7 +183,7 @@ async function runAnalysisInWorker(
*/
function scheduleConversationAnalysis(conversationKey: string): void {
// Skip if already processing
- if (conversationProcessing.has(conversationKey)) {
+ if (isConversationProcessingLocked(conversationKey)) {
return;
}
@@ -275,7 +287,7 @@ export function startPendingAIAnalysisWorker(): void {
}
// Skip if currently processing
- if (conversationProcessing.has(key)) {
+ if (isConversationProcessingLocked(key)) {
continue;
}
diff --git a/src/moderation/attachmentUploader.ts b/src/moderation/attachmentUploader.ts
index 7bb0d61..25c390e 100644
--- a/src/moderation/attachmentUploader.ts
+++ b/src/moderation/attachmentUploader.ts
@@ -4,14 +4,34 @@ import { uploadToTele } from "../uploader/teleUpload";
import {
updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded,
+ updateAttachmentDiscordUrl,
} from "./messageStore";
const logger = createChildLogger("attachment-uploader");
+class AttachmentDownloadError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ ) {
+ super(message);
+ this.name = "AttachmentDownloadError";
+ }
+}
+
+export type RefreshDiscordAttachmentUrl = () => Promise;
+
function toErrorMessage(error: unknown): string {
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(
fileBuffer: Buffer,
filename: string,
@@ -47,7 +67,10 @@ export async function downloadDiscordAttachment(url: string): Promise {
});
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();
@@ -65,9 +88,24 @@ export async function processAttachmentUpload(
attachmentId: string,
discordUrl: string,
filename: string,
+ options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {},
): Promise {
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);
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts
index 3688d30..542c41d 100644
--- a/src/moderation/llmModerationClient.ts
+++ b/src/moderation/llmModerationClient.ts
@@ -1,9 +1,30 @@
+import OpenAI from "openai";
import { config } from "../config.ts";
import { createChildLogger } from "../logger.ts";
import { retryWithBackoff } from "../retry.ts";
import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types";
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 {
message_id: string;
@@ -17,48 +38,6 @@ interface RawModerationResponse {
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.
* 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[].
* 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(
content: string,
targetIds: string[],
@@ -260,9 +282,11 @@ export function parseModerationResponse(
}
if (!targetIdSet.has(finalId)) {
- throw new Error(
- `Unknown message_id: ${finalId} (original: ${message_id})`,
+ log.warn(
+ { unknownId: finalId, originalId: message_id, targetIds },
+ "Skipping moderation result for non-target message_id",
);
+ return null;
}
if (foundIds.has(finalId)) {
@@ -322,12 +346,11 @@ export function parseModerationResponse(
{ missingIds, foundCount: foundIds.size, totalCount: targetIds.length },
"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) {
filteredResults.push({
messageId: missingId,
- status: "clean",
- flags: [],
+ status: "error",
+ flags: ["analysis_incomplete"],
score: 0,
analysis: "Analysis incomplete - LLM did not process this message",
});
@@ -391,7 +414,6 @@ ${messagesText}`;
const targetIdSet = new Set(targets.map((t) => t.id));
const getAttachmentImageUrl = (att: AttachmentRecord): string | null => {
if (att.uploaded_url) return att.uploaded_url;
- if (targetIdSet.has(att.message_id)) return att.discord_url;
return null;
};
const imageAttachments = (attachments || [])
@@ -471,79 +493,27 @@ ${messagesText}`;
}
const result = await retryWithBackoff(
- async () => {
- const controller = new AbortController();
- const timeoutId = setTimeout(
- () => controller.abort(),
- config.AI_ANALYSIS_TIMEOUT_MS,
- );
-
- try {
- const response = await fetch(
- `${config.AI_LLM_BASE_URL}/chat/completions`,
+ () =>
+ openai.chat.completions.create({
+ model: config.AI_LLM_MODEL,
+ messages: [
{
- method: "POST",
- headers: {
- "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 },
- }),
+ role: "system",
+ content: systemPrompt,
},
- );
- // Read the response body once (either text() or json()), then reuse it.
- let rawBody: string | undefined = undefined;
- if (typeof response.text === "function") {
- try {
- rawBody = await response.text();
- } catch {
- rawBody = undefined;
- }
- } else if (typeof response.json === "function") {
- try {
- const j = await response.json();
- rawBody = JSON.stringify(j);
- } 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);
- }
- },
+ {
+ role: "user",
+ content: messageContent,
+ },
+ ],
+ temperature: 0.2,
+ top_p: 0.95,
+ max_tokens: 65536,
+ response_format: { type: "json_object" },
+ stream: false,
+ chat_template_kwargs: { enable_thinking: false },
+ reasoning_budget: 0,
+ } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming),
{
retries: 3,
minTimeout: 1000,
@@ -569,25 +539,54 @@ ${messagesText}`;
} catch (parseError) {
const errorMsg =
parseError instanceof Error ? parseError.message : String(parseError);
- 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: "clean",
- flags: [],
- score: 0.1,
- analysis: `Parsing failed: ${errorMsg}. Defaulted to clean.`,
- }));
+ const salvaged = salvageMalformedModerationResponse(content, targetIds);
+ if (salvaged) {
+ log.warn(
+ {
+ error: errorMsg,
+ contentLength: content.length,
+ contentPreview: content.substring(0, 500),
+ targetIds,
+ recoveredIds: salvaged.map((result) => result.messageId),
+ model: config.AI_LLM_MODEL,
+ timestamp: new Date().toISOString(),
+ },
+ "Recovered moderation response from malformed JSON",
+ );
+ const recoveredIds = new Set(salvaged.map((result) => result.messageId));
+ parsed = [
+ ...salvaged,
+ ...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(
diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts
index 67957dc..c1209c8 100644
--- a/src/moderation/messageCapture.ts
+++ b/src/moderation/messageCapture.ts
@@ -121,6 +121,8 @@ export async function captureMessage(
broadcaster.messageCreated(messageRecord);
}
+ const attachmentUploadTasks: Promise[] = [];
+
// Insert attachments before queuing analysis to avoid race condition
if (message.attachments.size > 0) {
for (const [, attachment] of message.attachments) {
@@ -136,16 +138,29 @@ export async function captureMessage(
// Initiate async upload (non-blocking, fire-and-forget)
if (!isBacklog) {
- processAttachmentUpload(
- attachment.id,
- attachment.url,
- attachment.name || "unknown",
- ).catch((err) => {
- logger.error(
- { attachmentId: attachment.id, error: err },
- "Failed to initiate attachment upload",
- );
- });
+ attachmentUploadTasks.push(
+ processAttachmentUpload(
+ attachment.id,
+ attachment.url,
+ attachment.name || "unknown",
+ {
+ refreshDiscordUrl: async () => {
+ 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) {
@@ -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) {
- 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);
+ }
}
}
diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts
index 5910ffe..7e7eaea 100644
--- a/src/moderation/messageStore.ts
+++ b/src/moderation/messageStore.ts
@@ -325,6 +325,28 @@ export async function updateAttachmentAsUploaded(
}
}
+export async function updateAttachmentDiscordUrl(
+ attachmentId: string,
+ discordUrl: string,
+): Promise {
+ 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(
attachmentId: string,
error: string,
diff --git a/src/moderation/types.ts b/src/moderation/types.ts
index 5eb1566..2084477 100644
--- a/src/moderation/types.ts
+++ b/src/moderation/types.ts
@@ -86,7 +86,7 @@ export interface PageResult {
export interface AnalysisResult {
messageId: string;
- status: Exclude;
+ status: Exclude;
flags: string[];
score: number;
analysis: string;
diff --git a/tests/moderation/attachmentUploader.test.ts b/tests/moderation/attachmentUploader.test.ts
index 9986769..2f3ebee 100644
--- a/tests/moderation/attachmentUploader.test.ts
+++ b/tests/moderation/attachmentUploader.test.ts
@@ -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(() => {
+ vi.clearAllMocks();
process.env = {
...process.env,
DISCORD_TOKEN: "test-token",
@@ -37,4 +59,60 @@ describe("attachmentUploader", () => {
/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();
+ });
});
diff --git a/tests/moderation/llmModerationClient.test.ts b/tests/moderation/llmModerationClient.test.ts
index 0184d51..909fb68 100644
--- a/tests/moderation/llmModerationClient.test.ts
+++ b/tests/moderation/llmModerationClient.test.ts
@@ -73,28 +73,33 @@ describe("parseModerationResponse", () => {
]);
expect(result).toHaveLength(1);
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].analysis).toContain("incomplete");
});
- it("rejects unknown ids", () => {
- expect(() =>
- parseModerationResponse(
- JSON.stringify({
- results: [
- {
- message_id: "m2",
- status: "clean",
- flags: [],
- score: 0,
- analysis: "OK",
- },
- ],
- }),
- ["m1"],
- ),
- ).toThrow(/unknown/i);
+ it("skips unknown ids and fills missing targets", () => {
+ const result = parseModerationResponse(
+ JSON.stringify({
+ results: [
+ {
+ message_id: "m2",
+ status: "clean",
+ flags: [],
+ score: 0,
+ analysis: "OK",
+ },
+ ],
+ }),
+ ["m1"],
+ );
+
+ 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", () => {
@@ -412,9 +417,10 @@ describe("runModerationAnalysis", () => {
});
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.reasoning_budget).toBeUndefined();
+ expect(requestBody.stream).toBe(false);
+ expect(requestBody.reasoning_budget).toBe(0);
expect(requestBody.chat_template_kwargs).toEqual({
enable_thinking: false,
});
@@ -472,25 +478,26 @@ describe("runModerationAnalysis", () => {
targets: [createMessageRecord()],
contextText: "test context",
}),
- ).rejects.toThrow(/LLM API error 500/);
+ ).rejects.toThrow(/500/);
});
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 = {
choices: [
{
message: {
- content: JSON.stringify({
- results: [
- {
- message_id: "m1",
- status: "clean",
- flags: [],
- score: 0.1,
- analysis: "OK",
- },
- ],
- }),
+ content: `${moderationJson}\nextra`,
},
},
],
@@ -498,8 +505,7 @@ describe("runModerationAnalysis", () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
- text: async () =>
- `${JSON.stringify(mockResponse)}\n{"usage":{"tokens":12}}`,
+ text: async () => JSON.stringify(mockResponse),
});
const result = await runModerationAnalysis({
@@ -852,7 +858,7 @@ describe("runModerationAnalysis", () => {
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 = {
choices: [
{
@@ -922,9 +928,11 @@ describe("runModerationAnalysis", () => {
],
});
- expect((global.fetch as any).mock.calls[0][0]).toBe(
- "https://httpbin.org/image/png",
+ const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ 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 () => {
@@ -1396,23 +1404,25 @@ describe("runModerationAnalysis", () => {
).toThrow();
});
- it("throws on mismatched message IDs", () => {
- expect(() =>
- parseModerationResponse(
- JSON.stringify({
- results: [
- {
- message_id: "m999",
- status: "clean",
- flags: [],
- score: 0.1,
- analysis: "OK",
- },
- ],
- }),
- ["m1"],
- ),
- ).toThrow(/unknown.*message_id/i);
+ it("skips mismatched message IDs", () => {
+ const result = parseModerationResponse(
+ JSON.stringify({
+ results: [
+ {
+ message_id: "m999",
+ status: "clean",
+ flags: [],
+ score: 0.1,
+ analysis: "OK",
+ },
+ ],
+ }),
+ ["m1"],
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].messageId).toBe("m1");
+ expect(result[0].analysis).toContain("incomplete");
});
it("throws on invalid status value", () => {