UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

534 lines (529 loc) 17.2 kB
import { t as __exportAll } from "./rolldown-runtime-Cbj13DAv.js"; import { i as loadConfig } from "./config-B2kL1ciP.js"; import { n as resolveSignalAccount } from "./accounts-B3D4iWUP.js"; import { v as mediaKindFromMime } from "./image-ops-lDlFpoR2.js"; import { n as resolveMarkdownTableMode } from "./markdown-tables-GCHx-nGT.js"; import { n as fetchWithTimeout } from "./fetch-timeout-L2NTusph.js"; import { n as markdownToIR, t as chunkMarkdownIR } from "./ir-DAwVi0a7.js"; import { t as resolveFetch } from "./fetch-vg2oFVIH.js"; import { t as resolveOutboundAttachmentFromUrl } from "./outbound-attachment-CdFWGHgJ.js"; import { randomUUID } from "node:crypto"; //#region src/signal/format.ts function normalizeUrlForComparison(url) { let normalized = url.toLowerCase(); normalized = normalized.replace(/^https?:\/\//, ""); normalized = normalized.replace(/^www\./, ""); normalized = normalized.replace(/\/+$/, ""); return normalized; } function mapStyle(style) { switch (style) { case "bold": return "BOLD"; case "italic": return "ITALIC"; case "strikethrough": return "STRIKETHROUGH"; case "code": case "code_block": return "MONOSPACE"; case "spoiler": return "SPOILER"; default: return null; } } function mergeStyles(styles) { const sorted = [...styles].toSorted((a, b) => { if (a.start !== b.start) return a.start - b.start; if (a.length !== b.length) return a.length - b.length; return a.style.localeCompare(b.style); }); const merged = []; for (const style of sorted) { const prev = merged[merged.length - 1]; if (prev && prev.style === style.style && style.start <= prev.start + prev.length) { const prevEnd = prev.start + prev.length; prev.length = Math.max(prevEnd, style.start + style.length) - prev.start; continue; } merged.push({ ...style }); } return merged; } function clampStyles(styles, maxLength) { const clamped = []; for (const style of styles) { const start = Math.max(0, Math.min(style.start, maxLength)); const length = Math.min(style.start + style.length, maxLength) - start; if (length > 0) clamped.push({ start, length, style: style.style }); } return clamped; } function applyInsertionsToStyles(spans, insertions) { if (insertions.length === 0) return spans; const sortedInsertions = [...insertions].toSorted((a, b) => a.pos - b.pos); let updated = spans; let cumulativeShift = 0; for (const insertion of sortedInsertions) { const insertionPos = insertion.pos + cumulativeShift; const next = []; for (const span of updated) { if (span.end <= insertionPos) { next.push(span); continue; } if (span.start >= insertionPos) { next.push({ start: span.start + insertion.length, end: span.end + insertion.length, style: span.style }); continue; } if (span.start < insertionPos && span.end > insertionPos) { if (insertionPos > span.start) next.push({ start: span.start, end: insertionPos, style: span.style }); const shiftedStart = insertionPos + insertion.length; const shiftedEnd = span.end + insertion.length; if (shiftedEnd > shiftedStart) next.push({ start: shiftedStart, end: shiftedEnd, style: span.style }); } } updated = next; cumulativeShift += insertion.length; } return updated; } function renderSignalText(ir) { const text = ir.text ?? ""; if (!text) return { text: "", styles: [] }; const sortedLinks = [...ir.links].toSorted((a, b) => a.start - b.start); let out = ""; let cursor = 0; const insertions = []; for (const link of sortedLinks) { if (link.start < cursor) continue; out += text.slice(cursor, link.end); const href = link.href.trim(); const trimmedLabel = text.slice(link.start, link.end).trim(); if (href) if (!trimmedLabel) { out += href; insertions.push({ pos: link.end, length: href.length }); } else { const normalizedLabel = normalizeUrlForComparison(trimmedLabel); let comparableHref = href; if (href.startsWith("mailto:")) comparableHref = href.slice(7); if (normalizedLabel !== normalizeUrlForComparison(comparableHref)) { const addition = ` (${href})`; out += addition; insertions.push({ pos: link.end, length: addition.length }); } } cursor = link.end; } out += text.slice(cursor); const adjusted = applyInsertionsToStyles(ir.styles.map((span) => { const mapped = mapStyle(span.style); if (!mapped) return null; return { start: span.start, end: span.end, style: mapped }; }).filter((span) => span !== null), insertions); const trimmedText = out.trimEnd(); const trimmedLength = trimmedText.length; return { text: trimmedText, styles: mergeStyles(clampStyles(adjusted.map((span) => ({ start: span.start, length: span.end - span.start, style: span.style })), trimmedLength)) }; } function markdownToSignalText(markdown, options = {}) { return renderSignalText(markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "bold", blockquotePrefix: "> ", tableMode: options.tableMode })); } function sliceSignalStyles(styles, start, end) { const sliced = []; for (const style of styles) { const styleEnd = style.start + style.length; const sliceStart = Math.max(style.start, start); const sliceEnd = Math.min(styleEnd, end); if (sliceEnd > sliceStart) sliced.push({ start: sliceStart - start, length: sliceEnd - sliceStart, style: style.style }); } return sliced; } /** * Split Signal formatted text into chunks under the limit while preserving styles. * * This implementation deterministically tracks cursor position without using indexOf, * which is fragile when chunks are trimmed or when duplicate substrings exist. * Styles spanning chunk boundaries are split into separate ranges for each chunk. */ function splitSignalFormattedText(formatted, limit) { const { text, styles } = formatted; if (text.length <= limit) return [formatted]; const results = []; let remaining = text; let offset = 0; while (remaining.length > 0) { if (remaining.length <= limit) { const trimmed = remaining.trimEnd(); if (trimmed.length > 0) results.push({ text: trimmed, styles: mergeStyles(sliceSignalStyles(styles, offset, offset + trimmed.length)) }); break; } let breakIdx = findBreakIndex(remaining.slice(0, limit)); if (breakIdx <= 0) breakIdx = limit; const chunk = remaining.slice(0, breakIdx).trimEnd(); if (chunk.length > 0) results.push({ text: chunk, styles: mergeStyles(sliceSignalStyles(styles, offset, offset + chunk.length)) }); const brokeOnWhitespace = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnWhitespace ? 1 : 0)); remaining = remaining.slice(nextStart).trimStart(); offset = text.length - remaining.length; } return results; } /** * Find the best break index within a text window. * Prefers newlines over whitespace, avoids breaking inside parentheses. */ function findBreakIndex(window) { let lastNewline = -1; let lastWhitespace = -1; let parenDepth = 0; for (let i = 0; i < window.length; i++) { const char = window[i]; if (char === "(") { parenDepth++; continue; } if (char === ")" && parenDepth > 0) { parenDepth--; continue; } if (parenDepth === 0) { if (char === "\n") lastNewline = i; else if (/\s/.test(char)) lastWhitespace = i; } } return lastNewline > 0 ? lastNewline : lastWhitespace; } function markdownToSignalTextChunks(markdown, limit, options = {}) { const chunks = chunkMarkdownIR(markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "bold", blockquotePrefix: "> ", tableMode: options.tableMode }), limit); const results = []; for (const chunk of chunks) { const rendered = renderSignalText(chunk); if (rendered.text.length > limit) results.push(...splitSignalFormattedText(rendered, limit)); else results.push(rendered); } return results; } //#endregion //#region src/signal/client.ts const DEFAULT_TIMEOUT_MS = 1e4; function normalizeBaseUrl(url) { const trimmed = url.trim(); if (!trimmed) throw new Error("Signal base URL is required"); if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, ""); return `http://${trimmed}`.replace(/\/+$/, ""); } function getRequiredFetch() { const fetchImpl = resolveFetch(); if (!fetchImpl) throw new Error("fetch is not available"); return fetchImpl; } async function signalRpcRequest(method, params, opts) { const baseUrl = normalizeBaseUrl(opts.baseUrl); const id = randomUUID(); const body = JSON.stringify({ jsonrpc: "2.0", method, params, id }); const res = await fetchWithTimeout(`${baseUrl}/api/v1/rpc`, { method: "POST", headers: { "Content-Type": "application/json" }, body }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, getRequiredFetch()); if (res.status === 201) return; const text = await res.text(); if (!text) throw new Error(`Signal RPC empty response (status ${res.status})`); const parsed = JSON.parse(text); if (parsed.error) { const code = parsed.error.code ?? "unknown"; const msg = parsed.error.message ?? "Signal RPC error"; throw new Error(`Signal RPC ${code}: ${msg}`); } return parsed.result; } async function signalCheck(baseUrl, timeoutMs = DEFAULT_TIMEOUT_MS) { const normalized = normalizeBaseUrl(baseUrl); try { const res = await fetchWithTimeout(`${normalized}/api/v1/check`, { method: "GET" }, timeoutMs, getRequiredFetch()); if (!res.ok) return { ok: false, status: res.status, error: `HTTP ${res.status}` }; return { ok: true, status: res.status, error: null }; } catch (err) { return { ok: false, status: null, error: err instanceof Error ? err.message : String(err) }; } } async function streamSignalEvents(params) { const baseUrl = normalizeBaseUrl(params.baseUrl); const url = new URL(`${baseUrl}/api/v1/events`); if (params.account) url.searchParams.set("account", params.account); const fetchImpl = resolveFetch(); if (!fetchImpl) throw new Error("fetch is not available"); const res = await fetchImpl(url, { method: "GET", headers: { Accept: "text/event-stream" }, signal: params.abortSignal }); if (!res.ok || !res.body) throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let currentEvent = {}; const flushEvent = () => { if (!currentEvent.data && !currentEvent.event && !currentEvent.id) return; params.onEvent({ event: currentEvent.event, data: currentEvent.data, id: currentEvent.id }); currentEvent = {}; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lineEnd = buffer.indexOf("\n"); while (lineEnd !== -1) { let line = buffer.slice(0, lineEnd); buffer = buffer.slice(lineEnd + 1); if (line.endsWith("\r")) line = line.slice(0, -1); if (line === "") { flushEvent(); lineEnd = buffer.indexOf("\n"); continue; } if (line.startsWith(":")) { lineEnd = buffer.indexOf("\n"); continue; } const [rawField, ...rest] = line.split(":"); const field = rawField.trim(); const rawValue = rest.join(":"); const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; if (field === "event") currentEvent.event = value; else if (field === "data") currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value; else if (field === "id") currentEvent.id = value; lineEnd = buffer.indexOf("\n"); } } flushEvent(); } //#endregion //#region src/signal/rpc-context.ts function resolveSignalRpcContext(opts, accountInfo) { const hasBaseUrl = Boolean(opts.baseUrl?.trim()); const hasAccount = Boolean(opts.account?.trim()); const resolvedAccount = accountInfo || (!hasBaseUrl || !hasAccount ? resolveSignalAccount({ cfg: loadConfig(), accountId: opts.accountId }) : void 0); const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl; if (!baseUrl) throw new Error("Signal base URL is required"); return { baseUrl, account: opts.account?.trim() || resolvedAccount?.config.account?.trim() }; } //#endregion //#region src/signal/send.ts var send_exports = /* @__PURE__ */ __exportAll({ sendMessageSignal: () => sendMessageSignal, sendReadReceiptSignal: () => sendReadReceiptSignal, sendTypingSignal: () => sendTypingSignal }); function parseTarget(raw) { let value = raw.trim(); if (!value) throw new Error("Signal recipient is required"); if (value.toLowerCase().startsWith("signal:")) value = value.slice(7).trim(); const normalized = value.toLowerCase(); if (normalized.startsWith("group:")) return { type: "group", groupId: value.slice(6).trim() }; if (normalized.startsWith("username:")) return { type: "username", username: value.slice(9).trim() }; if (normalized.startsWith("u:")) return { type: "username", username: value.trim() }; return { type: "recipient", recipient: value }; } function buildTargetParams(target, allow) { if (target.type === "recipient") { if (!allow.recipient) return null; return { recipient: [target.recipient] }; } if (target.type === "group") { if (!allow.group) return null; return { groupId: target.groupId }; } if (target.type === "username") { if (!allow.username) return null; return { username: [target.username] }; } return null; } async function sendMessageSignal(to, text, opts = {}) { const cfg = loadConfig(); const accountInfo = resolveSignalAccount({ cfg, accountId: opts.accountId }); const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo); const target = parseTarget(to); let message = text ?? ""; let messageFromPlaceholder = false; let textStyles = []; const textMode = opts.textMode ?? "markdown"; const maxBytes = (() => { if (typeof opts.maxBytes === "number") return opts.maxBytes; if (typeof accountInfo.config.mediaMaxMb === "number") return accountInfo.config.mediaMaxMb * 1024 * 1024; if (typeof cfg.agents?.defaults?.mediaMaxMb === "number") return cfg.agents.defaults.mediaMaxMb * 1024 * 1024; return 8 * 1024 * 1024; })(); let attachments; if (opts.mediaUrl?.trim()) { const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, { localRoots: opts.mediaLocalRoots }); attachments = [resolved.path]; const kind = mediaKindFromMime(resolved.contentType ?? void 0); if (!message && kind) { message = kind === "image" ? "<media:image>" : `<media:${kind}>`; messageFromPlaceholder = true; } } if (message.trim() && !messageFromPlaceholder) if (textMode === "plain") textStyles = opts.textStyles ?? []; else { const tableMode = resolveMarkdownTableMode({ cfg, channel: "signal", accountId: accountInfo.accountId }); const formatted = markdownToSignalText(message, { tableMode }); message = formatted.text; textStyles = formatted.styles; } if (!message.trim() && (!attachments || attachments.length === 0)) throw new Error("Signal send requires text or media"); const params = { message }; if (textStyles.length > 0) params["text-style"] = textStyles.map((style) => `${style.start}:${style.length}:${style.style}`); if (account) params.account = account; if (attachments && attachments.length > 0) params.attachments = attachments; const targetParams = buildTargetParams(target, { recipient: true, group: true, username: true }); if (!targetParams) throw new Error("Signal recipient is required"); Object.assign(params, targetParams); const timestamp = (await signalRpcRequest("send", params, { baseUrl, timeoutMs: opts.timeoutMs }))?.timestamp; return { messageId: timestamp ? String(timestamp) : "unknown", timestamp }; } async function sendTypingSignal(to, opts = {}) { const { baseUrl, account } = resolveSignalRpcContext(opts); const targetParams = buildTargetParams(parseTarget(to), { recipient: true, group: true }); if (!targetParams) return false; const params = { ...targetParams }; if (account) params.account = account; if (opts.stop) params.stop = true; await signalRpcRequest("sendTyping", params, { baseUrl, timeoutMs: opts.timeoutMs }); return true; } async function sendReadReceiptSignal(to, targetTimestamp, opts = {}) { if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) return false; const { baseUrl, account } = resolveSignalRpcContext(opts); const targetParams = buildTargetParams(parseTarget(to), { recipient: true }); if (!targetParams) return false; const params = { ...targetParams, targetTimestamp, type: opts.type ?? "read" }; if (account) params.account = account; await signalRpcRequest("sendReceipt", params, { baseUrl, timeoutMs: opts.timeoutMs }); return true; } //#endregion export { resolveSignalRpcContext as a, streamSignalEvents as c, send_exports as i, markdownToSignalTextChunks as l, sendReadReceiptSignal as n, signalCheck as o, sendTypingSignal as r, signalRpcRequest as s, sendMessageSignal as t };