SHA256
212 lines
7.2 KiB
JavaScript
212 lines
7.2 KiB
JavaScript
function defaultParsed(rawText = '') {
|
|
const text = String(rawText || '');
|
|
return {
|
|
rawText: text,
|
|
prefixText: '',
|
|
visibleText: text,
|
|
displayText: text,
|
|
blocks: [],
|
|
replyRef: null,
|
|
callSummary: null,
|
|
fileAttachment: null,
|
|
};
|
|
}
|
|
|
|
function hasTechPrefix(text = '', offset = 0) {
|
|
return String(text || '').startsWith('<SHiNE:', offset) || String(text || '').startsWith('<S:', offset);
|
|
}
|
|
|
|
function getTechPrefixLength(text = '', offset = 0) {
|
|
if (String(text || '').startsWith('<SHiNE:', offset)) return 7;
|
|
if (String(text || '').startsWith('<S:', offset)) return 3;
|
|
return 0;
|
|
}
|
|
|
|
function parseReplyId(value = '') {
|
|
const parts = String(value || '').split('|');
|
|
if (parts.length !== 4) return null;
|
|
const fromLogin = String(parts[0] || '').trim();
|
|
const toLogin = String(parts[1] || '').trim();
|
|
const timeMs = Number(parts[2] || 0);
|
|
const nonce = Number(parts[3] || 0);
|
|
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || timeMs <= 0 || !Number.isFinite(nonce) || nonce < 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
fromLogin,
|
|
toLogin,
|
|
timeMs,
|
|
nonce,
|
|
baseKey: `${fromLogin}|${toLogin}|${timeMs}|${nonce}`,
|
|
};
|
|
}
|
|
|
|
function formatCallDuration(totalSeconds) {
|
|
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
|
|
const hours = Math.floor(total / 3600);
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|
const seconds = total % 60;
|
|
if (hours > 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('<SHiNE:')) return `< SHiNE:${text.slice(7)}`;
|
|
if (text.startsWith('<S:')) return `< S:${text.slice(3)}`;
|
|
return text;
|
|
}
|
|
|
|
export function buildDmCallTechBlock({ status = '', durationSec = 0, reason = '' } = {}) {
|
|
const cleanStatus = String(status || '').trim().toLowerCase();
|
|
if (cleanStatus === 'completed') {
|
|
return `<S:call;v=1;status=completed;duration=${Math.max(0, Math.floor(Number(durationSec || 0)))}>`;
|
|
}
|
|
const cleanReason = String(reason || '').trim().toLowerCase();
|
|
return `<S:call;v=1;status=failed;reason=${cleanReason || 'connect_failed'}>`;
|
|
}
|
|
|
|
export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
|
|
const cleanBaseKey = String(baseKey || '').trim();
|
|
if (!cleanBaseKey) return '';
|
|
return `<S:reply;v=1;id=${cleanBaseKey}>`;
|
|
}
|
|
|
|
function decodeFileField(value = '') {
|
|
try {
|
|
return decodeURIComponent(String(value || ''));
|
|
} catch {
|
|
return String(value || '');
|
|
}
|
|
}
|
|
|
|
function normalizeFileAttachment(fields = {}) {
|
|
const id = String(fields.id || '').trim();
|
|
const url = decodeFileField(fields.url || '').trim();
|
|
const keyB64Url = String(fields.key || '').trim();
|
|
const ivB64Url = String(fields.iv || '').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 || !ivB64Url || !Number.isFinite(size) || size < 0) return null;
|
|
return {
|
|
version: Number(fields.v || 0),
|
|
id,
|
|
url,
|
|
keyB64Url,
|
|
ivB64Url,
|
|
name,
|
|
mime,
|
|
size,
|
|
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
|
|
};
|
|
}
|
|
|
|
export function buildDmFileTechBlock(attachment = {}) {
|
|
const id = String(attachment?.id || '').trim();
|
|
const url = String(attachment?.url || '').trim();
|
|
const keyB64Url = String(attachment?.keyB64Url || '').trim();
|
|
const ivB64Url = String(attachment?.ivB64Url || '').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 || !ivB64Url) throw new Error('Не хватает данных для технического блока файла');
|
|
const name = encodeURIComponent(String(attachment?.name || 'file'));
|
|
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
|
|
const encodedUrl = encodeURIComponent(url);
|
|
return `<S:file;v=1;id=${id};url=${encodedUrl};key=${keyB64Url};iv=${ivB64Url};name=${name};mime=${mime};size=${size};encsize=${encryptedSize}>`;
|
|
}
|
|
|
|
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;
|
|
|
|
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' && !fileAttachment) {
|
|
fileAttachment = normalizeFileAttachment(fields);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|