From 441ff5a0ed6b994b88db8981c32a4b2925931019 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 28 May 2026 01:29:31 +0700 Subject: [PATCH] feat(moderation): fetch and analyze URLs (images and web text) from messages - Added to safely extract and fetch up to 3 URLs per message (with SSRF protection, 5MB limit, and 8s timeout). - Implemented recursive extraction to resolve Tenor/Giphy links from their HTML viewers to raw GIF binaries. - In , fetched images are automatically injected as into the vision LLM context, and truncated webpage text is appended to the message string. --- src/moderation/llmModerationClient.ts | 54 ++++++- src/moderation/urlFetcher.ts | 207 ++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 src/moderation/urlFetcher.ts diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index d9249a8..7854f5d 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -9,6 +9,7 @@ import type { AttachmentRecord, MessageRecord, } from "./types.js"; +import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; const ModerationResponseSchema = z.object({ results: z.array( @@ -468,6 +469,49 @@ export async function runModerationAnalysis( }), ); + // --- Fetch URLs found in target messages --- + // To avoid slowing down the pipeline too much, we limit to 3 URLs per message. + const messageWebTextMap = new Map(); + + await Promise.all( + targets.map(async (msg) => { + const content = msg.edited_content ?? msg.content; + const urls = extractUrlsFromText(content).slice(0, 3); + if (urls.length === 0) return; + + const webTexts: string[] = []; + + await Promise.all( + urls.map(async (url) => { + const result = await fetchUrlSafely(url); + + if (result.type === "image" && result.data && result.mimeType) { + // Append as an image part + const dataUrl = `data:${result.mimeType};base64,${result.data.toString("base64")}`; + const part: RawImagePart = { + type: "image_url", + image_url: { url: dataUrl }, + }; + const existing = messageImageMap.get(msg.id) ?? []; + existing.push(part); + messageImageMap.set(msg.id, existing); + } else if (result.type === "text" && result.textContent) { + webTexts.push(`[Isi Web dari ${url}]: ${result.textContent}`); + } else if (result.type === "error") { + log.debug( + { url, error: result.error }, + "Failed to fetch URL for moderation context", + ); + } + }), + ); + + if (webTexts.length > 0) { + messageWebTextMap.set(msg.id, webTexts); + } + }), + ); + const hasImages = messageImageMap.size > 0; // ------------------------------------------------------------------------- @@ -571,7 +615,10 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan const messagesBlock = targets .map((msg) => { const content = msg.edited_content ?? msg.content; - return `[target] id=${msg.id} user=${msg.username}: ${content}`; + const webTexts = messageWebTextMap.get(msg.id) ?? []; + const webContext = + webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; + return `[target] id=${msg.id} user=${msg.username}: ${content}${webContext}`; }) .join("\n"); @@ -588,7 +635,10 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan for (const msg of targets) { const content = msg.edited_content ?? msg.content; - const msgText = `[target] id=${msg.id} user=${msg.username}: ${content}`; + const webTexts = messageWebTextMap.get(msg.id) ?? []; + const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; + + const msgText = `[target] id=${msg.id} user=${msg.username}: ${content}${webContext}`; parts.push({ type: "text", text: msgText }); // Immediately follow the message text with its images diff --git a/src/moderation/urlFetcher.ts b/src/moderation/urlFetcher.ts new file mode 100644 index 0000000..db1b207 --- /dev/null +++ b/src/moderation/urlFetcher.ts @@ -0,0 +1,207 @@ +import { resolve } from "node:dns/promises"; +import { isIP } from "node:net"; +import { createChildLogger } from "../logger.js"; + +const log = createChildLogger("urlFetcher"); + +export interface FetchedUrlContext { + url: string; + type: "image" | "text" | "error"; + data?: Buffer; + mimeType?: string; + textContent?: string; + error?: string; +} + +const MAX_FETCH_SIZE = 5 * 1024 * 1024; // 5 MB +const FETCH_TIMEOUT_MS = 8000; +const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/gi; + +/** + * Basic SSRF protection. + * Note: A sophisticated attacker could still use DNS rebinding. + */ +async function isSafeUrl(urlStr: string): Promise { + try { + const parsed = new URL(urlStr); + const host = parsed.hostname; + + // Block obvious local IPs/hostnames + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host.startsWith("192.168.") || + host.startsWith("10.") || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host) + ) { + return false; + } + + // Try resolving to check if it resolves to a local IP + if (!isIP(host)) { + try { + const addresses = await resolve(host); + for (const ip of addresses) { + if ( + ip === "127.0.0.1" || + ip.startsWith("192.168.") || + ip.startsWith("10.") || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) + ) { + return false; + } + } + } catch (err) { + // If DNS fails, we can't fetch it anyway + return false; + } + } + + return true; + } catch (err) { + return false; + } +} + +function extractOgImage(html: string): string | null { + // Look for or + const ogRegex = /]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; + const match = html.match(ogRegex); + if (match && match[1]) { + // Unescape basic HTML entities + return match[1].replace(/&/g, "&").replace(/"/g, '"'); + } + + // Try reversed attribute order: + const ogRegexRev = /]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; + const matchRev = html.match(ogRegexRev); + if (matchRev && matchRev[1]) { + return matchRev[1].replace(/&/g, "&").replace(/"/g, '"'); + } + + return null; +} + +function truncateAndCleanHtml(html: string, maxLen = 1000): string { + // Strip