feat(moderation): enhance attachment handling and AI analysis integration
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user