diff --git a/docs/superpowers/plans/2026-05-18-robust-moderation-and-tests.md b/docs/superpowers/plans/2026-05-18-robust-moderation-and-tests.md new file mode 100644 index 0000000..468eec9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-robust-moderation-and-tests.md @@ -0,0 +1,361 @@ +# Robust Moderation & Test Improvements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or the plan-runner to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a robust and fault-tolerant parsing mechanism for LLM moderation responses, implement image attachment capping/prioritization to prevent 400 Bad Request errors, fix floating-point/exponential Snowflake precision loss, and fix existing failing dev/streaming tests. + +**Architecture:** +1. Improve parsing in `src/moderation/llmModerationClient.ts` to first try extracting JSON from Markdown blocks, then exhaustively scan start (`{`) and end (`}`) braces. +2. Group, sort, and cap image attachments to at most 8 elements, prioritizing targets over context. +3. Align existing failing tests in `tests/media/ytdlp.test.ts` and `tests/streaming/playTranscode.test.ts` to their runtime implementations. + +**Tech Stack:** TypeScript, Node.js, Vitest, Pino Logger + +--- + +### Task 1: Add Robust JSON Parsing and Capping to llmModerationClient + +**Files:** +- Modify: `src/moderation/llmModerationClient.ts` +- Test: `tests/moderation/llmModerationClient.test.ts` + +- [ ] **Step 1: Update parseModerationResponse and runModerationAnalysis** + +We will implement `extractJson` helper and update `parseModerationResponse` to use it. We will also sort and cap the image attachments in `runModerationAnalysis`. + +Modify `src/moderation/llmModerationClient.ts`: +```typescript +/** + * 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. + */ +export function extractJson(content: string): any { + // 1. Try to find markdown json code blocks: ```json ... ``` or ``` ... ``` + const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g; + let match; + while ((match = codeBlockRegex.exec(content)) !== null) { + const codeContent = match[1].trim(); + try { + const parsed = JSON.parse(codeContent); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (e) { + // Continue to next code block + } + } + + // 2. If no code blocks parse successfully, try scanning for {...} pairs + const openBraces: number[] = []; + const closeBraces: number[] = []; + for (let i = 0; i < content.length; i++) { + if (content[i] === "{") openBraces.push(i); + if (content[i] === "}") closeBraces.push(i); + } + + // Try pairs from largest span to smallest + for (const start of openBraces) { + for (let j = closeBraces.length - 1; j >= 0; j--) { + const end = closeBraces[j]; + if (end > start) { + const candidate = content.substring(start, end + 1); + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (e) { + // ignore and try next + } + } + } + } + + throw new Error("No JSON object found in response"); +} +``` + +Replace the JSON parsing block inside `parseModerationResponse` with: +```typescript + // Extract and parse JSON object + const parsed = extractJson(content); + + // Validate structure + if (!parsed || typeof parsed !== "object" || !("results" in parsed)) { + throw new Error("Response missing 'results' array"); + } +``` + +Update `runModerationAnalysis` image attachment sorting & capping: +```typescript + // Check for image attachments to support multimodal analysis + const targetIdSet = new Set(targets.map((t) => t.id)); + const imageAttachments = (attachments || []) + .filter( + (att) => + (att.uploaded_url || att.discord_url) && att.type.startsWith("image/"), + ) + .sort((a, b) => { + const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0; + const bIsTarget = targetIdSet.has(b.message_id) ? 1 : 0; + if (aIsTarget !== bIsTarget) { + return bIsTarget - aIsTarget; // Target messages first + } + return b.created_at - a.created_at; // Most recent first + }) + .slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit) +``` + +- [ ] **Step 2: Run existing tests to verify they pass** + +Run: `pnpm run test tests/moderation/llmModerationClient.test.ts` +Expected: PASS + +- [ ] **Step 3: Add new unit tests to verify robust JSON parsing and image capping** + +Add the following tests inside `describe("parseModerationResponse", ...)` in `tests/moderation/llmModerationClient.test.ts`: +```typescript + it("extracts JSON correctly from complex conversational output with thinking blocks containing braces", () => { + const content = `Based on the messages, I will analyze them. + + The JSON structure should be: + { + "results": [ ... ] + } + + Here is the results array: + { + "results": [ + { + "message_id": "m1", + "status": "clean", + "flags": [], + "score": 0.2, + "analysis": "Benign" + } + ] + }`; + const result = parseModerationResponse(content, ["m1"]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + }); + + it("extracts JSON from markdown code block wrapping", () => { + const content = `Sure! Here is the JSON structure: +\`\`\`json +{ + "results": [ + { + "message_id": "m1", + "status": "clean", + "flags": [], + "score": 0.2, + "analysis": "Benign" + } + ] +} +\`\`\``; + const result = parseModerationResponse(content, ["m1"]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + }); +``` + +Add the following test inside `describe("runModerationAnalysis", ...)` in `tests/moderation/llmModerationClient.test.ts`: +```typescript + it("caps image attachments to 8 and prioritizes targets over context", async () => { + const mockResponse = { + choices: [ + { + message: { + content: JSON.stringify({ + results: [ + { + message_id: "m1", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }), + }, + }, + ], + }; + + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes("picser.tech") || url.includes("discord.com")) { + return Promise.resolve({ + ok: true, + arrayBuffer: async () => Buffer.from("fake-bytes").buffer, + }); + } + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify(mockResponse), + json: async () => mockResponse, + }); + }); + + const createAttachment = (id: string, msgId: string, createdAt: number) => ({ + id, + message_id: msgId, + guild_id: "guild123", + channel_id: "channel123", + thread_id: null, + user_id: "user123", + filename: `${id}.png`, + size: 500, + type: "image/png", + discord_url: `https://discord.com/${id}.png`, + uploaded_url: `https://picser.tech/${id}.png`, + upload_status: "uploaded" as const, + upload_error: null, + created_at: createdAt, + uploaded_at: createdAt, + }); + + // 10 attachments total (3 targets, 7 context) + const attachments = [ + createAttachment("c1", "context1", 100), + createAttachment("c2", "context2", 200), + createAttachment("t1", "m1", 300), // Target 1 + createAttachment("c3", "context3", 400), + createAttachment("t2", "m1", 500), // Target 2 + createAttachment("c4", "context4", 600), + createAttachment("c5", "context5", 700), + createAttachment("t3", "m1", 800), // Target 3 + createAttachment("c6", "context6", 900), + createAttachment("c7", "context7", 1000), + ]; + + await runModerationAnalysis({ + targets: [createMessageRecord({ id: "m1" })], + contextText: "test context", + attachments, + }); + + const fetchCalls = (global.fetch as any).mock.calls; + // Should download exactly 8 images (since it's capped at 8) + // Target attachments (t3, t2, t1) must be fetched, then context in descending order of created_at: + // Sorted order: t3 (800), t2 (500), t1 (300), c7 (1000), c6 (900), c5 (700), c4 (600), c3 (400) + // Excluded: c2 (200), c1 (100) + const downloadedUrls = fetchCalls + .slice(0, 8) + .map((call: any) => call[0]); + + expect(downloadedUrls).toContain("https://picser.tech/t3.png"); + expect(downloadedUrls).toContain("https://picser.tech/t2.png"); + expect(downloadedUrls).toContain("https://picser.tech/t1.png"); + expect(downloadedUrls).toContain("https://picser.tech/c7.png"); + expect(downloadedUrls).not.toContain("https://picser.tech/c1.png"); + }); +``` + +- [ ] **Step 4: Run all moderation client tests** + +Run: `pnpm run test tests/moderation/llmModerationClient.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit changes** + +```bash +git add src/moderation/llmModerationClient.ts tests/moderation/llmModerationClient.test.ts +git commit -m "feat: implement robust JSON parsing and multimodal image capping" +``` + +--- + +### Task 2: Align ytdlp and playTranscode tests + +**Files:** +- Modify: `tests/media/ytdlp.test.ts` +- Modify: `tests/streaming/playTranscode.test.ts` + +- [ ] **Step 1: Fix ytdlp.test.ts** + +Update the expected arguments in the mock assertion to expect `best[protocol^=http]/best` for video format. +Modify `tests/media/ytdlp.test.ts`: +```typescript + it("reads direct video URL", async () => { + const proc = new FakeProcess(); + const spawn = vi.fn(() => proc); + const ytdlp = createYtDlp({ spawn }); + + const result = ytdlp.getDirectVideoUrl("https://youtu.be/video"); + proc.stdout.write("https://video.example.com/stream\n"); + proc.stdout.end(); + proc.emit("close", 0); + + await expect(result).resolves.toBe("https://video.example.com/stream"); + expect(spawn).toHaveBeenCalledWith( + "yt-dlp", + [ + "https://youtu.be/video", + "--get-url", + "--format", + "best[protocol^=http]/best", + "--no-playlist", + "--no-warnings", + "--quiet", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + }); +``` + +- [ ] **Step 2: Run ytdlp unit test** + +Run: `pnpm run test tests/media/ytdlp.test.ts` +Expected: PASS + +- [ ] **Step 3: Fix playTranscode.test.ts** + +Modify `tests/streaming/playTranscode.test.ts` to check if `readable.on` exists before calling it: +```typescript + play: vi.fn().mockImplementation(async (readable) => { + // consume a bit from readable to simulate playback + if (readable && typeof readable.on === "function") { + readable.on("data", (d: Buffer) => {}); + } + // resolve after a short delay + await new Promise((r) => setTimeout(r, 5)); + }), +``` + +- [ ] **Step 4: Run playTranscode unit test** + +Run: `pnpm run test tests/streaming/playTranscode.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit changes** + +```bash +git add tests/media/ytdlp.test.ts tests/streaming/playTranscode.test.ts +git commit -m "fix: align ytdlp and playTranscode tests with actual implementations" +``` + +--- + +### Task 3: Final Verification and Clean Build + +**Files:** +- None (verification only) + +- [ ] **Step 1: Run all test suites** + +Run: `pnpm run test` +Expected: PASS (140/140 tests pass) + +- [ ] **Step 2: Run typechecker** + +Run: `pnpm run typecheck` +Expected: No type errors + +- [ ] **Step 3: Run Biome linter and formatter** + +Run: `pnpm run lint` +Expected: No linter errors +Run: `pnpm run format` +Expected: No formatting changes diff --git a/docs/superpowers/specs/2026-05-18-robust-moderation-and-tests-design.md b/docs/superpowers/specs/2026-05-18-robust-moderation-and-tests-design.md new file mode 100644 index 0000000..f70bbe6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-robust-moderation-and-tests-design.md @@ -0,0 +1,56 @@ +# Design Spec: Robust Moderation & Test Improvements + +**Date**: 2026-05-18 +**Topic**: Robust LLM Moderation Parsing, Capping Multimodal Attachments, and Fixing Dev/Streaming Tests + +--- + +## 1. Goal & Context +The project contains an LLM-based content moderation system that analyzes Discord messages and their image attachments. Real-world utilization revealed several issues: +1. **Multimodal API Limits**: High numbers of image attachments in the target or surrounding context messages exceed API limits (e.g. Nemotron/Omni models cap at 8 images), triggering an HTTP 400 error. +2. **LLM Output Variance**: LLM responses containing reasoning processes, conversational preambles, or markdown wrappers fail to parse under the current naive brace-matching algorithm, yielding `No JSON object found` or `Response missing 'results' array`. +3. **Snowflake Precision Loss**: Snowflake IDs returned by the LLM sometimes suffer from floating-point rounding or formatting issues, preventing them from matching the original string-based target IDs. +4. **Dev/Streaming Test Failures**: Failing tests in `ytdlp.test.ts` and `playTranscode.test.ts` due to mismatched parameters and type assertions. + +--- + +## 2. Architecture & Detailed Design + +### A. Multimodal Attachment Filtering & Prioritization +In `src/moderation/llmModerationClient.ts`: +* Extract all image attachments. +* Sort and prioritize attachments: + * Targets first: Attachments belonging to messages in the active `targets` list. + * Context second: Attachments belonging to context messages, sorted by `created_at` descending (most recent first). +* Slice the resulting array to a maximum of **8 elements** to ensure we never hit model limits. +* If the list is empty, proceed with the existing transparent 1x1 dummy PNG fallback. + +### B. Resilient JSON Extraction +Implement `extractJson` inside `src/moderation/llmModerationClient.ts`: +1. **Markdown Blocks**: Scan for code blocks using `/```(?:json)?\s*([\s\S]*?)\s*```/g`. Try to parse the first match yielding an object. +2. **Exhaustive Span Search**: If markdown parsing fails, locate the indices of all `{` and `}` characters in the string. Try all matching pairs, starting from the largest span to the smallest. +3. **Error Reporting**: If no candidate substring parses as an object, throw `No JSON object found in response`. + +### C. Message ID Fuzzy Mapping +* Map `message_id` back to target IDs by stringifying and checking exact match. +* If not matched and the ID ends with `"00"` or contains `"e+"` (indicating exponential format or floating point precision loss), search `targetIds` for a prefix match (first 10 characters) and restore the original ID. + +### D. Streaming & Dev Test Fixes +* **`tests/media/ytdlp.test.ts`**: Update the assertion to expect `--format best[protocol^=http]/best` to match the actual production code. +* **`tests/streaming/playTranscode.test.ts`**: Safely check if the input `readable` is an object and has the `.on` function before calling `readable.on("data", ...)`. + +--- + +## 3. Test Plan & Expanded Coverage +We will implement dedicated unit tests in `tests/moderation/llmModerationClient.test.ts`: +1. **Image Capping & Prioritization**: Ensure image attachments are sorted correctly and capped at 8. +2. **Complex Conversational Content**: Verify extraction from messages wrapped in markdown, with leading/trailing text, and multiple code blocks. +3. **Reasoning Blocks**: Verify extraction when reasoning blocks contain separate `{` and `}` symbols. +4. **Precision Loss Scenarios**: Verify automatic correction of floating-point string representations of Snowflake IDs. + +--- + +## 4. Success Criteria +* All tests pass successfully (`pnpm run test` exits with `0`). +* System remains highly resilient to formatting variance in LLM responses. +* No 400 Bad Request errors occur due to exceeding the maximum image attachment limit. diff --git a/src/media/mediaResolver.ts b/src/media/mediaResolver.ts index 54739c3..a87bfed 100644 --- a/src/media/mediaResolver.ts +++ b/src/media/mediaResolver.ts @@ -1,7 +1,7 @@ import { existsSync, statSync } from "node:fs"; import path from "node:path"; import { AppError } from "../errors"; -import type { ResolvedMediaSource, MediaMode } from "./mediaTypes"; +import type { MediaMode, ResolvedMediaSource } from "./mediaTypes"; import { createPlayDlResolver } from "./playDlResolver"; import { createYtDlp, type YtDlpClient } from "./ytdlp"; diff --git a/src/media/screenShareController.ts b/src/media/screenShareController.ts index 4dbd48a..9688a3a 100644 --- a/src/media/screenShareController.ts +++ b/src/media/screenShareController.ts @@ -1,7 +1,7 @@ -import { Streamer, playPreparedStream } from "../streaming"; import { AppError } from "../errors"; import { createChildLogger } from "../logger"; import { discordPlayer } from "../player"; +import { playPreparedStream, Streamer } from "../streaming"; const logger = createChildLogger("screen-share"); diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 97101e3..c6df1c3 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -17,6 +17,55 @@ interface RawModerationResponse { results: RawModerationResult[]; } +/** + * 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. + */ +export function extractJson(content: string): any { + // 1. Try to find markdown json code blocks: ```json ... ``` or ``` ... ``` + const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g; + const matches = content.matchAll(codeBlockRegex); + for (const match of matches) { + const codeContent = match[1].trim(); + try { + const parsed = JSON.parse(codeContent); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (e) { + // Continue to next code block + } + } + + // 2. If no code blocks parse successfully, try scanning for {...} pairs + const openBraces: number[] = []; + const closeBraces: number[] = []; + for (let i = 0; i < content.length; i++) { + if (content[i] === "{") openBraces.push(i); + if (content[i] === "}") closeBraces.push(i); + } + + // Try pairs from largest span to smallest + for (const start of openBraces) { + for (let j = closeBraces.length - 1; j >= 0; j--) { + const end = closeBraces[j]; + if (end > start) { + const candidate = content.substring(start, end + 1); + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (e) { + // ignore and try next + } + } + } + } + + throw new Error("No JSON object found in response"); +} + /** * Parses LLM moderation response and validates against target IDs. * Extracts JSON from surrounding text, validates structure, and transforms to AnalysisResult[]. @@ -26,44 +75,8 @@ export function parseModerationResponse( content: string, targetIds: string[], ): AnalysisResult[] { - // Find first opening brace and last closing brace - const startIdx = content.indexOf("{"); - const endIdx = content.lastIndexOf("}"); - - if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) { - throw new Error("No JSON object found in response"); - } - - // Attempt to parse the largest possible JSON object - let parsed: unknown; - const candidate = content.substring(startIdx, endIdx + 1); - - try { - parsed = JSON.parse(candidate); - } catch (error) { - // If full substring fails, try scanning backwards from the last } - let lastError: Error = - error instanceof Error ? error : new Error(String(error)); - - for (let i = endIdx - 1; i > startIdx; i--) { - if (content[i] === "}") { - try { - parsed = JSON.parse(content.substring(startIdx, i + 1)); - break; - } catch (innerError) { - lastError = - innerError instanceof Error - ? innerError - : new Error(String(innerError)); - continue; - } - } - } - - if (!parsed) { - throw new Error(`Failed to parse JSON: ${lastError.message}`); - } - } + // Extract and parse JSON object + const parsed = extractJson(content); // Validate structure if (!parsed || typeof parsed !== "object" || !("results" in parsed)) { @@ -88,7 +101,10 @@ export function parseModerationResponse( throw new Error("Result missing 'message_id'"); } - let finalId = String(message_id); + let finalId = String(message_id).trim(); + if (finalId.startsWith("[") && finalId.endsWith("]")) { + finalId = finalId.slice(1, -1).trim(); + } // Precision loss fix: If the ID from LLM is not found, // try to find the closest match in targets if it looks rounded (ends in 000) @@ -222,10 +238,21 @@ Each result must have: Return ONLY valid JSON, no other text.`; // Check for image attachments to support multimodal analysis - const imageAttachments = (attachments || []).filter( - (att) => - (att.uploaded_url || att.discord_url) && att.type.startsWith("image/"), - ); + const targetIdSet = new Set(targets.map((t) => t.id)); + const imageAttachments = (attachments || []) + .filter( + (att) => + (att.uploaded_url || att.discord_url) && att.type.startsWith("image/"), + ) + .sort((a, b) => { + const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0; + const bIsTarget = targetIdSet.has(b.message_id) ? 1 : 0; + if (aIsTarget !== bIsTarget) { + return bIsTarget - aIsTarget; // Target messages first + } + return b.created_at - a.created_at; // Most recent first + }) + .slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit) let messageContent: | string diff --git a/src/player.ts b/src/player.ts index 84117ab..94858e3 100644 --- a/src/player.ts +++ b/src/player.ts @@ -9,8 +9,8 @@ import { VoiceConnection, } from "@discordjs/voice"; import type { - DiscordPlayOptions, DiscordPlayerOwner, + DiscordPlayOptions, } from "./media/mediaTypes"; export class DiscordPlayer { diff --git a/src/streaming/index.ts b/src/streaming/index.ts index 0a1b4ee..1bfb4d8 100644 --- a/src/streaming/index.ts +++ b/src/streaming/index.ts @@ -1,15 +1,15 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import type { Readable } from "node:stream"; import type { ChildProcess } from "node:child_process"; -import type { Client } from "discord.js-selfbot-v13"; +import { EventEmitter } from "node:events"; +import type { Readable } from "node:stream"; +import { PassThrough } from "node:stream"; import { Streamer as DankStreamer, - prepareStream as dankPrepareStream, playStream as dankPlayStream, - Utils, + prepareStream as dankPrepareStream, Encoders, + Utils, } from "@dank074/discord-video-stream"; +import type { Client } from "discord.js-selfbot-v13"; type VoiceConnectionLike = any; type StreamConnectionLike = any; diff --git a/src/streaming/transcoder.ts b/src/streaming/transcoder.ts index 7678411..50bdfec 100644 --- a/src/streaming/transcoder.ts +++ b/src/streaming/transcoder.ts @@ -1,9 +1,9 @@ -import { spawn, ChildProcess } from "node:child_process"; -import { PassThrough } from "node:stream"; +import { ChildProcess, spawn } from "node:child_process"; import type { Readable } from "node:stream"; -import { retryWithBackoff } from "../retry"; +import { PassThrough } from "node:stream"; import { createChildLogger } from "../logger"; import { transcoderRestartsCounter, transcoderRunningGauge } from "../metrics"; +import { retryWithBackoff } from "../retry"; const logger = createChildLogger("transcoder"); diff --git a/src/webserver.ts b/src/webserver.ts index f16199d..9858ec5 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Streamer } from "./streaming"; import { AudioPlayerStatus } from "@discordjs/voice"; import type { Client } from "discord.js-selfbot-v13"; import express, { @@ -29,6 +28,7 @@ import { createMessageRoutes } from "./routes/messageRoutes"; import { createSyncRoutes } from "./routes/syncRoutes"; import { createUIStateRoutes } from "./routes/uiStateRoutes"; import { createVoiceRoutes } from "./routes/voiceRoutes"; +import { Streamer } from "./streaming"; import type { VoiceController } from "./voiceController"; const __filename = fileURLToPath(import.meta.url); diff --git a/test_dank.ts b/test_dank.ts index 79da909..cae969a 100644 --- a/test_dank.ts +++ b/test_dank.ts @@ -1,4 +1,4 @@ -import { prepareStream, Encoders } from "@dank074/discord-video-stream"; +import { Encoders, prepareStream } from "@dank074/discord-video-stream"; import fs from "fs"; async function run() { diff --git a/test_dank2.ts b/test_dank2.ts index a7e6b77..62717c8 100644 --- a/test_dank2.ts +++ b/test_dank2.ts @@ -1,4 +1,4 @@ -import { prepareStream, Encoders } from "@dank074/discord-video-stream"; +import { Encoders, prepareStream } from "@dank074/discord-video-stream"; import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js"; async function run() { diff --git a/test_stream.ts b/test_stream.ts index 050fe7e..a0ea95f 100644 --- a/test_stream.ts +++ b/test_stream.ts @@ -1,6 +1,6 @@ import { prepareStream } from "@dank074/discord-video-stream"; -import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js"; import { Encoders } from "@dank074/discord-video-stream/dist/media/encoders/index.js"; +import { demux } from "@dank074/discord-video-stream/dist/media/LibavDemuxer.js"; async function run() { const { command, output } = prepareStream( diff --git a/tests/media/musicPlayer.test.ts b/tests/media/musicPlayer.test.ts index 32f936c..8629555 100644 --- a/tests/media/musicPlayer.test.ts +++ b/tests/media/musicPlayer.test.ts @@ -4,8 +4,8 @@ type Spawn = typeof nodeSpawn; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { describe, expect, it, vi } from "vitest"; import { StreamType } from "@discordjs/voice"; +import { describe, expect, it, vi } from "vitest"; import type { DiscordAudioPlayer, DiscordPlayerOwner, diff --git a/tests/media/ytdlp.test.ts b/tests/media/ytdlp.test.ts index a716535..ac8d979 100644 --- a/tests/media/ytdlp.test.ts +++ b/tests/media/ytdlp.test.ts @@ -11,7 +11,7 @@ class FakeProcess extends EventEmitter { describe("createYtDlp", () => { it("reads YouTube metadata as JSON", async () => { const proc = new FakeProcess(); - const spawn = vi.fn(() => proc); + const spawn = vi.fn(() => proc) as any; const ytdlp = createYtDlp({ spawn }); const result = ytdlp.getMetadata("https://youtu.be/video"); @@ -43,7 +43,7 @@ describe("createYtDlp", () => { it("reads direct audio URL", async () => { const proc = new FakeProcess(); - const spawn = vi.fn(() => proc); + const spawn = vi.fn(() => proc) as any; const ytdlp = createYtDlp({ spawn }); const result = ytdlp.getDirectAudioUrl("https://youtu.be/video"); @@ -69,7 +69,7 @@ describe("createYtDlp", () => { it("reads direct video URL", async () => { const proc = new FakeProcess(); - const spawn = vi.fn(() => proc); + const spawn = vi.fn(() => proc) as any; const ytdlp = createYtDlp({ spawn }); const result = ytdlp.getDirectVideoUrl("https://youtu.be/video"); @@ -84,7 +84,7 @@ describe("createYtDlp", () => { "https://youtu.be/video", "--get-url", "--format", - "bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best", + "best[protocol^=http]/best", "--no-playlist", "--no-warnings", "--quiet", @@ -95,7 +95,7 @@ describe("createYtDlp", () => { it("rejects when yt-dlp exits non-zero", async () => { const proc = new FakeProcess(); - const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) }); + const ytdlp = createYtDlp({ spawn: vi.fn(() => proc) as any }); const result = ytdlp.getMetadata("https://youtu.be/video"); proc.stderr.write("failed"); diff --git a/tests/moderation/llmModerationClient.test.ts b/tests/moderation/llmModerationClient.test.ts index 57fa67e..046e295 100644 --- a/tests/moderation/llmModerationClient.test.ts +++ b/tests/moderation/llmModerationClient.test.ts @@ -253,6 +253,68 @@ describe("parseModerationResponse", () => { expect(result[0].score).toBe(0); }); + + it("extracts JSON correctly from complex conversational output with thinking blocks containing braces", () => { + const content = `Based on the messages, I will analyze them. + + The JSON structure should be: + { + "results": [ ... ] + } + + Here is the results array: + { + "results": [ + { + "message_id": "m1", + "status": "clean", + "flags": [], + "score": 0.2, + "analysis": "Benign" + } + ] + }`; + const result = parseModerationResponse(content, ["m1"]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + }); + + it("extracts JSON from markdown code block wrapping", () => { + const content = `Sure! Here is the JSON structure: +\`\`\`json +{ + "results": [ + { + "message_id": "m1", + "status": "clean", + "flags": [], + "score": 0.2, + "analysis": "Benign" + } + ] +} +\`\`\``; + const result = parseModerationResponse(content, ["m1"]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + }); + + it("handles message_id returned with square brackets", () => { + const content = JSON.stringify({ + results: [ + { + message_id: "[m1]", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }); + const result = parseModerationResponse(content, ["m1"]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + }); }); describe("runModerationAnalysis", () => { @@ -432,4 +494,103 @@ describe("runModerationAnalysis", () => { "You are a content moderation assistant.", ); }); + + it("caps image attachments to 8 and prioritizes targets over context", async () => { + const mockResponse = { + choices: [ + { + message: { + content: JSON.stringify({ + results: [ + { + message_id: "m1", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }), + }, + }, + ], + }; + + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url.includes("picser.tech") || url.includes("discord.com")) { + return Promise.resolve({ + ok: true, + arrayBuffer: async () => { + const buffer = Buffer.from("fake-bytes"); + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ); + }, + }); + } + return Promise.resolve({ + ok: true, + text: async () => JSON.stringify(mockResponse), + json: async () => mockResponse, + }); + }); + + const createAttachment = ( + id: string, + msgId: string, + createdAt: number, + ) => ({ + id, + message_id: msgId, + guild_id: "guild123", + channel_id: "channel123", + thread_id: null, + user_id: "user123", + filename: `${id}.png`, + size: 500, + type: "image/png", + discord_url: `https://discord.com/${id}.png`, + uploaded_url: `https://picser.tech/${id}.png`, + upload_status: "uploaded" as const, + upload_error: null, + created_at: createdAt, + uploaded_at: createdAt, + }); + + // 10 attachments total (3 targets, 7 context) + const attachments = [ + createAttachment("c1", "context1", 100), + createAttachment("c2", "context2", 200), + createAttachment("t1", "m1", 300), // Target 1 + createAttachment("c3", "context3", 400), + createAttachment("t2", "m1", 500), // Target 2 + createAttachment("c4", "context4", 600), + createAttachment("c5", "context5", 700), + createAttachment("t3", "m1", 800), // Target 3 + createAttachment("c6", "context6", 900), + createAttachment("c7", "context7", 1000), + ]; + + await runModerationAnalysis({ + targets: [createMessageRecord({ id: "m1" })], + contextText: "test context", + attachments, + }); + + const fetchCalls = (global.fetch as any).mock.calls; + // Should download exactly 8 images (since it's capped at 8) plus 1 call for completion API = 9 calls total. + expect(fetchCalls.length).toBe(9); + + // Target attachments (t3, t2, t1) must be fetched, then context in descending order of created_at: + // Sorted order: t3 (800), t2 (500), t1 (300), c7 (1000), c6 (900), c5 (700), c4 (600), c3 (400) + // Excluded: c2 (200), c1 (100) + const downloadedUrls = fetchCalls.slice(0, 8).map((call: any) => call[0]); + + expect(downloadedUrls).toContain("https://picser.tech/t3.png"); + expect(downloadedUrls).toContain("https://picser.tech/t2.png"); + expect(downloadedUrls).toContain("https://picser.tech/t1.png"); + expect(downloadedUrls).toContain("https://picser.tech/c7.png"); + expect(downloadedUrls).not.toContain("https://picser.tech/c1.png"); + }); }); diff --git a/tests/streaming/playTranscode.test.ts b/tests/streaming/playTranscode.test.ts index 30f552c..8b512fa 100644 --- a/tests/streaming/playTranscode.test.ts +++ b/tests/streaming/playTranscode.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { ...actual, - spawn: (cmd: string, args: string[], opts: any) => { + spawn: (_cmd: string, _args: string[], _opts: any) => { const stdout = new PassThrough(); const stderr = new PassThrough(); const listeners: Record = {}; @@ -46,7 +46,9 @@ describe("playTranscodedPreparedStream", () => { stream: { playVideo: () => null, playAudio: () => null }, play: vi.fn().mockImplementation(async (readable) => { // consume a bit from readable to simulate playback - readable.on("data", (d: Buffer) => {}); + if (readable && typeof readable.on === "function") { + readable.on("data", (_d: Buffer) => {}); + } // resolve after a short delay await new Promise((r) => setTimeout(r, 5)); }), diff --git a/tests/streaming/transcoder.test.ts b/tests/streaming/transcoder.test.ts index 5f07c78..03fdb5d 100644 --- a/tests/streaming/transcoder.test.ts +++ b/tests/streaming/transcoder.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; import { PassThrough } from "node:stream"; +import { describe, expect, it, vi } from "vitest"; // Mock spawn to avoid calling real ffmpeg vi.mock("node:child_process", async () => {