import { ASSISTANT_MCP_CHANNEL, ASSISTANT_MCP_PROXY_URL, ASSISTANT_MCP_TIMEOUT_MS } from "../config"; import iconv from "iconv-lite"; interface McpExecuteQueryResponse { success?: unknown; data?: unknown; error?: unknown; } export interface AddressMcpQueryResult { ok: boolean; rows: Array>; error: string | null; } export interface AddressMcpMetadataRowsResult { fetched_rows: number; raw_rows: Array>; rows: Array>; error: string | null; } function toStringValue(value: unknown): string { if (value === null || value === undefined) { return ""; } return String(value); } function parseFiniteNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string") { const parsed = Number(value.replace(",", ".").trim()); if (Number.isFinite(parsed)) { return parsed; } } return null; } function textMojibakeScore(value: string): number { const source = String(value ?? ""); const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const latin = (source.match(/[A-Za-z]/g) ?? []).length; const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/g) ?? []).length; const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length; return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2; } function looksLikeMojibake(value: string): boolean { const source = String(value ?? ""); if (!source.trim()) { return false; } if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ‘’“”•–—™љ›њќћџ]/.test(source)) { return true; } return (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2; } function decodeUtf8FromWin1251Mojibake(value: string): string { if (!looksLikeMojibake(value)) { return value; } try { const bytes = iconv.encode(value, "win1251"); const decoded = bytes.toString("utf8"); return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value; } catch { return value; } } function decodeUtf8FromLatin1Mojibake(value: string): string { if (!looksLikeMojibake(value)) { return value; } try { const decoded = Buffer.from(value, "latin1").toString("utf8"); return textMojibakeScore(decoded) > textMojibakeScore(value) ? decoded : value; } catch { return value; } } function normalizeMojibakeString(value: string): string { const fromWin1251 = decodeUtf8FromWin1251Mojibake(value); return decodeUtf8FromLatin1Mojibake(fromWin1251); } function normalizeMojibakeValue(value: unknown): unknown { if (typeof value === "string") { return normalizeMojibakeString(value); } if (Array.isArray(value)) { return value.map((item) => normalizeMojibakeValue(item)); } if (value && typeof value === "object") { const source = value as Record; const normalized: Record = {}; for (const [key, raw] of Object.entries(source)) { const repairedKey = normalizeMojibakeString(key); normalized[repairedKey] = normalizeMojibakeValue(raw); } return normalized; } return value; } function normalizeMojibakeRows(rows: Array>): Array> { return rows.map((row) => normalizeMojibakeValue(row) as Record); } function parseRowsFromTextTable(source: string): Array> { const normalized = normalizeMojibakeString(String(source ?? "")).replace(/\r/g, "").trim(); if (!normalized) { return []; } const headerMatch = normalized.match(/\{([^}]*)\}:/); if (!headerMatch) { return []; } const columns = String(headerMatch[1] ?? "") .split(",") .map((item) => item.replace(/^"+|"+$/g, "").trim()) .filter(Boolean); const body = normalized.slice((headerMatch.index ?? 0) + headerMatch[0].length).trim(); if (!body) { return []; } const rows: Array> = []; const parseCsvLine = (line: string): string[] => { const values: string[] = []; let current = ""; let inQuotes = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; if (char === '"') { if (inQuotes && line[index + 1] === '"') { current += '"'; index += 1; continue; } inQuotes = !inQuotes; continue; } if (char === "," && !inQuotes) { values.push(current.trim()); current = ""; continue; } current += char; } values.push(current.trim()); return values; }; const lines = body .split("\n") .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { const values = parseCsvLine(line); if (values.length === 0) { continue; } const row: Record = {}; for (let index = 0; index < columns.length; index += 1) { const key = columns[index] ?? `column_${index + 1}`; const raw = values[index] ?? ""; const parsed = parseFiniteNumber(raw); row[key] = parsed ?? raw; } if (values[0]) row.Period = values[0]; if (values[1]) row.Registrator = values[1]; if (values[2]) row.AccountDt = values[2]; if (values[3]) row.AccountKt = values[3]; if (values[4]) row.Amount = parseFiniteNumber(values[4]) ?? values[4]; rows.push(row); } return normalizeMojibakeRows(rows); } function parseRowsPayload( payload: unknown, options: { allowSingleObjectRow?: boolean; } = {} ): AddressMcpQueryResult { if (!payload || typeof payload !== "object") { return { ok: false, rows: [], error: "MCP payload is empty or malformed" }; } const source = payload as McpExecuteQueryResponse; if (source.success !== true) { return { ok: false, rows: [], error: toStringValue(source.error).trim() || "MCP execute_query returned success=false" }; } if (Array.isArray(source.data)) { const rows = normalizeMojibakeRows( source.data .map((item) => (item && typeof item === "object" ? (item as Record) : null)) .filter((item): item is Record => item !== null) ); return { ok: true, rows, error: null }; } if (typeof source.data === "string") { return { ok: true, rows: parseRowsFromTextTable(source.data), error: null }; } if (source.data && typeof source.data === "object" && Array.isArray((source.data as { rows?: unknown }).rows)) { const rows = normalizeMojibakeRows( ((source.data as { rows: unknown[] }).rows ?? []) .map((item) => (item && typeof item === "object" ? (item as Record) : null)) .filter((item): item is Record => item !== null) ); return { ok: true, rows, error: null }; } if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) { return { ok: true, rows: [normalizeMojibakeValue(source.data) as Record], error: null }; } return { ok: true, rows: [], error: null }; } function buildMcpUrl(endpoint: string): string { const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const separator = normalizedEndpoint.includes("?") ? "&" : "?"; return `${ASSISTANT_MCP_PROXY_URL}${normalizedEndpoint}${separator}channel=${encodeURIComponent(ASSISTANT_MCP_CHANNEL)}`; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function filterRowsByAccountScope( rows: Array>, accountScope: string[] ): Array> { if (accountScope.length === 0) { return rows; } const matchers = accountScope.map((account) => new RegExp(`\\b${escapeRegExp(account)}(?:\\.\\d{1,2})?\\b`, "i")); return rows.filter((row) => { const searchable = Object.values(row) .map((item) => String(item ?? "")) .join(" "); return matchers.some((matcher) => matcher.test(searchable)); }); } export async function executeAddressMcpQuery(input: { query: string; limit: number; account_scope?: string[]; timeout_ms?: number; }): Promise<{ fetched_rows: number; matched_rows: number; raw_rows: Array>; rows: Array>; error: string | null; }> { const endpoint = buildMcpUrl("/api/execute_query"); const controller = new AbortController(); const resolvedTimeoutMs = typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) ? Math.max(300, Math.trunc(input.timeout_ms)) : Math.max(300, ASSISTANT_MCP_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json; charset=utf-8" }, body: JSON.stringify({ query: input.query, limit: input.limit }), signal: controller.signal }); const responseText = await response.text(); if (!response.ok) { return { fetched_rows: 0, matched_rows: 0, raw_rows: [], rows: [], error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}` }; } const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {}; const parsed = parseRowsPayload(payload); if (!parsed.ok) { return { fetched_rows: 0, matched_rows: 0, raw_rows: [], rows: [], error: parsed.error }; } const filtered = filterRowsByAccountScope(parsed.rows, Array.isArray(input.account_scope) ? input.account_scope : []); return { fetched_rows: parsed.rows.length, matched_rows: filtered.length, raw_rows: parsed.rows, rows: filtered, error: null }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { fetched_rows: 0, matched_rows: 0, raw_rows: [], rows: [], error: `MCP fetch failed: ${message}` }; } finally { clearTimeout(timeout); } } export async function executeAddressMcpMetadata(input: { filter?: string; meta_type?: string | string[]; name_mask?: string; limit?: number; offset?: number; sections?: string[]; extension_name?: string | null; timeout_ms?: number; }): Promise { const endpoint = buildMcpUrl("/api/get_metadata"); const controller = new AbortController(); const resolvedTimeoutMs = typeof input.timeout_ms === "number" && Number.isFinite(input.timeout_ms) ? Math.max(300, Math.trunc(input.timeout_ms)) : Math.max(300, ASSISTANT_MCP_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), resolvedTimeoutMs); try { const body: Record = {}; if (typeof input.filter === "string" && input.filter.trim().length > 0) { body.filter = input.filter.trim(); } if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) { body.meta_type = input.meta_type.trim(); } else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) { const values = input.meta_type .map((item) => String(item ?? "").trim()) .filter((item) => item.length > 0); if (values.length > 0) { body.meta_type = values; } } if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) { body.name_mask = input.name_mask.trim(); } if (typeof input.limit === "number" && Number.isFinite(input.limit)) { body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit))); } if (typeof input.offset === "number" && Number.isFinite(input.offset)) { body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset))); } if (Array.isArray(input.sections) && input.sections.length > 0) { const sections = input.sections .map((item) => String(item ?? "").trim()) .filter((item) => item.length > 0); if (sections.length > 0) { body.sections = sections; } } if (input.extension_name !== undefined) { body.extension_name = input.extension_name; } const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json; charset=utf-8" }, body: JSON.stringify(body), signal: controller.signal }); const responseText = await response.text(); if (!response.ok) { return { fetched_rows: 0, raw_rows: [], rows: [], error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}` }; } const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {}; const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true }); if (!parsed.ok) { return { fetched_rows: 0, raw_rows: [], rows: [], error: parsed.error }; } return { fetched_rows: parsed.rows.length, raw_rows: parsed.rows, rows: parsed.rows, error: null }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { fetched_rows: 0, raw_rows: [], rows: [], error: `MCP fetch failed: ${message}` }; } finally { clearTimeout(timeout); } }