function defaultParsed(rawText = '') { const text = String(rawText || ''); return { rawText: text, prefixText: '', visibleText: text, displayText: text, blocks: [], replyRef: null, callSummary: null, fileAttachment: null, fileAttachments: [], }; } function hasTechPrefix(text = '', offset = 0) { return String(text || '').startsWith(' 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${minutes}:${String(seconds).padStart(2, '0')}`; } function buildCallDisplayText(callSummary) { if (!callSummary) return ''; if (callSummary.status === 'completed') { return `Звонок: ${formatCallDuration(callSummary.durationSec)}`; } const reason = String(callSummary.reason || '').trim().toLowerCase(); if (reason === 'offline') return 'Абонент не в сети'; if (reason === 'no_answer') return 'Нет ответа'; if (reason === 'connect_failed') return 'Не удалось установить соединение'; if (reason === 'busy') return 'Абонент занят'; if (reason === 'declined') return 'Звонок отклонён'; return 'Звонок не завершён'; } export function sanitizeUserDmTextForSend(rawText = '') { const text = String(rawText || ''); if (text.startsWith('`; } const cleanReason = String(reason || '').trim().toLowerCase(); return ``; } export function buildDmReplyTechBlock({ baseKey = '' } = {}) { const cleanBaseKey = String(baseKey || '').trim(); if (!cleanBaseKey) return ''; return ``; } function decodeFileField(value = '') { try { return decodeURIComponent(String(value || '')); } catch { return String(value || ''); } } function normalizeFileAttachment(fields = {}) { const version = Number(fields.v || 0); const id = String(fields.id || '').trim(); const url = decodeFileField(fields.url || '').trim(); const keyB64Url = String(fields.key || '').trim(); const name = decodeFileField(fields.name || '').trim() || 'file'; const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream'; const size = Number(fields.size || 0); const encryptedSize = Number(fields.encsize || 0); if (!id || !url || !keyB64Url || !Number.isFinite(size) || size < 0) return null; if (version >= 2) { const ivPrefixB64Url = String(fields.ivp || '').trim(); if (!ivPrefixB64Url) return null; const chunkSize = Number(fields.chunk || 0); const chunkCount = Number(fields.chunks || 0); const kind = String(fields.kind || 'file').trim().toLowerCase() === 'voice' ? 'voice' : 'file'; const durationMs = Math.max(0, Math.floor(Number(fields.dur || 0))); return { version, id, url, keyB64Url, ivPrefixB64Url, name, mime, size, encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 0, chunkCount: Number.isFinite(chunkCount) && chunkCount >= 0 ? Math.floor(chunkCount) : 0, torrentV2InfoHashB64Url: String(fields.th || '').trim(), torrentV2PiecesRootB64Url: String(fields.pr || '').trim(), kind, durationMs, }; } const ivB64Url = String(fields.iv || '').trim(); if (!ivB64Url) return null; return { version: version || 1, id, url, keyB64Url, ivB64Url, name, mime, size, encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, kind: 'file', durationMs: 0, }; } export function buildDmFileTechBlock(attachment = {}) { const version = Math.max(1, Math.floor(Number(attachment?.version || 1))); const id = String(attachment?.id || '').trim(); const url = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); const size = Math.max(0, Math.floor(Number(attachment?.size || 0))); const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0))); if (!id || !url || !keyB64Url) throw new Error('Не хватает данных для технического блока файла'); const name = encodeURIComponent(String(attachment?.name || 'file')); const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream')); const encodedUrl = encodeURIComponent(url); if (version >= 2) { const ivPrefix = String(attachment?.ivPrefixB64Url || '').trim(); if (!ivPrefix) throw new Error('Не хватает IV prefix для chunked-файла'); const chunkSize = Math.max(0, Math.floor(Number(attachment?.chunkSize || 0))); const chunkCount = Math.max(0, Math.floor(Number(attachment?.chunkCount || 0))); const torrentHash = String(attachment?.torrentV2InfoHashB64Url || '').trim(); const piecesRoot = String(attachment?.torrentV2PiecesRootB64Url || '').trim(); const kind = String(attachment?.kind || 'file') === 'voice' ? 'voice' : 'file'; const durationMs = Math.max(0, Math.floor(Number(attachment?.durationMs || 0))); return ``; } const ivB64Url = String(attachment?.ivB64Url || '').trim(); if (!ivB64Url) throw new Error('Не хватает IV для файла v1'); return ``; } export function parseDmTechBlocks(rawText = '') { const text = String(rawText || ''); if (!hasTechPrefix(text)) return defaultParsed(text); const blocks = []; let cursor = 0; let replyRef = null; let callSummary = null; let fileAttachment = null; const fileAttachments = []; while (hasTechPrefix(text, cursor)) { const end = text.indexOf('>', cursor); if (end < 0) { if (!blocks.length) return defaultParsed(text); break; } const prefixLength = getTechPrefixLength(text, cursor); if (!prefixLength) break; const inner = text.slice(cursor + prefixLength, end); const segments = inner.split(';').map((part) => String(part || '').trim()).filter(Boolean); if (!segments.length) { if (!blocks.length) return defaultParsed(text); break; } const kind = String(segments[0] || '').trim().toLowerCase(); const fields = {}; for (let i = 1; i < segments.length; i += 1) { const part = segments[i]; const eq = part.indexOf('='); if (eq <= 0) continue; const key = part.slice(0, eq).trim().toLowerCase(); const value = part.slice(eq + 1).trim(); if (key) fields[key] = value; } const block = { kind, version: Number(fields.v || 0), fields }; blocks.push(block); if (kind === 'reply' && !replyRef) { replyRef = parseReplyId(fields.id || ''); } else if (kind === 'call' && !callSummary) { const status = String(fields.status || '').trim().toLowerCase(); if (status === 'completed') { const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0))); callSummary = { status: 'completed', durationSec, }; } else { callSummary = { status: 'failed', reason: String(fields.reason || '').trim().toLowerCase(), }; } } else if (kind === 'file') { const attachment = normalizeFileAttachment(fields); if (attachment) { fileAttachments.push(attachment); if (!fileAttachment) fileAttachment = attachment; } } cursor = end + 1; } const visibleText = text.slice(cursor); const displayText = callSummary ? buildCallDisplayText(callSummary) : visibleText; return { rawText: text, prefixText: text.slice(0, cursor), visibleText, displayText, blocks, replyRef, callSummary, fileAttachment, fileAttachments, }; }