UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

1,576 lines (1,564 loc) 83.1 kB
import { t as __exportAll } from "./rolldown-runtime-Cbj13DAv.js"; import { A as normalizeAccountId$1 } from "./agent-scope-Csu2B6AM.js"; import { H as logVerbose, N as resolveUserPath, W as shouldLogVerbose, b as getActivePluginRegistry } from "./exec-BMnoMcZW.js"; import { At as hasAlphaChannel, En as mediaKindFromMime, G as appendAssistantMessageToSessionTranscript, It as closeDispatcher, K as resolveMirroredTranscriptText, Lt as createPinnedDispatcher, Mt as resizeToJpeg, On as resolveSignalAccount, Ot as convertHeicToJpeg, Pt as saveMediaBuffer, Rt as resolvePinnedHostname, Tn as maxBytesForKind, _n as detectMime, gt as getChannelDock, jn as normalizeChannelId, jt as optimizeImageToPng, kn as getChannelPlugin, m as isMessagingToolDuplicate, on as INTERNAL_MESSAGE_CHANNEL, vn as extensionForMime, zt as resolvePinnedHostnameWithPolicy } from "./pi-embedded-helpers-BxqZh6U7.js"; import { t as loadConfig } from "./config-CG73z4h6.js"; import path from "node:path"; import fs from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import MarkdownIt from "markdown-it"; //#region src/auto-reply/tokens.ts const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; const SILENT_REPLY_TOKEN = "NO_REPLY"; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function isSilentReplyText(text, token = SILENT_REPLY_TOKEN) { if (!text) return false; const escaped = escapeRegExp(token); if (new RegExp(`^\\s*${escaped}(?=$|\\W)`).test(text)) return true; return new RegExp(`\\b${escaped}\\b\\W*$`).test(text); } //#endregion //#region src/markdown/fences.ts function parseFenceSpans(buffer) { const spans = []; let open; let offset = 0; while (offset <= buffer.length) { const nextNewline = buffer.indexOf("\n", offset); const lineEnd = nextNewline === -1 ? buffer.length : nextNewline; const line = buffer.slice(offset, lineEnd); const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/); if (match) { const indent = match[1]; const marker = match[2]; const markerChar = marker[0]; const markerLen = marker.length; if (!open) open = { start: offset, markerChar, markerLen, openLine: line, marker, indent }; else if (open.markerChar === markerChar && markerLen >= open.markerLen) { const end = lineEnd; spans.push({ start: open.start, end, openLine: open.openLine, marker: open.marker, indent: open.indent }); open = void 0; } } if (nextNewline === -1) break; offset = nextNewline + 1; } if (open) spans.push({ start: open.start, end: buffer.length, openLine: open.openLine, marker: open.marker, indent: open.indent }); return spans; } function findFenceSpanAt(spans, index) { return spans.find((span) => index > span.start && index < span.end); } function isSafeFenceBreak(spans, index) { return !findFenceSpanAt(spans, index); } //#endregion //#region src/auto-reply/chunk.ts const DEFAULT_CHUNK_LIMIT = 4e3; const DEFAULT_CHUNK_MODE = "length"; function resolveChunkLimitForProvider(cfgSection, accountId) { if (!cfgSection) return; const normalizedAccountId = normalizeAccountId$1(accountId); const accounts = cfgSection.accounts; if (accounts && typeof accounts === "object") { const direct = accounts[normalizedAccountId]; if (typeof direct?.textChunkLimit === "number") return direct.textChunkLimit; const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId.toLowerCase()); const match = matchKey ? accounts[matchKey] : void 0; if (typeof match?.textChunkLimit === "number") return match.textChunkLimit; } return cfgSection.textChunkLimit; } function resolveTextChunkLimit(cfg, provider, accountId, opts) { const fallback = typeof opts?.fallbackLimit === "number" && opts.fallbackLimit > 0 ? opts.fallbackLimit : DEFAULT_CHUNK_LIMIT; const providerOverride = (() => { if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return; return resolveChunkLimitForProvider((cfg?.channels)?.[provider] ?? cfg?.[provider], accountId); })(); if (typeof providerOverride === "number" && providerOverride > 0) return providerOverride; return fallback; } function resolveChunkModeForProvider(cfgSection, accountId) { if (!cfgSection) return; const normalizedAccountId = normalizeAccountId$1(accountId); const accounts = cfgSection.accounts; if (accounts && typeof accounts === "object") { const direct = accounts[normalizedAccountId]; if (direct?.chunkMode) return direct.chunkMode; const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId.toLowerCase()); const match = matchKey ? accounts[matchKey] : void 0; if (match?.chunkMode) return match.chunkMode; } return cfgSection.chunkMode; } function resolveChunkMode(cfg, provider, accountId) { if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE; return resolveChunkModeForProvider((cfg?.channels)?.[provider] ?? cfg?.[provider], accountId) ?? DEFAULT_CHUNK_MODE; } /** * Split text on newlines, trimming line whitespace. * Blank lines are folded into the next non-empty line as leading "\n" prefixes. * Long lines can be split by length (default) or kept intact via splitLongLines:false. */ function chunkByNewline(text, maxLineLength, opts) { if (!text) return []; if (maxLineLength <= 0) return text.trim() ? [text] : []; const splitLongLines = opts?.splitLongLines !== false; const trimLines = opts?.trimLines !== false; const lines = splitByNewline(text, opts?.isSafeBreak); const chunks = []; let pendingBlankLines = 0; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { pendingBlankLines += 1; continue; } const maxPrefix = Math.max(0, maxLineLength - 1); const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0; const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : ""; pendingBlankLines = 0; const lineValue = trimLines ? trimmed : line; if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) { chunks.push(prefix + lineValue); continue; } const firstLimit = Math.max(1, maxLineLength - prefix.length); const first = lineValue.slice(0, firstLimit); chunks.push(prefix + first); const remaining = lineValue.slice(firstLimit); if (remaining) chunks.push(...chunkText(remaining, maxLineLength)); } if (pendingBlankLines > 0 && chunks.length > 0) chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines); return chunks; } /** * Split text into chunks on paragraph boundaries (blank lines), preserving lists and * single-newline line wraps inside paragraphs. * * - Only breaks at paragraph separators ("\n\n" or more, allowing whitespace on blank lines) * - Packs multiple paragraphs into a single chunk up to `limit` * - Falls back to length-based splitting when a single paragraph exceeds `limit` * (unless `splitLongParagraphs` is disabled) */ function chunkByParagraph(text, limit, opts) { if (!text) return []; if (limit <= 0) return [text]; const splitLongParagraphs = opts?.splitLongParagraphs !== false; const normalized = text.replace(/\r\n?/g, "\n"); if (!/\n[\t ]*\n+/.test(normalized)) { if (normalized.length <= limit) return [normalized]; if (!splitLongParagraphs) return [normalized]; return chunkText(normalized, limit); } const spans = parseFenceSpans(normalized); const parts = []; const re = /\n[\t ]*\n+/g; let lastIndex = 0; for (const match of normalized.matchAll(re)) { const idx = match.index ?? 0; if (!isSafeFenceBreak(spans, idx)) continue; parts.push(normalized.slice(lastIndex, idx)); lastIndex = idx + match[0].length; } parts.push(normalized.slice(lastIndex)); const chunks = []; for (const part of parts) { const paragraph = part.replace(/\s+$/g, ""); if (!paragraph.trim()) continue; if (paragraph.length <= limit) chunks.push(paragraph); else if (!splitLongParagraphs) chunks.push(paragraph); else chunks.push(...chunkText(paragraph, limit)); } return chunks; } /** * Unified chunking function that dispatches based on mode. */ function chunkTextWithMode(text, limit, mode) { if (mode === "newline") return chunkByParagraph(text, limit); return chunkText(text, limit); } function chunkMarkdownTextWithMode(text, limit, mode) { if (mode === "newline") { const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false }); const out = []; for (const chunk of paragraphChunks) { const nested = chunkMarkdownText(chunk, limit); if (!nested.length && chunk) out.push(chunk); else out.push(...nested); } return out; } return chunkMarkdownText(text, limit); } function splitByNewline(text, isSafeBreak = () => true) { const lines = []; let start = 0; for (let i = 0; i < text.length; i++) if (text[i] === "\n" && isSafeBreak(i)) { lines.push(text.slice(start, i)); start = i + 1; } lines.push(text.slice(start)); return lines; } function chunkText(text, limit) { if (!text) return []; if (limit <= 0) return [text]; if (text.length <= limit) return [text]; const chunks = []; let remaining = text; while (remaining.length > limit) { const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(remaining.slice(0, limit)); let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace; if (breakIdx <= 0) breakIdx = limit; const chunk = remaining.slice(0, breakIdx).trimEnd(); if (chunk.length > 0) chunks.push(chunk); const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); remaining = remaining.slice(nextStart).trimStart(); } if (remaining.length) chunks.push(remaining); return chunks; } function chunkMarkdownText(text, limit) { if (!text) return []; if (limit <= 0) return [text]; if (text.length <= limit) return [text]; const chunks = []; let remaining = text; while (remaining.length > limit) { const spans = parseFenceSpans(remaining); const softBreak = pickSafeBreakIndex(remaining.slice(0, limit), spans); let breakIdx = softBreak > 0 ? softBreak : limit; const initialFence = isSafeFenceBreak(spans, breakIdx) ? void 0 : findFenceSpanAt(spans, breakIdx); let fenceToSplit = initialFence; if (initialFence) { const closeLine = `${initialFence.indent}${initialFence.marker}`; const maxIdxIfNeedNewline = limit - (closeLine.length + 1); if (maxIdxIfNeedNewline <= 0) { fenceToSplit = void 0; breakIdx = limit; } else { const minProgressIdx = Math.min(remaining.length, initialFence.start + initialFence.openLine.length + 2); const maxIdxIfAlreadyNewline = limit - closeLine.length; let pickedNewline = false; let lastNewline = remaining.lastIndexOf("\n", Math.max(0, maxIdxIfAlreadyNewline - 1)); while (lastNewline !== -1) { const candidateBreak = lastNewline + 1; if (candidateBreak < minProgressIdx) break; const candidateFence = findFenceSpanAt(spans, candidateBreak); if (candidateFence && candidateFence.start === initialFence.start) { breakIdx = Math.max(1, candidateBreak); pickedNewline = true; break; } lastNewline = remaining.lastIndexOf("\n", lastNewline - 1); } if (!pickedNewline) if (minProgressIdx > maxIdxIfAlreadyNewline) { fenceToSplit = void 0; breakIdx = limit; } else breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline); } const fenceAtBreak = findFenceSpanAt(spans, breakIdx); fenceToSplit = fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : void 0; } let rawChunk = remaining.slice(0, breakIdx); if (!rawChunk) break; const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); let next = remaining.slice(nextStart); if (fenceToSplit) { const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`; rawChunk = rawChunk.endsWith("\n") ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`; next = `${fenceToSplit.openLine}\n${next}`; } else next = stripLeadingNewlines(next); chunks.push(rawChunk); remaining = next; } if (remaining.length) chunks.push(remaining); return chunks; } function stripLeadingNewlines(value) { let i = 0; while (i < value.length && value[i] === "\n") i++; return i > 0 ? value.slice(i) : value; } function pickSafeBreakIndex(window, spans) { const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) => isSafeFenceBreak(spans, index)); if (lastNewline > 0) return lastNewline; if (lastWhitespace > 0) return lastWhitespace; return -1; } function scanParenAwareBreakpoints(window, isAllowed = () => true) { let lastNewline = -1; let lastWhitespace = -1; let depth = 0; for (let i = 0; i < window.length; i++) { if (!isAllowed(i)) continue; const char = window[i]; if (char === "(") { depth += 1; continue; } if (char === ")" && depth > 0) { depth -= 1; continue; } if (depth !== 0) continue; if (char === "\n") lastNewline = i; else if (/\s/.test(char)) lastWhitespace = i; } return { lastNewline, lastWhitespace }; } //#endregion //#region src/config/markdown-tables.ts const DEFAULT_TABLE_MODES = new Map([["signal", "bullets"], ["whatsapp", "bullets"]]); const isMarkdownTableMode = (value) => value === "off" || value === "bullets" || value === "code"; function resolveMarkdownModeFromSection(section, accountId) { if (!section) return; const normalizedAccountId = normalizeAccountId$1(accountId); const accounts = section.accounts; if (accounts && typeof accounts === "object") { const directMode = accounts[normalizedAccountId]?.markdown?.tables; if (isMarkdownTableMode(directMode)) return directMode; const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalizedAccountId.toLowerCase()); const matchMode = (matchKey ? accounts[matchKey] : void 0)?.markdown?.tables; if (isMarkdownTableMode(matchMode)) return matchMode; } const sectionMode = section.markdown?.tables; return isMarkdownTableMode(sectionMode) ? sectionMode : void 0; } function resolveMarkdownTableMode(params) { const channel = normalizeChannelId(params.channel); const defaultMode = channel ? DEFAULT_TABLE_MODES.get(channel) ?? "code" : "code"; if (!channel || !params.cfg) return defaultMode; return resolveMarkdownModeFromSection(params.cfg.channels?.[channel] ?? params.cfg?.[channel], params.accountId) ?? defaultMode; } //#endregion //#region src/infra/net/fetch-guard.ts const DEFAULT_MAX_REDIRECTS = 3; function isRedirectStatus(status) { return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; } function buildAbortSignal(params) { const { timeoutMs, signal } = params; if (!timeoutMs && !signal) return { signal: void 0, cleanup: () => {} }; if (!timeoutMs) return { signal, cleanup: () => {} }; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const onAbort = () => controller.abort(); if (signal) if (signal.aborted) controller.abort(); else signal.addEventListener("abort", onAbort, { once: true }); const cleanup = () => { clearTimeout(timeoutId); if (signal) signal.removeEventListener("abort", onAbort); }; return { signal: controller.signal, cleanup }; } async function fetchWithSsrFGuard(params) { const fetcher = params.fetchImpl ?? globalThis.fetch; if (!fetcher) throw new Error("fetch is not available"); const maxRedirects = typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects) ? Math.max(0, Math.floor(params.maxRedirects)) : DEFAULT_MAX_REDIRECTS; const { signal, cleanup } = buildAbortSignal({ timeoutMs: params.timeoutMs, signal: params.signal }); let released = false; const release = async (dispatcher) => { if (released) return; released = true; cleanup(); await closeDispatcher(dispatcher ?? void 0); }; const visited = /* @__PURE__ */ new Set(); let currentUrl = params.url; let redirectCount = 0; while (true) { let parsedUrl; try { parsedUrl = new URL(currentUrl); } catch { await release(); throw new Error("Invalid URL: must be http or https"); } if (!["http:", "https:"].includes(parsedUrl.protocol)) { await release(); throw new Error("Invalid URL: must be http or https"); } let dispatcher = null; try { const pinned = Boolean(params.policy?.allowPrivateNetwork || params.policy?.allowedHostnames?.length) ? await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, { lookupFn: params.lookupFn, policy: params.policy }) : await resolvePinnedHostname(parsedUrl.hostname, params.lookupFn); if (params.pinDns !== false) dispatcher = createPinnedDispatcher(pinned); const init = { ...params.init ? { ...params.init } : {}, redirect: "manual", ...dispatcher ? { dispatcher } : {}, ...signal ? { signal } : {} }; const response = await fetcher(parsedUrl.toString(), init); if (isRedirectStatus(response.status)) { const location = response.headers.get("location"); if (!location) { await release(dispatcher); throw new Error(`Redirect missing location header (${response.status})`); } redirectCount += 1; if (redirectCount > maxRedirects) { await release(dispatcher); throw new Error(`Too many redirects (limit: ${maxRedirects})`); } const nextUrl = new URL(location, parsedUrl).toString(); if (visited.has(nextUrl)) { await release(dispatcher); throw new Error("Redirect loop detected"); } visited.add(nextUrl); response.body?.cancel(); await closeDispatcher(dispatcher); currentUrl = nextUrl; continue; } return { response, finalUrl: currentUrl, release: async () => release(dispatcher) }; } catch (err) { await release(dispatcher); throw err; } } } //#endregion //#region src/media/fetch.ts var MediaFetchError = class extends Error { constructor(code, message) { super(message); this.code = code; this.name = "MediaFetchError"; } }; function stripQuotes(value) { return value.replace(/^["']|["']$/g, ""); } function parseContentDispositionFileName(header) { if (!header) return; const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header); if (starMatch?.[1]) { const cleaned = stripQuotes(starMatch[1].trim()); const encoded = cleaned.split("''").slice(1).join("''") || cleaned; try { return path.basename(decodeURIComponent(encoded)); } catch { return path.basename(encoded); } } const match = /filename\s*=\s*([^;]+)/i.exec(header); if (match?.[1]) return path.basename(stripQuotes(match[1].trim())); } async function readErrorBodySnippet(res, maxChars = 200) { try { const text = await res.text(); if (!text) return; const collapsed = text.replace(/\s+/g, " ").trim(); if (!collapsed) return; if (collapsed.length <= maxChars) return collapsed; return `${collapsed.slice(0, maxChars)}…`; } catch { return; } } async function fetchRemoteMedia(options) { const { url, fetchImpl, filePathHint, maxBytes, maxRedirects, ssrfPolicy, lookupFn } = options; let res; let finalUrl = url; let release = null; try { const result = await fetchWithSsrFGuard({ url, fetchImpl, maxRedirects, policy: ssrfPolicy, lookupFn }); res = result.response; finalUrl = result.finalUrl; release = result.release; } catch (err) { throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`); } try { if (!res.ok) { const statusText = res.statusText ? ` ${res.statusText}` : ""; const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : ""; let detail = `HTTP ${res.status}${statusText}`; if (!res.body) detail = `HTTP ${res.status}${statusText}; empty response body`; else { const snippet = await readErrorBodySnippet(res); if (snippet) detail += `; body: ${snippet}`; } throw new MediaFetchError("http_error", `Failed to fetch media from ${url}${redirected}: ${detail}`); } const contentLength = res.headers.get("content-length"); if (maxBytes && contentLength) { const length = Number(contentLength); if (Number.isFinite(length) && length > maxBytes) throw new MediaFetchError("max_bytes", `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`); } const buffer = maxBytes ? await readResponseWithLimit(res, maxBytes) : Buffer.from(await res.arrayBuffer()); let fileNameFromUrl; try { const parsed = new URL(finalUrl); fileNameFromUrl = path.basename(parsed.pathname) || void 0; } catch {} const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition")); let fileName = headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : void 0); const filePathForMime = headerFileName && path.extname(headerFileName) ? headerFileName : filePathHint ?? finalUrl; const contentType = await detectMime({ buffer, headerMime: res.headers.get("content-type"), filePath: filePathForMime }); if (fileName && !path.extname(fileName) && contentType) { const ext = extensionForMime(contentType); if (ext) fileName = `${fileName}${ext}`; } return { buffer, contentType: contentType ?? void 0, fileName }; } finally { if (release) await release(); } } async function readResponseWithLimit(res, maxBytes) { const body = res.body; if (!body || typeof body.getReader !== "function") { const fallback = Buffer.from(await res.arrayBuffer()); if (fallback.length > maxBytes) throw new MediaFetchError("max_bytes", `Failed to fetch media from ${res.url || "response"}: payload exceeds maxBytes ${maxBytes}`); return fallback; } const reader = body.getReader(); const chunks = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value?.length) { total += value.length; if (total > maxBytes) { try { await reader.cancel(); } catch {} throw new MediaFetchError("max_bytes", `Failed to fetch media from ${res.url || "response"}: payload exceeds maxBytes ${maxBytes}`); } chunks.push(value); } } } finally { try { reader.releaseLock(); } catch {} } return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); } //#endregion //#region src/web/media.ts const HEIC_MIME_RE = /^image\/hei[cf]$/i; const HEIC_EXT_RE = /\.(heic|heif)$/i; const MB$1 = 1024 * 1024; function formatMb(bytes, digits = 2) { return (bytes / MB$1).toFixed(digits); } function formatCapLimit(label, cap, size) { return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`; } function formatCapReduce(label, cap, size) { return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`; } function isHeicSource(opts) { if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) return true; if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) return true; return false; } function toJpegFileName(fileName) { if (!fileName) return; const trimmed = fileName.trim(); if (!trimmed) return fileName; const parsed = path.parse(trimmed); if (!parsed.ext || HEIC_EXT_RE.test(parsed.ext)) return path.format({ dir: parsed.dir, name: parsed.name || trimmed, ext: ".jpg" }); return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" }); } function logOptimizedImage(params) { if (!shouldLogVerbose()) return; if (params.optimized.optimizedSize >= params.originalSize) return; if (params.optimized.format === "png") { logVerbose(`Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`); return; } logVerbose(`Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`); } async function optimizeImageWithFallback(params) { const { buffer, cap, meta } = params; if ((meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png")) && await hasAlphaChannel(buffer)) { const optimized = await optimizeImageToPng(buffer, cap); if (optimized.buffer.length <= cap) return { ...optimized, format: "png" }; if (shouldLogVerbose()) logVerbose(`PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`); } return { ...await optimizeImageToJpeg(buffer, cap, meta), format: "jpeg" }; } async function loadWebMediaInternal(mediaUrl, options = {}) { const { maxBytes, optimizeImages = true, ssrfPolicy } = options; if (mediaUrl.startsWith("file://")) try { mediaUrl = fileURLToPath(mediaUrl); } catch { throw new Error(`Invalid file:// URL: ${mediaUrl}`); } const optimizeAndClampImage = async (buffer, cap, meta) => { const originalSize = buffer.length; const optimized = await optimizeImageWithFallback({ buffer, cap, meta }); logOptimizedImage({ originalSize, optimized }); if (optimized.buffer.length > cap) throw new Error(formatCapReduce("Media", cap, optimized.buffer.length)); const contentType = optimized.format === "png" ? "image/png" : "image/jpeg"; const fileName = optimized.format === "jpeg" && meta && isHeicSource(meta) ? toJpegFileName(meta.fileName) : meta?.fileName; return { buffer: optimized.buffer, contentType, kind: "image", fileName }; }; const clampAndFinalize = async (params) => { const cap = maxBytes !== void 0 ? maxBytes : maxBytesForKind(params.kind); if (params.kind === "image") { const isGif = params.contentType === "image/gif"; if (isGif || !optimizeImages) { if (params.buffer.length > cap) throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length)); return { buffer: params.buffer, contentType: params.contentType, kind: params.kind, fileName: params.fileName }; } return { ...await optimizeAndClampImage(params.buffer, cap, { contentType: params.contentType, fileName: params.fileName }) }; } if (params.buffer.length > cap) throw new Error(formatCapLimit("Media", cap, params.buffer.length)); return { buffer: params.buffer, contentType: params.contentType ?? void 0, kind: params.kind, fileName: params.fileName }; }; if (/^https?:\/\//i.test(mediaUrl)) { const defaultFetchCap = maxBytesForKind("unknown"); const { buffer, contentType, fileName } = await fetchRemoteMedia({ url: mediaUrl, maxBytes: maxBytes === void 0 ? defaultFetchCap : optimizeImages ? Math.max(maxBytes, defaultFetchCap) : maxBytes, ssrfPolicy }); return await clampAndFinalize({ buffer, contentType, kind: mediaKindFromMime(contentType), fileName }); } if (mediaUrl.startsWith("~")) mediaUrl = resolveUserPath(mediaUrl); const data = await fs.readFile(mediaUrl); const mime = await detectMime({ buffer: data, filePath: mediaUrl }); const kind = mediaKindFromMime(mime); let fileName = path.basename(mediaUrl) || void 0; if (fileName && !path.extname(fileName) && mime) { const ext = extensionForMime(mime); if (ext) fileName = `${fileName}${ext}`; } return await clampAndFinalize({ buffer: data, contentType: mime, kind, fileName }); } async function loadWebMedia(mediaUrl, maxBytes, options) { return await loadWebMediaInternal(mediaUrl, { maxBytes, optimizeImages: true, ssrfPolicy: options?.ssrfPolicy }); } async function loadWebMediaRaw(mediaUrl, maxBytes, options) { return await loadWebMediaInternal(mediaUrl, { maxBytes, optimizeImages: false, ssrfPolicy: options?.ssrfPolicy }); } async function optimizeImageToJpeg(buffer, maxBytes, opts = {}) { let source = buffer; if (isHeicSource(opts)) try { source = await convertHeicToJpeg(buffer); } catch (err) { throw new Error(`HEIC image conversion failed: ${String(err)}`, { cause: err }); } const sides = [ 2048, 1536, 1280, 1024, 800 ]; const qualities = [ 80, 70, 60, 50, 40 ]; let smallest = null; for (const side of sides) for (const quality of qualities) try { const out = await resizeToJpeg({ buffer: source, maxSide: side, quality, withoutEnlargement: true }); const size = out.length; if (!smallest || size < smallest.size) smallest = { buffer: out, size, resizeSide: side, quality }; if (size <= maxBytes) return { buffer: out, optimizedSize: size, resizeSide: side, quality }; } catch {} if (smallest) return { buffer: smallest.buffer, optimizedSize: smallest.size, resizeSide: smallest.resizeSide, quality: smallest.quality }; throw new Error("Failed to optimize image"); } //#endregion //#region src/markdown/ir.ts function createMarkdownIt(options) { const md = new MarkdownIt({ html: false, linkify: options.linkify ?? true, breaks: false, typographer: false }); md.enable("strikethrough"); if (options.tableMode && options.tableMode !== "off") md.enable("table"); else md.disable("table"); if (options.autolink === false) md.disable("autolink"); return md; } function getAttr(token, name) { if (token.attrGet) return token.attrGet(name); if (token.attrs) { for (const [key, value] of token.attrs) if (key === name) return value; } return null; } function createTextToken(base, content) { return { ...base, type: "text", content, children: void 0 }; } function applySpoilerTokens(tokens) { for (const token of tokens) if (token.children && token.children.length > 0) token.children = injectSpoilersIntoInline(token.children); } function injectSpoilersIntoInline(tokens) { const result = []; const state = { spoilerOpen: false }; for (const token of tokens) { if (token.type !== "text") { result.push(token); continue; } const content = token.content ?? ""; if (!content.includes("||")) { result.push(token); continue; } let index = 0; while (index < content.length) { const next = content.indexOf("||", index); if (next === -1) { if (index < content.length) result.push(createTextToken(token, content.slice(index))); break; } if (next > index) result.push(createTextToken(token, content.slice(index, next))); state.spoilerOpen = !state.spoilerOpen; result.push({ type: state.spoilerOpen ? "spoiler_open" : "spoiler_close" }); index = next + 2; } } return result; } function initRenderTarget() { return { text: "", styles: [], openStyles: [], links: [], linkStack: [] }; } function resolveRenderTarget(state) { return state.table?.currentCell ?? state; } function appendText(state, value) { if (!value) return; const target = resolveRenderTarget(state); target.text += value; } function openStyle(state, style) { const target = resolveRenderTarget(state); target.openStyles.push({ style, start: target.text.length }); } function closeStyle(state, style) { const target = resolveRenderTarget(state); for (let i = target.openStyles.length - 1; i >= 0; i -= 1) if (target.openStyles[i]?.style === style) { const start = target.openStyles[i].start; target.openStyles.splice(i, 1); const end = target.text.length; if (end > start) target.styles.push({ start, end, style }); return; } } function appendParagraphSeparator(state) { if (state.env.listStack.length > 0) return; if (state.table) return; state.text += "\n\n"; } function appendListPrefix(state) { const stack = state.env.listStack; const top = stack[stack.length - 1]; if (!top) return; top.index += 1; const indent = " ".repeat(Math.max(0, stack.length - 1)); const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; state.text += `${indent}${prefix}`; } function renderInlineCode(state, content) { if (!content) return; const target = resolveRenderTarget(state); const start = target.text.length; target.text += content; target.styles.push({ start, end: start + content.length, style: "code" }); } function renderCodeBlock(state, content) { let code = content ?? ""; if (!code.endsWith("\n")) code = `${code}\n`; const target = resolveRenderTarget(state); const start = target.text.length; target.text += code; target.styles.push({ start, end: start + code.length, style: "code_block" }); if (state.env.listStack.length === 0) target.text += "\n"; } function handleLinkClose(state) { const target = resolveRenderTarget(state); const link = target.linkStack.pop(); if (!link?.href) return; const href = link.href.trim(); if (!href) return; const start = link.labelStart; const end = target.text.length; if (end <= start) { target.links.push({ start, end, href }); return; } target.links.push({ start, end, href }); } function initTableState() { return { headers: [], rows: [], currentRow: [], currentCell: null, inHeader: false }; } function finishTableCell(cell) { closeRemainingStyles(cell); return { text: cell.text, styles: cell.styles, links: cell.links }; } function trimCell(cell) { const text = cell.text; let start = 0; let end = text.length; while (start < end && /\s/.test(text[start] ?? "")) start += 1; while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1; if (start === 0 && end === text.length) return cell; const trimmedText = text.slice(start, end); const trimmedLength = trimmedText.length; const trimmedStyles = []; for (const span of cell.styles) { const sliceStart = Math.max(0, span.start - start); const sliceEnd = Math.min(trimmedLength, span.end - start); if (sliceEnd > sliceStart) trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style }); } const trimmedLinks = []; for (const span of cell.links) { const sliceStart = Math.max(0, span.start - start); const sliceEnd = Math.min(trimmedLength, span.end - start); if (sliceEnd > sliceStart) trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href }); } return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks }; } function appendCell(state, cell) { if (!cell.text) return; const start = state.text.length; state.text += cell.text; for (const span of cell.styles) state.styles.push({ start: start + span.start, end: start + span.end, style: span.style }); for (const link of cell.links) state.links.push({ start: start + link.start, end: start + link.end, href: link.href }); } function renderTableAsBullets(state) { if (!state.table) return; const headers = state.table.headers.map(trimCell); const rows = state.table.rows.map((row) => row.map(trimCell)); if (headers.length === 0 && rows.length === 0) return; if (headers.length > 1 && rows.length > 0) for (const row of rows) { if (row.length === 0) continue; const rowLabel = row[0]; if (rowLabel?.text) { const labelStart = state.text.length; appendCell(state, rowLabel); const labelEnd = state.text.length; if (labelEnd > labelStart) state.styles.push({ start: labelStart, end: labelEnd, style: "bold" }); state.text += "\n"; } for (let i = 1; i < row.length; i++) { const header = headers[i]; const value = row[i]; if (!value?.text) continue; state.text += "• "; if (header?.text) { appendCell(state, header); state.text += ": "; } else state.text += `Column ${i}: `; appendCell(state, value); state.text += "\n"; } state.text += "\n"; } else for (const row of rows) { for (let i = 0; i < row.length; i++) { const header = headers[i]; const value = row[i]; if (!value?.text) continue; state.text += "• "; if (header?.text) { appendCell(state, header); state.text += ": "; } appendCell(state, value); state.text += "\n"; } state.text += "\n"; } } function renderTableAsCode(state) { if (!state.table) return; const headers = state.table.headers.map(trimCell); const rows = state.table.rows.map((row) => row.map(trimCell)); const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); if (columnCount === 0) return; const widths = Array.from({ length: columnCount }, () => 0); const updateWidths = (cells) => { for (let i = 0; i < columnCount; i += 1) { const width = cells[i]?.text.length ?? 0; if (widths[i] < width) widths[i] = width; } }; updateWidths(headers); for (const row of rows) updateWidths(row); const codeStart = state.text.length; const appendRow = (cells) => { state.text += "|"; for (let i = 0; i < columnCount; i += 1) { state.text += " "; const cell = cells[i]; if (cell) appendCell(state, cell); const pad = widths[i] - (cell?.text.length ?? 0); if (pad > 0) state.text += " ".repeat(pad); state.text += " |"; } state.text += "\n"; }; const appendDivider = () => { state.text += "|"; for (let i = 0; i < columnCount; i += 1) { const dashCount = Math.max(3, widths[i]); state.text += ` ${"-".repeat(dashCount)} |`; } state.text += "\n"; }; appendRow(headers); appendDivider(); for (const row of rows) appendRow(row); const codeEnd = state.text.length; if (codeEnd > codeStart) state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" }); if (state.env.listStack.length === 0) state.text += "\n"; } function renderTokens(tokens, state) { for (const token of tokens) switch (token.type) { case "inline": if (token.children) renderTokens(token.children, state); break; case "text": appendText(state, token.content ?? ""); break; case "em_open": openStyle(state, "italic"); break; case "em_close": closeStyle(state, "italic"); break; case "strong_open": openStyle(state, "bold"); break; case "strong_close": closeStyle(state, "bold"); break; case "s_open": openStyle(state, "strikethrough"); break; case "s_close": closeStyle(state, "strikethrough"); break; case "code_inline": renderInlineCode(state, token.content ?? ""); break; case "spoiler_open": if (state.enableSpoilers) openStyle(state, "spoiler"); break; case "spoiler_close": if (state.enableSpoilers) closeStyle(state, "spoiler"); break; case "link_open": { const href = getAttr(token, "href") ?? ""; const target = resolveRenderTarget(state); target.linkStack.push({ href, labelStart: target.text.length }); break; } case "link_close": handleLinkClose(state); break; case "image": appendText(state, token.content ?? ""); break; case "softbreak": case "hardbreak": appendText(state, "\n"); break; case "paragraph_close": appendParagraphSeparator(state); break; case "heading_open": if (state.headingStyle === "bold") openStyle(state, "bold"); break; case "heading_close": if (state.headingStyle === "bold") closeStyle(state, "bold"); appendParagraphSeparator(state); break; case "blockquote_open": if (state.blockquotePrefix) state.text += state.blockquotePrefix; break; case "blockquote_close": state.text += "\n"; break; case "bullet_list_open": state.env.listStack.push({ type: "bullet", index: 0 }); break; case "bullet_list_close": state.env.listStack.pop(); break; case "ordered_list_open": { const start = Number(getAttr(token, "start") ?? "1"); state.env.listStack.push({ type: "ordered", index: start - 1 }); break; } case "ordered_list_close": state.env.listStack.pop(); break; case "list_item_open": appendListPrefix(state); break; case "list_item_close": state.text += "\n"; break; case "code_block": case "fence": renderCodeBlock(state, token.content ?? ""); break; case "html_block": case "html_inline": appendText(state, token.content ?? ""); break; case "table_open": if (state.tableMode !== "off") { state.table = initTableState(); state.hasTables = true; } break; case "table_close": if (state.table) { if (state.tableMode === "bullets") renderTableAsBullets(state); else if (state.tableMode === "code") renderTableAsCode(state); } state.table = null; break; case "thead_open": if (state.table) state.table.inHeader = true; break; case "thead_close": if (state.table) state.table.inHeader = false; break; case "tbody_open": case "tbody_close": break; case "tr_open": if (state.table) state.table.currentRow = []; break; case "tr_close": if (state.table) { if (state.table.inHeader) state.table.headers = state.table.currentRow; else state.table.rows.push(state.table.currentRow); state.table.currentRow = []; } break; case "th_open": case "td_open": if (state.table) state.table.currentCell = initRenderTarget(); break; case "th_close": case "td_close": if (state.table?.currentCell) { state.table.currentRow.push(finishTableCell(state.table.currentCell)); state.table.currentCell = null; } break; case "hr": state.text += "\n"; break; default: if (token.children) renderTokens(token.children, state); break; } } function closeRemainingStyles(target) { for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { const open = target.openStyles[i]; const end = target.text.length; if (end > open.start) target.styles.push({ start: open.start, end, style: open.style }); } target.openStyles = []; } function clampStyleSpans(spans, maxLength) { const clamped = []; for (const span of spans) { const start = Math.max(0, Math.min(span.start, maxLength)); const end = Math.max(start, Math.min(span.end, maxLength)); if (end > start) clamped.push({ start, end, style: span.style }); } return clamped; } function clampLinkSpans(spans, maxLength) { const clamped = []; for (const span of spans) { const start = Math.max(0, Math.min(span.start, maxLength)); const end = Math.max(start, Math.min(span.end, maxLength)); if (end > start) clamped.push({ start, end, href: span.href }); } return clamped; } function mergeStyleSpans(spans) { const sorted = [...spans].toSorted((a, b) => { if (a.start !== b.start) return a.start - b.start; if (a.end !== b.end) return a.end - b.end; return a.style.localeCompare(b.style); }); const merged = []; for (const span of sorted) { const prev = merged[merged.length - 1]; if (prev && prev.style === span.style && span.start <= prev.end) { prev.end = Math.max(prev.end, span.end); continue; } merged.push({ ...span }); } return merged; } function sliceStyleSpans(spans, start, end) { if (spans.length === 0) return []; const sliced = []; for (const span of spans) { const sliceStart = Math.max(span.start, start); const sliceEnd = Math.min(span.end, end); if (sliceEnd > sliceStart) sliced.push({ start: sliceStart - start, end: sliceEnd - start, style: span.style }); } return mergeStyleSpans(sliced); } function sliceLinkSpans(spans, start, end) { if (spans.length === 0) return []; const sliced = []; for (const span of spans) { const sliceStart = Math.max(span.start, start); const sliceEnd = Math.min(span.end, end); if (sliceEnd > sliceStart) sliced.push({ start: sliceStart - start, end: sliceEnd - start, href: span.href }); } return sliced; } function markdownToIR(markdown, options = {}) { return markdownToIRWithMeta(markdown, options).ir; } function markdownToIRWithMeta(markdown, options = {}) { const env = { listStack: [] }; const tokens = createMarkdownIt(options).parse(markdown ?? "", env); if (options.enableSpoilers) applySpoilerTokens(tokens); const tableMode = options.tableMode ?? "off"; const state = { text: "", styles: [], openStyles: [], links: [], linkStack: [], env, headingStyle: options.headingStyle ?? "none", blockquotePrefix: options.blockquotePrefix ?? "", enableSpoilers: options.enableSpoilers ?? false, tableMode, table: null, hasTables: false }; renderTokens(tokens, state); closeRemainingStyles(state); const trimmedLength = state.text.trimEnd().length; let codeBlockEnd = 0; for (const span of state.styles) { if (span.style !== "code_block") continue; if (span.end > codeBlockEnd) codeBlockEnd = span.end; } const finalLength = Math.max(trimmedLength, codeBlockEnd); return { ir: { text: finalLength === state.text.length ? state.text : state.text.slice(0, finalLength), styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), links: clampLinkSpans(state.links, finalLength) }, hasTables: state.hasTables }; } function chunkMarkdownIR(ir, limit) { if (!ir.text) return []; if (limit <= 0 || ir.text.length <= limit) return [ir]; const chunks = chunkText(ir.text, limit); const results = []; let cursor = 0; chunks.forEach((chunk, index) => { if (!chunk) return; if (index > 0) while (cursor < ir.text.length && /\s/.test(ir.text[cursor] ?? "")) cursor += 1; const start = cursor; const end = Math.min(ir.text.length, start + chunk.length); results.push({ text: chunk, styles: sliceStyleSpans(ir.styles, start, end), links: sliceLinkSpans(ir.links, start, end) }); cursor = end; }); return results; } //#endregion //#region src/infra/fetch.ts function withDuplex(init, input) { const hasInitBody = init?.body != null; const hasRequestBody = !hasInitBody && typeof Request !== "undefined" && input instanceof Request && input.body != null; if (!hasInitBody && !hasRequestBody) return init; if (init && "duplex" in init) return init; return init ? { ...init, duplex: "half" } : { duplex: "half" }; } function wrapFetchWithAbortSignal(fetchImpl) { const wrapped = ((input, init) => { const patchedInit = withDuplex(init, input); const signal = patchedInit?.signal; if (!signal) return fetchImpl(input, patchedInit); if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) return fetchImpl(input, patchedInit); if (typeof AbortController === "undefined") return fetchImpl(input, patchedInit); if (typeof signal.addEventListener !== "function") return fetchImpl(input, patchedInit); const controller = new AbortController(); const onAbort = () => controller.abort(); if (signal.aborted) controller.abort(); else signal.addEventListener("abort", onAbort, { once: true }); const response = fetchImpl(input, { ...patchedInit, signal: controller.signal }); if (typeof signal.removeEventListener === "function") response.finally(() => { signal.removeEventListener("abort", onAbort); }); return response; }); const fetchWithPreconnect = fetchImpl; wrapped.preconnect = typeof fetchWithPreconnect.preconnect === "function" ? fetchWithPreconnect.preconnect.bind(fetchWithPreconnect) : () => {}; return Object.assign(wrapped, fetchImpl); } function resolveFetch(fetchImpl) { const resolved = fetchImpl ?? globalThis.fetch; if (!resolved) return; return wrapFetchWithAbortSignal(resolved); } //#endregion //#region src/utils/directive-tags.ts const AUDIO_TAG_RE = /\[\[\s*audio_as_voice\s*\]\]/gi; const REPLY_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi; function normalizeDirectiveWhitespace(text) { return text.replace(/[ \t]+/g, " ").replace(/[ \t]*\n[ \t]*/g, "\n").trim(); } function parseInlineDirectives(text, options = {}) { const { currentMessageId, stripAudioTag = true, stripReplyTags = true } = options; if (!text) return { text: "", audioAsVoice: false, replyToCurrent: false, hasAudioTag: false, hasReplyTag: false }; let cleaned = text; let audioAsVoice = false; let hasAudioTag = false; let hasReplyTag = false; let sawCurrent = false; let lastExplicitId; cleaned = cleaned.replace(AUDIO_TAG_RE, (match) => { audioAsVoice = true; hasAudioTag = true; return stripAudioTag ? " " : match; }); cleaned = cleaned.replace(REPLY_TAG_RE, (match, idRaw) => { hasReplyTag = true; if (idRaw === void 0) sawCurrent = true; else { const id = idRaw.trim(); if (id) lastExplicitId = id; } return stripReplyTags ? " " : match; }); cleaned = normalizeDirectiveWhitespace(cleaned); const replyToId = lastExplicitId ?? (sawCurrent ? currentMessageId?.trim() || void 0 : void 0); return { text: cleaned, audioAsVoice, replyToId, replyToExplicitId: lastExplicitId, replyToCurrent: sawCurrent, hasAudioTag, hasReplyTag }; } //#endregion //#region src/media/audio-tags.ts /** * Extract audio mode tag from text. * Supports [[audio_as_voice]] to send audio as voice bubble instead of file. * Default is file (preserves backward compatibility). */ function parseAudioTag(text) { const result = parseInlineDirectives(text, { stripReplyTags: false }); return { text: result.text, audioAsVoice: result.audioAsVoice, hadTag: result.hasAudioTag }; } //#endregion //#region src/media/parse.ts const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi; function normalizeMediaSource(src) { return src.startsWith("file://") ? src.replace("file://", "") : src; } function cleanCandidate(raw) { return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, ""); } function isValidMedia(candidate, opts) { if (!candidate) return false; if (candidate.length > 4096) return false; if (!opts?.allowSpaces && /\s/.test(candidate)) return false; if (/^https?:\/\//i.test(candidate)) return true; return candidate.startsWith("./") && !candidate.includes(".."); } function unwrapQuoted(value) { const trimmed = value.trim(); if (trimmed.length < 2) return; const first = trimmed[0]; if (first !== trimmed[trimmed.length - 1]) return; if (first !== `"` && first !== "'" && first !== "`") return; return trimmed.slice(1, -1).trim(); } function isInsideFence(fenceSpans, offset) { return fenceSpans.some((span) => offset >= span.start && offset < s