@gguf/claw
Version:
WhatsApp gateway CLI (Baileys web) with Pi RPC agent
1,576 lines (1,564 loc) • 83.1 kB
JavaScript
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