Сделать DM body opaque для сервера и улучшить fallback UI

This commit is contained in:
AidarKC
2026-07-10 18:12:17 +04:00
parent cc1f78106d
commit 076b43e542
10 changed files with 190 additions and 56 deletions
+15 -1
View File
@@ -239,6 +239,9 @@ const DM_MAX_ENCRYPTED_BODY_BYTES = 16384;
const DM_CRYPTO_METHOD_X25519_HKDF_AES_GCM = 1;
const DM_CRYPTO_VERSION_1_0 = 0;
const DM_HKDF_INFO = utf8Bytes('SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM');
const DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT = 'unsupported_format';
const DM_DECRYPT_ERROR_DECRYPT_FAILED = 'decrypt_failed';
const DM_DECRYPT_ERROR_CORRUPTED = 'corrupted';
function ensureAsciiBytes(value, field, min = 1, max = 60) {
const text = String(value || '').trim();
@@ -330,6 +333,14 @@ function parseEncryptedDmBodyBytes(payloadBytes) {
};
}
function classifyDmDecryptFailure(error) {
const code = String(error?.message || '').trim();
if (code === 'BAD_ENCRYPTED_BODY') return DM_DECRYPT_ERROR_CORRUPTED;
if (code === 'Неподдерживаемый метод шифрования DM') return DM_DECRYPT_ERROR_UNSUPPORTED_FORMAT;
if (code === 'OperationError') return DM_DECRYPT_ERROR_DECRYPT_FAILED;
return DM_DECRYPT_ERROR_DECRYPT_FAILED;
}
function parseReadReceiptBodyBytes(payloadBytes) {
if (!(payloadBytes instanceof Uint8Array)) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
let o = 0;
@@ -435,7 +446,6 @@ function parseSignedMessageBlockBytes(bytes) {
let readReceiptPayload = null;
if (messageType === DM_TYPE_INCOMING || messageType === DM_TYPE_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_MESSAGE_LEN');
encryptedBodyPayload = parseEncryptedDmBodyBytes(bodyBytes);
} else if (messageType === DM_TYPE_READ_INCOMING || messageType === DM_TYPE_READ_OUTGOING_COPY) {
if (bodyBytes.length === 0) throw new Error('BAD_RECEIPT_PAYLOAD_LEN');
if (revisionTimeMs !== 0 || reencryptedAtMs !== 0) throw new Error('BAD_RECEIPT_REVISION');
@@ -2334,6 +2344,10 @@ export class AuthService {
return parseReadReceiptBodyBytes(payloadBytes);
}
classifyDmDecryptFailure(error) {
return classifyDmDecryptFailure(error);
}
async decryptSignedMessageContent({ parsed, blobB64 = '', login, storagePwd }) {
const parsedBlock = parsed || this.parseSignedMessageBlob(blobB64);
const messageType = Number(parsedBlock?.messageType || 0);
+86 -47
View File
@@ -1,4 +1,4 @@
import { addSystemChatMessage, authService, authorizeSession, state } from '../state.js';
import { addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
const TYPES = {
INVITE: 100,
@@ -100,6 +100,88 @@ function formatDuration(ms) {
return `${sec}с`;
}
function isInviteUndelivered(call) {
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
const pushDelivered = Number(
call?.inviteDelivery?.deliveredWebPushSessions
|| call?.inviteDelivery?.deliveredFcmSessions
|| 0
);
return (wsDelivered + pushDelivered) <= 0;
}
function buildOutgoingCallSummaryText(call, summaryCode) {
if (!call || call.direction !== 'out') return '';
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
return `Звонок: ${duration}`;
}
if (summaryCode === 'busy') {
return 'Звонил, но недозвонился: абонент занят';
}
if (summaryCode === 'declined') {
return 'Звонил, но недозвонился: звонок отклонён';
}
if (summaryCode === 'no_answer') {
if (isInviteUndelivered(call)) {
return 'Звонил, но недозвонился: абонент не в сети';
}
return 'Звонил, но недозвонился: нет ответа';
}
if (summaryCode === 'error') {
return 'Звонил, но недозвонился: не удалось установить соединение';
}
return '';
}
async function applyLocalOutgoingDmCopy(peerLogin, result) {
const localOutgoingBlobB64 = String(result?.localOutgoingBlobB64 || '').trim();
if (!peerLogin || !localOutgoingBlobB64) return false;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
addSignedMessageToChat({
chatId: peerLogin,
messageKey: String(result?.outgoingKey || parsed?.messageKey || ''),
baseKey: String(result?.baseKey || result?.localBaseKey || parsed?.baseKey || ''),
from: 'out',
text: String(decrypted?.text || ''),
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deleted: Boolean(parsed?.deleted),
});
return true;
} catch {
return false;
}
}
async function sendOutgoingCallSummaryDm(call, summaryCode) {
if (!call || call.direction !== 'out' || !call.peerLogin) return;
const text = buildOutgoingCallSummaryText(call, summaryCode);
if (!text) return;
const login = String(state?.session?.login || '').trim();
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
if (!login || !storagePwd) return;
try {
const result = await authService.sendDirectMessage({
login,
toLogin: call.peerLogin,
text,
storagePwd,
});
await applyLocalOutgoingDmCopy(call.peerLogin, result);
} catch (error) {
await emitDebug(call, 'warn', 'call_summary_dm_failed', toErrorText(error));
}
}
function cloneDefaultIceServers() {
return DEFAULT_ICE_SERVERS.map((row) => ({ ...row }));
}
@@ -791,49 +873,6 @@ function startReconnectFlow(call, reason = 'disconnected') {
void runAttempt();
}
function pushCallSummary(call, summaryCode) {
if (!call?.peerLogin) return;
const outgoing = call.direction === 'out';
const from = outgoing ? 'out' : 'in';
if (summaryCode === 'busy') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (занято)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'no_answer') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (нет ответа)'
: `[Звонок] Пропущенный входящий от ${call.peerLogin}`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'declined') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (отклонён)'
: `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'error') {
const text = outgoing
? '[Звонок] Исходящий: не дозвонились (ошибка соединения)'
: `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`;
addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' });
return;
}
if (summaryCode === 'completed') {
const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs()));
const label = outgoing ? 'Исходящий' : 'Входящий';
addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, {
from,
kind: 'call-tech',
});
}
}
async function finalizeCall(call, {
localReasonCode = 'error',
debugReason = '',
@@ -902,7 +941,7 @@ async function finalizeCall(call, {
}
if (!suppressSummary) {
pushCallSummary(call, localReasonCode);
await sendOutgoingCallSummaryDm(call, localReasonCode);
}
call.phase = 'ended';
@@ -1424,7 +1463,7 @@ export async function startOutgoingCall(peerLogin) {
}, 35000);
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
} catch (error) {
const text = String(error?.message || '').toUpperCase();
const isNotAuth = text.includes('NOT_AUTHENTICATED');
@@ -1432,7 +1471,7 @@ export async function startOutgoingCall(peerLogin) {
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
if (recovered) {
try {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
return;
} catch {}
}