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_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free
AI_ANALYSIS_TIMEOUT_MS=30000
# Database Configuration
DATABASE_TYPE=sqlite
+28 -8
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Needs Review</CardTitle>
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
<CardDescription>
{flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
</CardDescription>
</CardHeader>
<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>
</Card>
);
+1
View File
@@ -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",
+20
View File
@@ -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
-1
View File
@@ -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()
+17 -5
View File
@@ -25,10 +25,12 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
// Debounce state per conversation key
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
// Track conversations currently being processed
const conversationProcessing = new Set<string>();
const conversationProcessing = new Map<string, number>();
// Track conversations in error cooldown (failed recently)
const conversationErrorCooldown = new Map<string, number>();
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;
}
+40 -2
View File
@@ -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<string | null>;
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<Buffer> {
});
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<void> {
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) {
+137 -138
View File
@@ -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(
+38 -12
View File
@@ -121,6 +121,8 @@ export async function captureMessage(
broadcaster.messageCreated(messageRecord);
}
const attachmentUploadTasks: Promise<void>[] = [];
// 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);
}
}
}
+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(
attachmentId: string,
error: string,
+1 -1
View File
@@ -86,7 +86,7 @@ export interface PageResult<T> {
export interface AnalysisResult {
messageId: string;
status: Exclude<AIStatus, "pending" | "error">;
status: Exclude<AIStatus, "pending">;
flags: string[];
score: number;
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(() => {
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();
});
});
+64 -54
View File
@@ -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", () => {