SHA256
2735 lines
97 KiB
JavaScript
2735 lines
97 KiB
JavaScript
import { addAppLogEntry, addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
|
||
import { buildDmCallTechBlock } from './dm-tech-blocks.js';
|
||
|
||
const TYPES = {
|
||
INVITE: 100,
|
||
RINGING: 110,
|
||
ACCEPT: 120,
|
||
DECLINE_BUSY: 130,
|
||
TIMEOUT: 140,
|
||
HANGUP: 150,
|
||
CONNECT_START: 170,
|
||
OFFER: 200,
|
||
ANSWER: 210,
|
||
ICE: 220,
|
||
};
|
||
const SIGNAL_TARGET_SINGLE = 'single_session';
|
||
const SIGNAL_TARGET_ALL = 'all_sessions';
|
||
const CALL_SIGNAL_TYPES = Object.freeze({
|
||
[TYPES.INVITE]: 'call_invite',
|
||
[TYPES.RINGING]: 'call_ringing',
|
||
[TYPES.ACCEPT]: 'call_accept',
|
||
[TYPES.DECLINE_BUSY]: 'call_decline_busy',
|
||
[TYPES.TIMEOUT]: 'call_timeout',
|
||
[TYPES.HANGUP]: 'call_hangup',
|
||
[TYPES.CONNECT_START]: 'call_connect_start',
|
||
[TYPES.OFFER]: 'call_offer',
|
||
[TYPES.ANSWER]: 'call_answer',
|
||
[TYPES.ICE]: 'call_ice',
|
||
});
|
||
|
||
const calls = new Map();
|
||
const callStateListeners = new Set();
|
||
|
||
let activeCallId = '';
|
||
let debugReporter = null;
|
||
|
||
let audioContext = null;
|
||
let toneTimerId = null;
|
||
let toneName = '';
|
||
let toneFlip = false;
|
||
|
||
const DEFAULT_ICE_SERVERS = Object.freeze([
|
||
{ urls: 'stun:stun.l.google.com:19302' },
|
||
]);
|
||
const CALL_SUMMARY_MIN_TOTAL_MS = 5000;
|
||
const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000;
|
||
const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000;
|
||
const INCOMING_CONNECT_TIMEOUT_MS = 20_000;
|
||
const MAX_TIMELINE_ENTRIES = 64;
|
||
const CAMERA_CONSTRAINTS = Object.freeze({
|
||
width: { ideal: 1280 },
|
||
height: { ideal: 720 },
|
||
frameRate: { ideal: 24, max: 30 },
|
||
facingMode: 'user',
|
||
});
|
||
|
||
function nowMs() {
|
||
return Date.now();
|
||
}
|
||
|
||
function isMobileBrowser() {
|
||
const uaDataMobile = navigator?.userAgentData?.mobile;
|
||
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
|
||
const ua = String(navigator?.userAgent || '');
|
||
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
||
}
|
||
|
||
function canRouteAudioOutputInBrowser() {
|
||
const mediaProto = globalThis?.HTMLMediaElement?.prototype;
|
||
return Boolean(
|
||
window.isSecureContext
|
||
&& isMobileBrowser()
|
||
&& mediaProto
|
||
&& typeof mediaProto.setSinkId === 'function'
|
||
&& navigator?.mediaDevices
|
||
&& typeof navigator.mediaDevices.selectAudioOutput === 'function'
|
||
);
|
||
}
|
||
|
||
function sanitizeTimelineText(value) {
|
||
return String(value || '')
|
||
.replace(/\s+/g, ' ')
|
||
.replace(/[,]/g, ';')
|
||
.trim();
|
||
}
|
||
|
||
function getSignalTypeName(type) {
|
||
const normalized = Number(type);
|
||
if (normalized === TYPES.INVITE) return 'invite';
|
||
if (normalized === TYPES.RINGING) return 'ringing';
|
||
if (normalized === TYPES.ACCEPT) return 'accept';
|
||
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
|
||
if (normalized === TYPES.TIMEOUT) return 'timeout';
|
||
if (normalized === TYPES.HANGUP) return 'hangup';
|
||
if (normalized === TYPES.CONNECT_START) return 'connect_start';
|
||
if (normalized === TYPES.OFFER) return 'offer';
|
||
if (normalized === TYPES.ANSWER) return 'answer';
|
||
if (normalized === TYPES.ICE) return 'ice';
|
||
return `type_${String(type || '')}`;
|
||
}
|
||
|
||
function buildConnectStartData(winnerSessionId) {
|
||
return `session=${String(winnerSessionId || '').trim()}`;
|
||
}
|
||
|
||
function parseConnectStartWinnerSessionId(data = '') {
|
||
const raw = String(data || '').trim();
|
||
if (!raw) return '';
|
||
if (raw.startsWith('session=')) return raw.slice('session='.length).trim();
|
||
if (raw.startsWith('winnerSessionId=')) return raw.slice('winnerSessionId='.length).trim();
|
||
return '';
|
||
}
|
||
|
||
function getCallSignalType(type) {
|
||
return CALL_SIGNAL_TYPES[Number(type)] || '';
|
||
}
|
||
|
||
function encodeCallSignalData(callId, type, data = '') {
|
||
return JSON.stringify({
|
||
callId: String(callId || '').trim(),
|
||
type: Number(type),
|
||
data: String(data || ''),
|
||
});
|
||
}
|
||
|
||
function decodeCallSignalData(raw) {
|
||
try {
|
||
const parsed = JSON.parse(String(raw || '{}'));
|
||
return {
|
||
callId: String(parsed?.callId || '').trim(),
|
||
type: Number(parsed?.type),
|
||
data: String(parsed?.data || ''),
|
||
};
|
||
} catch {
|
||
return {
|
||
callId: '',
|
||
type: Number.NaN,
|
||
data: '',
|
||
};
|
||
}
|
||
}
|
||
|
||
function ensureCallTimeline(call) {
|
||
if (!call) return;
|
||
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
|
||
if (!Number.isFinite(call.timelineSeq)) call.timelineSeq = 0;
|
||
if (!Number.isFinite(call.timelineBaseMs) || call.timelineBaseMs <= 0) {
|
||
call.timelineBaseMs = Number(call.startedAtMs || nowMs());
|
||
}
|
||
}
|
||
|
||
function recordCallTimeline(call, stage, details = '', level = 'info') {
|
||
if (!call) return;
|
||
ensureCallTimeline(call);
|
||
const ts = nowMs();
|
||
const deltaMs = Math.max(0, ts - Number(call.timelineBaseMs || ts));
|
||
const cleanStage = sanitizeTimelineText(stage) || 'unknown';
|
||
const cleanDetails = sanitizeTimelineText(details);
|
||
call.timelineSeq += 1;
|
||
call.timelineEvents.push({
|
||
seq: call.timelineSeq,
|
||
ts,
|
||
deltaMs,
|
||
stage: cleanStage,
|
||
details: cleanDetails,
|
||
});
|
||
if (call.timelineEvents.length > MAX_TIMELINE_ENTRIES) {
|
||
call.timelineEvents.splice(0, call.timelineEvents.length - MAX_TIMELINE_ENTRIES);
|
||
}
|
||
addAppLogEntry({
|
||
level,
|
||
source: 'call',
|
||
message: `[${call.callId || '?'}] +${deltaMs}ms ${cleanStage}`,
|
||
details: cleanDetails || buildCallFactsJson(call),
|
||
});
|
||
}
|
||
|
||
function buildTimelineSummary(call) {
|
||
const events = Array.isArray(call?.timelineEvents) ? call.timelineEvents : [];
|
||
if (!events.length) return '';
|
||
return events
|
||
.map((entry) => {
|
||
const head = `+${Number(entry?.deltaMs || 0)}:${sanitizeTimelineText(entry?.stage || '')}`;
|
||
const tail = sanitizeTimelineText(entry?.details || '');
|
||
return tail ? `${head}(${tail})` : head;
|
||
})
|
||
.join('|')
|
||
.slice(0, 1800);
|
||
}
|
||
|
||
function setRemoteSessionId(call, sessionId, reason = '') {
|
||
if (!call) return;
|
||
const next = String(sessionId || '').trim();
|
||
if (!next) return;
|
||
const prev = String(call.remoteSessionId || '').trim();
|
||
if (prev === next) {
|
||
recordCallTimeline(call, 'remote_session_confirmed', `session=${next}; reason=${reason || 'same'}`);
|
||
return;
|
||
}
|
||
call.remoteSessionId = next;
|
||
recordCallTimeline(call, 'remote_session_selected', `from=${prev || '-'}; to=${next}; reason=${reason || 'unknown'}`);
|
||
}
|
||
|
||
function resolveCallPreflightTimeoutMs() {
|
||
const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000);
|
||
return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000));
|
||
}
|
||
|
||
function isSessionReadyForCall() {
|
||
const wsOpen = Boolean(authService?.ws?.ws && authService.ws.ws.readyState === WebSocket.OPEN);
|
||
const hasSession = Boolean(state?.session?.isAuthorized && state?.session?.login && state?.session?.sessionId);
|
||
return wsOpen && hasSession;
|
||
}
|
||
|
||
async function withTimeout(promise, timeoutMs, timeoutMessage = 'timeout') {
|
||
let timerId = 0;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise((_, reject) => {
|
||
timerId = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timerId) window.clearTimeout(timerId);
|
||
}
|
||
}
|
||
|
||
async function ensureSessionForCall({ timeoutMs, force = false } = {}) {
|
||
if (!force && isSessionReadyForCall()) return true;
|
||
const login = String(state?.session?.login || '').trim();
|
||
const sessionId = String(state?.session?.sessionId || '').trim();
|
||
if (!login || !sessionId) return false;
|
||
|
||
try {
|
||
await withTimeout(authService.ws.open(), timeoutMs, 'call_preflight_ws_timeout');
|
||
const resumed = await withTimeout(authService.resumeSession(login, sessionId), timeoutMs, 'call_preflight_resume_timeout');
|
||
authorizeSession(resumed);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function makeCallId() {
|
||
return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`;
|
||
}
|
||
|
||
function getCall(callId) {
|
||
return calls.get(callId) || null;
|
||
}
|
||
|
||
function getActiveCall() {
|
||
if (!activeCallId) return null;
|
||
return getCall(activeCallId);
|
||
}
|
||
|
||
function toErrorText(error) {
|
||
return error?.message || String(error || 'unknown');
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
const totalSec = Math.max(0, Math.round(Number(ms || 0) / 1000));
|
||
const sec = totalSec % 60;
|
||
const totalMin = Math.floor(totalSec / 60);
|
||
const min = totalMin % 60;
|
||
const hours = Math.floor(totalMin / 60);
|
||
if (hours > 0) return `${hours}ч ${min}м ${sec}с`;
|
||
if (min > 0) return `${min}м ${sec}с`;
|
||
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 getInviteDeliveredCount(call) {
|
||
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
|
||
const pushDelivered = Number(
|
||
call?.inviteDelivery?.deliveredWebPushSessions
|
||
|| call?.inviteDelivery?.deliveredFcmSessions
|
||
|| 0
|
||
);
|
||
return wsDelivered + pushDelivered;
|
||
}
|
||
|
||
function buildOutgoingCallSummaryText(call, summaryCode) {
|
||
if (!call || call.direction !== 'out') return '';
|
||
if (summaryCode === 'completed') {
|
||
return buildDmCallTechBlock({
|
||
status: 'completed',
|
||
durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000),
|
||
});
|
||
}
|
||
if (summaryCode === 'busy') {
|
||
return buildDmCallTechBlock({ status: 'failed', reason: 'busy' });
|
||
}
|
||
if (summaryCode === 'declined') {
|
||
return buildDmCallTechBlock({ status: 'failed', reason: 'declined' });
|
||
}
|
||
if (summaryCode === 'no_answer') {
|
||
if (isInviteUndelivered(call)) {
|
||
return buildDmCallTechBlock({ status: 'failed', reason: 'offline' });
|
||
}
|
||
return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' });
|
||
}
|
||
if (summaryCode === 'error') {
|
||
return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' });
|
||
}
|
||
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 totalMs = Math.max(0, nowMs() - Number(call.startedAtMs || nowMs()));
|
||
if (totalMs < CALL_SUMMARY_MIN_TOTAL_MS) 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 }));
|
||
}
|
||
|
||
function parseIceUrls(raw) {
|
||
if (Array.isArray(raw)) {
|
||
return raw
|
||
.map((item) => String(item || '').trim())
|
||
.filter((item) => item.length > 0);
|
||
}
|
||
const single = String(raw || '').trim();
|
||
if (!single) return [];
|
||
return [single];
|
||
}
|
||
|
||
function uniqueUrls(urls = []) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
urls.forEach((url) => {
|
||
const clean = String(url || '').trim();
|
||
if (!clean || seen.has(clean)) return;
|
||
seen.add(clean);
|
||
out.push(clean);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function parseTurnHostFromUrl(rawUrl) {
|
||
const value = String(rawUrl || '').trim();
|
||
if (!value) return '';
|
||
const noProto = value.replace(/^turns?:/i, '');
|
||
const noQuery = noProto.split('?')[0];
|
||
const noPath = noQuery.split('/')[0];
|
||
const hostPort = noPath.replace(/^\/\//, '').trim();
|
||
if (!hostPort) return '';
|
||
if (hostPort.startsWith('[')) {
|
||
const end = hostPort.indexOf(']');
|
||
if (end > 1) return hostPort.slice(1, end);
|
||
return '';
|
||
}
|
||
const idx = hostPort.indexOf(':');
|
||
return (idx >= 0 ? hostPort.slice(0, idx) : hostPort).trim();
|
||
}
|
||
|
||
async function resolveIceServers(call) {
|
||
try {
|
||
const payload = await authService.getCallIceConfig();
|
||
const stunUrls = uniqueUrls(parseIceUrls(payload?.stunUrls));
|
||
const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls));
|
||
const turnUsername = String(payload?.turnUsername || '').trim();
|
||
const turnPassword = String(payload?.turnPassword || '').trim();
|
||
const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : [];
|
||
|
||
const turnHostSet = new Set();
|
||
const iceServers = [];
|
||
if (stunUrls.length > 0) {
|
||
iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls });
|
||
}
|
||
if (turnServers.length > 0) {
|
||
turnServers.forEach((item) => {
|
||
const urls = uniqueUrls(parseIceUrls(item?.urls));
|
||
const username = String(item?.username || '').trim();
|
||
const password = String(item?.password || '').trim();
|
||
if (urls.length === 0 || !username || !password) return;
|
||
urls.forEach((url) => {
|
||
const host = parseTurnHostFromUrl(url);
|
||
if (host) turnHostSet.add(host);
|
||
});
|
||
iceServers.push({
|
||
urls: urls.length === 1 ? urls[0] : urls,
|
||
username,
|
||
credential: password,
|
||
});
|
||
});
|
||
} else if (turnUrls.length > 0 && turnUsername && turnPassword) {
|
||
turnUrls.forEach((url) => {
|
||
const host = parseTurnHostFromUrl(url);
|
||
if (host) turnHostSet.add(host);
|
||
});
|
||
iceServers.push({
|
||
urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls,
|
||
username: turnUsername,
|
||
credential: turnPassword,
|
||
});
|
||
}
|
||
|
||
if (iceServers.length === 0) {
|
||
await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun');
|
||
return cloneDefaultIceServers();
|
||
}
|
||
if (call) {
|
||
call.turnHostsConfigured = Array.from(turnHostSet);
|
||
}
|
||
await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turnEntries=${Math.max(0, iceServers.length - (stunUrls.length > 0 ? 1 : 0))}`);
|
||
return iceServers;
|
||
} catch (error) {
|
||
await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error));
|
||
return cloneDefaultIceServers();
|
||
}
|
||
}
|
||
|
||
async function collectIceCandidateAnalytics(call) {
|
||
const pc = call?.pc;
|
||
if (!pc || typeof pc.getStats !== 'function') return {};
|
||
try {
|
||
const stats = await pc.getStats();
|
||
const localCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 };
|
||
const remoteCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 };
|
||
const relayLocalAddresses = new Set();
|
||
const relayRemoteAddresses = new Set();
|
||
const configuredHosts = new Set((call?.turnHostsConfigured || []).map((v) => String(v || '').trim()).filter(Boolean));
|
||
const matchedConfiguredHosts = new Set();
|
||
let succeededPairsCount = 0;
|
||
let succeededPairsWithRelayCount = 0;
|
||
|
||
const bump = (bucket, rawType) => {
|
||
const type = String(rawType || '').trim().toLowerCase();
|
||
if (!type) bucket.other += 1;
|
||
else if (Object.prototype.hasOwnProperty.call(bucket, type)) bucket[type] += 1;
|
||
else bucket.other += 1;
|
||
};
|
||
const hostOf = (candidate) => {
|
||
const raw = String(candidate?.ip || candidate?.address || '').trim();
|
||
if (!raw) return '';
|
||
if (raw.startsWith('[')) {
|
||
const end = raw.indexOf(']');
|
||
if (end > 1) return raw.slice(1, end);
|
||
}
|
||
return raw;
|
||
};
|
||
|
||
stats.forEach((report) => {
|
||
if (!report || report.type !== 'local-candidate') return;
|
||
bump(localCounts, report.candidateType);
|
||
if (String(report.candidateType || '').toLowerCase() === 'relay') {
|
||
const addr = String(report.ip || report.address || '');
|
||
const port = String(report.port || '');
|
||
relayLocalAddresses.add(port ? `${addr}:${port}` : addr);
|
||
const host = hostOf(report);
|
||
if (host && configuredHosts.has(host)) matchedConfiguredHosts.add(host);
|
||
}
|
||
});
|
||
stats.forEach((report) => {
|
||
if (!report || report.type !== 'remote-candidate') return;
|
||
bump(remoteCounts, report.candidateType);
|
||
if (String(report.candidateType || '').toLowerCase() === 'relay') {
|
||
const addr = String(report.ip || report.address || '');
|
||
const port = String(report.port || '');
|
||
relayRemoteAddresses.add(port ? `${addr}:${port}` : addr);
|
||
}
|
||
});
|
||
stats.forEach((report) => {
|
||
if (!report || report.type !== 'candidate-pair') return;
|
||
const state = String(report.state || '').toLowerCase();
|
||
const ok = report.selected === true || (report.nominated === true && state === 'succeeded') || state === 'succeeded';
|
||
if (!ok) return;
|
||
succeededPairsCount += 1;
|
||
const local = report.localCandidateId && typeof stats.get === 'function' ? stats.get(report.localCandidateId) : null;
|
||
const remote = report.remoteCandidateId && typeof stats.get === 'function' ? stats.get(report.remoteCandidateId) : null;
|
||
const lt = String(local?.candidateType || '').toLowerCase();
|
||
const rt = String(remote?.candidateType || '').toLowerCase();
|
||
if (lt === 'relay' || rt === 'relay') succeededPairsWithRelayCount += 1;
|
||
});
|
||
|
||
return {
|
||
localCandidatesHost: localCounts.host,
|
||
localCandidatesSrflx: localCounts.srflx,
|
||
localCandidatesRelay: localCounts.relay,
|
||
localCandidatesPrflx: localCounts.prflx,
|
||
localCandidatesOther: localCounts.other,
|
||
remoteCandidatesHost: remoteCounts.host,
|
||
remoteCandidatesSrflx: remoteCounts.srflx,
|
||
remoteCandidatesRelay: remoteCounts.relay,
|
||
remoteCandidatesPrflx: remoteCounts.prflx,
|
||
remoteCandidatesOther: remoteCounts.other,
|
||
relayLocalCandidatesFound: relayLocalAddresses.size,
|
||
relayRemoteCandidatesFound: relayRemoteAddresses.size,
|
||
relayLocalCandidatesAddresses: Array.from(relayLocalAddresses).join('|'),
|
||
relayRemoteCandidatesAddresses: Array.from(relayRemoteAddresses).join('|'),
|
||
configuredTurnHosts: Array.from(configuredHosts).join('|'),
|
||
configuredTurnHostsCount: configuredHosts.size,
|
||
reachableTurnHostsCount: matchedConfiguredHosts.size,
|
||
reachableTurnHosts: Array.from(matchedConfiguredHosts).join('|'),
|
||
turnConfiguredButNotReachedHosts: Array.from(configuredHosts).filter((host) => !matchedConfiguredHosts.has(host)).join('|'),
|
||
succeededCandidatePairsCount: succeededPairsCount,
|
||
succeededCandidatePairsWithRelayCount: succeededPairsWithRelayCount,
|
||
};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function ensureAudioContext() {
|
||
if (audioContext) return audioContext;
|
||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||
if (!Ctx) return null;
|
||
audioContext = new Ctx();
|
||
return audioContext;
|
||
}
|
||
|
||
function playBeep(freq = 440, durationMs = 120, gainValue = 0.08) {
|
||
const ctx = ensureAudioContext();
|
||
if (!ctx) return;
|
||
if (ctx.state === 'suspended') {
|
||
void ctx.resume().catch(() => {});
|
||
}
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.type = 'sine';
|
||
osc.frequency.value = Number(freq) || 440;
|
||
gain.gain.value = gainValue;
|
||
osc.connect(gain);
|
||
gain.connect(ctx.destination);
|
||
const now = ctx.currentTime;
|
||
osc.start(now);
|
||
osc.stop(now + Math.max(0.03, durationMs / 1000));
|
||
}
|
||
|
||
function stopTone() {
|
||
if (toneTimerId) {
|
||
clearInterval(toneTimerId);
|
||
toneTimerId = null;
|
||
}
|
||
toneName = '';
|
||
toneFlip = false;
|
||
}
|
||
|
||
function startTone(nextToneName) {
|
||
if (!nextToneName) {
|
||
stopTone();
|
||
return;
|
||
}
|
||
if (toneName === nextToneName) return;
|
||
stopTone();
|
||
toneName = nextToneName;
|
||
if (nextToneName === 'searching') {
|
||
toneTimerId = window.setInterval(() => {
|
||
toneFlip = !toneFlip;
|
||
playBeep(toneFlip ? 920 : 760, 120, 0.07);
|
||
}, 420);
|
||
return;
|
||
}
|
||
if (nextToneName === 'ringback') {
|
||
playBeep(425, 900, 0.08);
|
||
toneTimerId = window.setInterval(() => {
|
||
playBeep(425, 900, 0.08);
|
||
}, 4000);
|
||
return;
|
||
}
|
||
if (nextToneName === 'incoming') {
|
||
const hit = () => {
|
||
playBeep(830, 180, 0.09);
|
||
window.setTimeout(() => playBeep(680, 180, 0.09), 240);
|
||
};
|
||
hit();
|
||
toneTimerId = window.setInterval(hit, 2000);
|
||
}
|
||
}
|
||
|
||
function getCallStateSnapshot() {
|
||
const call = getActiveCall();
|
||
if (!call) return null;
|
||
if (call.localUiDismissed) return null;
|
||
const callPhase = String(call.phase || '').trim();
|
||
const localVideoTracks = call.localStream?.getVideoTracks?.() || [];
|
||
const remoteVideoTracks = call.remoteMediaStream?.getVideoTracks?.() || [];
|
||
const canToggleSpeaker = Boolean(
|
||
callPhase !== 'incoming'
|
||
&& callPhase !== 'ended'
|
||
&& canRouteAudioOutputInBrowser()
|
||
&& call.remoteAudio
|
||
);
|
||
const canToggleCamera = Boolean(
|
||
callPhase !== 'incoming'
|
||
&& callPhase !== 'ended'
|
||
&& navigator?.mediaDevices?.getUserMedia
|
||
&& call.pc
|
||
);
|
||
return {
|
||
callId: call.callId,
|
||
peerLogin: call.peerLogin || '',
|
||
direction: call.direction || 'out',
|
||
phase: callPhase,
|
||
statusText: call.statusText || '',
|
||
startedAtMs: Number(call.startedAtMs || 0),
|
||
connectedAtMs: Number(call.connectedAtMs || 0),
|
||
muted: Boolean(call.muted),
|
||
cameraEnabled: Boolean(call.cameraEnabled),
|
||
speakerEnabled: Boolean(call.speakerEnabled),
|
||
canToggleSpeaker,
|
||
canToggleCamera,
|
||
canAnswer: callPhase === 'incoming',
|
||
canDecline: callPhase === 'incoming',
|
||
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
||
canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting',
|
||
hasLocalVideo: Boolean(
|
||
call.cameraEnabled
|
||
&& call.localVideoTrack
|
||
&& call.localVideoTrack.readyState === 'live'
|
||
&& call.localVideoTrack.enabled !== false
|
||
),
|
||
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live' && !track?.muted),
|
||
localPreviewStream: call.cameraEnabled ? (call.localStream || null) : null,
|
||
remoteMediaStream: call.remoteMediaStream || null,
|
||
};
|
||
}
|
||
|
||
function notifyCallState() {
|
||
const snapshot = getCallStateSnapshot();
|
||
callStateListeners.forEach((listener) => {
|
||
try {
|
||
listener(snapshot);
|
||
} catch {}
|
||
});
|
||
}
|
||
|
||
function dismissCallUiLocally(call) {
|
||
if (!call) return;
|
||
call.localUiDismissed = true;
|
||
notifyCallState();
|
||
}
|
||
|
||
function setStatus(call, statusText, phase = '') {
|
||
if (!call) return;
|
||
call.statusText = String(statusText || '').trim();
|
||
if (phase) {
|
||
call.phase = String(phase || '').trim();
|
||
}
|
||
if (call.phase === 'searching') startTone('searching');
|
||
else if (call.phase === 'ringing') startTone('ringback');
|
||
else if (call.phase === 'incoming') startTone('incoming');
|
||
else stopTone();
|
||
|
||
recordCallTimeline(call, 'status', `phase=${call.phase || ''}; text=${call.statusText || ''}`);
|
||
void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`);
|
||
notifyCallState();
|
||
}
|
||
|
||
function buildActiveStatusText(call) {
|
||
const route = String(call?.connectionRouteLabel || '').trim();
|
||
const videoSlotMarker = call?.videoSlotReady ? ' +' : '';
|
||
return route ? `Разговор идёт (${route}${videoSlotMarker})` : 'Разговор идёт';
|
||
}
|
||
|
||
function refreshVideoSlotReady(call) {
|
||
if (!call?.pc) return false;
|
||
const localSdp = String(call.pc.localDescription?.sdp || '');
|
||
const remoteSdp = String(call.pc.remoteDescription?.sdp || '');
|
||
const hasVideoInLocal = localSdp.includes('\nm=video') || localSdp.startsWith('m=video');
|
||
const hasVideoInRemote = remoteSdp.includes('\nm=video') || remoteSdp.startsWith('m=video');
|
||
call.videoSlotReady = Boolean(hasVideoInLocal && hasVideoInRemote && call.videoTransceiver);
|
||
return call.videoSlotReady;
|
||
}
|
||
|
||
function setActiveStatus(call) {
|
||
setStatus(call, buildActiveStatusText(call), 'active');
|
||
}
|
||
|
||
function toIsoTs(ts) {
|
||
const n = Number(ts || 0);
|
||
if (!Number.isFinite(n) || n <= 0) return '';
|
||
try { return new Date(n).toISOString(); } catch { return ''; }
|
||
}
|
||
|
||
function buildCallFactsJson(call, extra = {}) {
|
||
const pc = call?.pc || null;
|
||
const facts = {
|
||
callId: call?.callId || '',
|
||
peerLogin: call?.peerLogin || '',
|
||
remoteSessionId: call?.remoteSessionId || '',
|
||
direction: call?.direction || '',
|
||
phase: call?.phase || '',
|
||
statusText: call?.statusText || '',
|
||
startedAtMs: Number(call?.startedAtMs || 0),
|
||
startedAtIso: toIsoTs(call?.startedAtMs),
|
||
connectedAtMs: Number(call?.connectedAtMs || 0),
|
||
connectedAtIso: toIsoTs(call?.connectedAtMs),
|
||
routeLabel: call?.connectionRouteLabel || '',
|
||
routeDetails: call?.connectionRouteDetails || '',
|
||
pcConnectionState: pc?.connectionState || '',
|
||
pcIceConnectionState: pc?.iceConnectionState || '',
|
||
pcSignalingState: pc?.signalingState || '',
|
||
hasLocalStream: Boolean(call?.localStream),
|
||
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
||
localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0,
|
||
timeline: buildTimelineSummary(call),
|
||
...extra,
|
||
};
|
||
try {
|
||
return JSON.stringify(facts);
|
||
} catch {
|
||
return JSON.stringify({ callId: call?.callId || '', serializeError: true });
|
||
}
|
||
}
|
||
|
||
function buildCallFactsLine(call, extra = {}) {
|
||
const pc = call?.pc || null;
|
||
const facts = {
|
||
callId: call?.callId || '',
|
||
peerLogin: call?.peerLogin || '',
|
||
remoteSessionId: call?.remoteSessionId || '',
|
||
direction: call?.direction || '',
|
||
phase: call?.phase || '',
|
||
statusText: call?.statusText || '',
|
||
startedAtMs: Number(call?.startedAtMs || 0),
|
||
startedAtIso: toIsoTs(call?.startedAtMs),
|
||
connectedAtMs: Number(call?.connectedAtMs || 0),
|
||
connectedAtIso: toIsoTs(call?.connectedAtMs),
|
||
routeLabel: call?.connectionRouteLabel || '',
|
||
routeDetails: call?.connectionRouteDetails || '',
|
||
pcConnectionState: pc?.connectionState || '',
|
||
pcIceConnectionState: pc?.iceConnectionState || '',
|
||
pcSignalingState: pc?.signalingState || '',
|
||
hasLocalStream: Boolean(call?.localStream),
|
||
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
||
localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0,
|
||
localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)),
|
||
timeline: buildTimelineSummary(call),
|
||
...extra,
|
||
};
|
||
return Object.entries(facts)
|
||
.map(([k, v]) => `${k}=${String(v ?? '').replace(/,/g, ';')}`)
|
||
.join(', ');
|
||
}
|
||
|
||
function buildMediaSnapshot(call) {
|
||
const localStream = call?.localStream || null;
|
||
const localTracks = localStream?.getTracks?.() || [];
|
||
const remoteAudio = call?.remoteAudio || null;
|
||
const remoteStream = call?.remoteMediaStream || remoteAudio?.srcObject || null;
|
||
const remoteTracks = remoteStream?.getTracks?.() || [];
|
||
return {
|
||
hasLocalStream: Boolean(localStream),
|
||
localTrackCount: localTracks.length,
|
||
localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0,
|
||
localVideoTracksCount: localStream?.getVideoTracks?.()?.length || 0,
|
||
localTrackStates: localTracks.map((track) => ({
|
||
kind: String(track?.kind || ''),
|
||
enabled: Boolean(track?.enabled),
|
||
muted: Boolean(track?.muted),
|
||
readyState: String(track?.readyState || ''),
|
||
label: String(track?.label || ''),
|
||
})),
|
||
hasRemoteAudio: Boolean(remoteAudio),
|
||
hasRemoteAudioSrcObject: Boolean(remoteStream),
|
||
remoteTrackCount: remoteTracks.length,
|
||
remoteTrackStates: remoteTracks.map((track) => ({
|
||
kind: String(track?.kind || ''),
|
||
enabled: Boolean(track?.enabled),
|
||
muted: Boolean(track?.muted),
|
||
readyState: String(track?.readyState || ''),
|
||
label: String(track?.label || ''),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function summarizeMediaSnapshot(snapshot) {
|
||
if (!snapshot) return '';
|
||
const localSummary = Array.isArray(snapshot.localTrackStates)
|
||
? snapshot.localTrackStates.map((track) => (
|
||
`${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}`
|
||
)).join('|')
|
||
: '';
|
||
const remoteSummary = Array.isArray(snapshot.remoteTrackStates)
|
||
? snapshot.remoteTrackStates.map((track) => (
|
||
`${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}`
|
||
)).join('|')
|
||
: '';
|
||
return [
|
||
`localStream=${snapshot.hasLocalStream ? 1 : 0}`,
|
||
`localTracks=${Number(snapshot.localTrackCount || 0)}`,
|
||
`localAudio=${Number(snapshot.localAudioTracksCount || 0)}`,
|
||
`localVideo=${Number(snapshot.localVideoTracksCount || 0)}`,
|
||
`localStates=${localSummary || '-'}`,
|
||
`remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`,
|
||
`remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`,
|
||
`remoteTracks=${Number(snapshot.remoteTrackCount || 0)}`,
|
||
`remoteStates=${remoteSummary || '-'}`,
|
||
].join(';');
|
||
}
|
||
|
||
function getCallDiagnosticsContext(call) {
|
||
const pc = call?.pc || null;
|
||
const nav = typeof navigator !== 'undefined' ? navigator : null;
|
||
const conn = nav?.connection || nav?.mozConnection || nav?.webkitConnection || null;
|
||
const permissionsApiAvailable = typeof nav?.permissions?.query === 'function';
|
||
const mediaDevicesAvailable = Boolean(nav?.mediaDevices?.getUserMedia);
|
||
const online = typeof nav?.onLine === 'boolean' ? nav.onLine : null;
|
||
const visibilityState = typeof document !== 'undefined' ? String(document.visibilityState || '') : '';
|
||
const pageFocused = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
|
||
? Boolean(document.hasFocus())
|
||
: false;
|
||
const localTracks = call?.localStream?.getTracks?.() || [];
|
||
const localAudioTracks = call?.localStream?.getAudioTracks?.() || [];
|
||
const localVideoTracks = call?.localStream?.getVideoTracks?.() || [];
|
||
const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length;
|
||
const enabledLocalVideoTracks = localVideoTracks.filter((track) => track?.enabled).length;
|
||
const transceiversCount = pc?.getTransceivers?.()?.length || 0;
|
||
const sendersCount = pc?.getSenders?.()?.length || 0;
|
||
const receiversCount = pc?.getReceivers?.()?.length || 0;
|
||
const iceGatheringState = pc?.iceGatheringState || '';
|
||
const currentLocalDescType = pc?.localDescription?.type || '';
|
||
const currentRemoteDescType = pc?.remoteDescription?.type || '';
|
||
const mediaSnapshot = buildMediaSnapshot(call);
|
||
|
||
return {
|
||
remoteSessionIdPresent: Boolean(call?.remoteSessionId),
|
||
callExistsInStore: calls.has(String(call?.callId || '')),
|
||
browserOnline: online === null ? '' : String(online),
|
||
documentVisibilityState: visibilityState,
|
||
pageFocused,
|
||
userAgent: typeof nav?.userAgent === 'string' ? nav.userAgent : '',
|
||
platform: typeof nav?.platform === 'string' ? nav.platform : '',
|
||
language: typeof nav?.language === 'string' ? nav.language : '',
|
||
permissionsApiAvailable,
|
||
mediaDevicesApiAvailable: mediaDevicesAvailable,
|
||
connectionType: String(conn?.type || ''),
|
||
effectiveConnectionType: String(conn?.effectiveType || ''),
|
||
networkRttMs: Number(conn?.rtt || 0),
|
||
networkDownlinkMbps: Number(conn?.downlink || 0),
|
||
saveData: conn?.saveData === true,
|
||
localTrackCount: localTracks.length,
|
||
localAudioTracksCount: localAudioTracks.length,
|
||
localAudioTracksEnabledCount: enabledLocalAudioTracks,
|
||
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
|
||
localVideoTracksCount: localVideoTracks.length,
|
||
localVideoTracksEnabledCount: enabledLocalVideoTracks,
|
||
localVideoTrackLabels: localVideoTracks.map((t) => String(t?.label || '')).join('|'),
|
||
localTrackStates: summarizeMediaSnapshot(mediaSnapshot),
|
||
hasPeerConnection: Boolean(pc),
|
||
pcConnectionState: pc?.connectionState || '',
|
||
pcIceConnectionState: pc?.iceConnectionState || '',
|
||
pcIceGatheringState: iceGatheringState,
|
||
pcSignalingState: pc?.signalingState || '',
|
||
pcCanTrickleIceCandidates: pc?.canTrickleIceCandidates === null || pc?.canTrickleIceCandidates === undefined
|
||
? ''
|
||
: String(pc?.canTrickleIceCandidates),
|
||
localDescriptionType: currentLocalDescType,
|
||
remoteDescriptionType: currentRemoteDescType,
|
||
pcTransceiversCount: transceiversCount,
|
||
pcSendersCount: sendersCount,
|
||
pcReceiversCount: receiversCount,
|
||
};
|
||
}
|
||
|
||
async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) {
|
||
if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return;
|
||
try {
|
||
const diagnostics = getCallDiagnosticsContext(call);
|
||
const valueLine = buildCallFactsLine(call, {
|
||
eventType: String(eventType || '').trim(),
|
||
eventCode: String(eventCode || '').trim(),
|
||
reason: String(reason || '').trim(),
|
||
reportedAtMs: nowMs(),
|
||
reportedAtIso: toIsoTs(nowMs()),
|
||
timelineEntriesCount: Array.isArray(call?.timelineEvents) ? call.timelineEvents.length : 0,
|
||
...diagnostics,
|
||
...extraFacts,
|
||
});
|
||
await authService.sendCallDeliveryReport({
|
||
type: String(eventType || '').trim(),
|
||
value: valueLine,
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
function cleanupTimers(call) {
|
||
if (call.timers?.ack10s) clearTimeout(call.timers.ack10s);
|
||
if (call.timers?.total35s) clearTimeout(call.timers.total35s);
|
||
if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s);
|
||
if (call.timers?.incomingConnect20s) clearTimeout(call.timers.incomingConnect20s);
|
||
if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe);
|
||
call.timers.transportProbe = null;
|
||
}
|
||
|
||
function scheduleOutgoingAckTimeout(call) {
|
||
if (!call?.timers) return;
|
||
if (call.timers.ack10s) {
|
||
clearTimeout(call.timers.ack10s);
|
||
call.timers.ack10s = null;
|
||
}
|
||
const deliveredCount = getInviteDeliveredCount(call);
|
||
const timeoutMs = deliveredCount > 0
|
||
? OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS
|
||
: OUTGOING_NO_ACK_TIMEOUT_MS;
|
||
const debugReason = deliveredCount > 0 ? 'no_ack_after_delivery_25s' : 'no_ack_10s';
|
||
recordCallTimeline(call, 'ack_timeout_scheduled', `timeoutMs=${timeoutMs}; delivered=${deliveredCount}; reason=${debugReason}`);
|
||
call.timers.ack10s = setTimeout(() => {
|
||
if (!calls.has(call.callId)) return;
|
||
if (call.phase === 'searching' || call.phase === 'ringing') {
|
||
recordCallTimeline(call, 'ack_timeout_fired', `phase=${call.phase || ''}; reason=${debugReason}`, 'warn');
|
||
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason });
|
||
}
|
||
}, timeoutMs);
|
||
}
|
||
|
||
function scheduleIncomingConnectTimeout(call) {
|
||
if (!call?.timers) return;
|
||
if (call.timers.incomingConnect20s) {
|
||
clearTimeout(call.timers.incomingConnect20s);
|
||
}
|
||
recordCallTimeline(call, 'incoming_connect_timeout_scheduled', `timeoutMs=${INCOMING_CONNECT_TIMEOUT_MS}`);
|
||
call.timers.incomingConnect20s = setTimeout(() => {
|
||
if (!calls.has(call.callId)) return;
|
||
if (call.phase !== 'connecting') return;
|
||
recordCallTimeline(call, 'incoming_connect_timeout_fired', `phase=${call.phase || ''}`, 'warn');
|
||
void finalizeCall(call, {
|
||
localReasonCode: 'no_answer',
|
||
debugReason: 'incoming_connect_timeout_20s',
|
||
});
|
||
}, INCOMING_CONNECT_TIMEOUT_MS);
|
||
}
|
||
|
||
async function flushPendingIceCandidates(call) {
|
||
if (!call?.pc) return;
|
||
const pending = Array.isArray(call.pendingRemoteIceCandidates) ? call.pendingRemoteIceCandidates : [];
|
||
if (!pending.length) return;
|
||
call.pendingRemoteIceCandidates = [];
|
||
for (const candidate of pending) {
|
||
try {
|
||
await call.pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||
await emitDebug(call, 'info', 'ice_processed_from_queue', 'candidate added');
|
||
} catch (error) {
|
||
await emitDebug(call, 'warn', 'ice_process_failed_from_queue', toErrorText(error));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function closeMedia(call) {
|
||
const pc = call?.pc || null;
|
||
const beforeSnapshot = buildMediaSnapshot(call);
|
||
recordCallTimeline(call, 'media_close_begin', summarizeMediaSnapshot(beforeSnapshot));
|
||
try {
|
||
const senders = pc?.getSenders?.() || [];
|
||
senders.forEach((sender) => {
|
||
try {
|
||
if (typeof sender?.replaceTrack === 'function') sender.replaceTrack(null);
|
||
} catch {}
|
||
});
|
||
} catch {}
|
||
try {
|
||
const transceivers = pc?.getTransceivers?.() || [];
|
||
transceivers.forEach((tr) => {
|
||
try { tr?.stop?.(); } catch {}
|
||
});
|
||
} catch {}
|
||
try {
|
||
if (pc) {
|
||
pc.ontrack = null;
|
||
pc.onicecandidate = null;
|
||
pc.onconnectionstatechange = null;
|
||
pc.oniceconnectionstatechange = null;
|
||
pc.onsignalingstatechange = null;
|
||
pc.onicegatheringstatechange = null;
|
||
}
|
||
} catch {}
|
||
try {
|
||
call.localStream?.getTracks?.()?.forEach((track) => {
|
||
try { track.enabled = false; } catch {}
|
||
try { track.stop(); } catch {}
|
||
});
|
||
} catch {}
|
||
try {
|
||
if (call.remoteAudio) {
|
||
const remoteTracks = call.remoteMediaStream?.getTracks?.() || call.remoteAudio.srcObject?.getTracks?.() || [];
|
||
remoteTracks.forEach((track) => {
|
||
try { track.enabled = false; } catch {}
|
||
try { track.stop(); } catch {}
|
||
});
|
||
try { call.remoteAudio.pause?.(); } catch {}
|
||
try { call.remoteAudio.srcObject = null; } catch {}
|
||
try { call.remoteAudio.removeAttribute?.('src'); } catch {}
|
||
try { call.remoteAudio.load?.(); } catch {}
|
||
call.remoteAudio = null;
|
||
}
|
||
} catch {}
|
||
try { pc?.close?.(); } catch {}
|
||
call.pc = null;
|
||
call.localStream = null;
|
||
call.localVideoTrack = null;
|
||
call.videoSender = null;
|
||
call.remoteMediaStream = null;
|
||
call.audioSenders = [];
|
||
call.connectionRouteLabel = '';
|
||
call.connectionRouteDetails = '';
|
||
call.pendingRemoteIceCandidates = [];
|
||
const afterSnapshot = buildMediaSnapshot(call);
|
||
recordCallTimeline(call, 'media_close_end', summarizeMediaSnapshot(afterSnapshot));
|
||
await emitDebug(
|
||
call,
|
||
'info',
|
||
'media_closed',
|
||
`before=${summarizeMediaSnapshot(beforeSnapshot)} | after=${summarizeMediaSnapshot(afterSnapshot)}`,
|
||
);
|
||
}
|
||
|
||
function stopReconnectFlow(call) {
|
||
if (!call?.timers) return;
|
||
if (call.timers.reconnectStep) {
|
||
clearTimeout(call.timers.reconnectStep);
|
||
call.timers.reconnectStep = null;
|
||
}
|
||
if (call.timers.reconnectDeadline) {
|
||
clearTimeout(call.timers.reconnectDeadline);
|
||
call.timers.reconnectDeadline = null;
|
||
}
|
||
call.reconnectInProgress = false;
|
||
call.reconnectAttempts = 0;
|
||
}
|
||
|
||
async function detectConnectionRoute(call) {
|
||
const pc = call?.pc;
|
||
if (!pc || typeof pc.getStats !== 'function') {
|
||
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
|
||
}
|
||
try {
|
||
const stats = await pc.getStats();
|
||
let selectedPair = null;
|
||
|
||
stats.forEach((report) => {
|
||
if (selectedPair) return;
|
||
if (report.type !== 'transport') return;
|
||
if (!report.selectedCandidatePairId) return;
|
||
const pair = typeof stats.get === 'function' ? stats.get(report.selectedCandidatePairId) : null;
|
||
if (pair) selectedPair = pair;
|
||
});
|
||
|
||
if (!selectedPair) {
|
||
stats.forEach((report) => {
|
||
if (selectedPair) return;
|
||
if (report.type === 'candidate-pair' && report.selected) selectedPair = report;
|
||
});
|
||
}
|
||
|
||
if (!selectedPair) {
|
||
stats.forEach((report) => {
|
||
if (selectedPair) return;
|
||
if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') {
|
||
selectedPair = report;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!selectedPair) return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
|
||
|
||
const local = selectedPair.localCandidateId && typeof stats.get === 'function'
|
||
? stats.get(selectedPair.localCandidateId)
|
||
: null;
|
||
const remote = selectedPair.remoteCandidateId && typeof stats.get === 'function'
|
||
? stats.get(selectedPair.remoteCandidateId)
|
||
: null;
|
||
|
||
const localType = String(local?.candidateType || '').trim().toLowerCase();
|
||
const remoteType = String(remote?.candidateType || '').trim().toLowerCase();
|
||
const localIp = String(local?.ip || local?.address || '');
|
||
const remoteIp = String(remote?.ip || remote?.address || '');
|
||
const localPort = String(local?.port || '');
|
||
const remotePort = String(remote?.port || '');
|
||
const relayProto = String(local?.relayProtocol || remote?.relayProtocol || '');
|
||
const turnCandidateAddress = localType === 'relay'
|
||
? `${localIp}${localPort ? `:${localPort}` : ''}`
|
||
: (remoteType === 'relay' ? `${remoteIp}${remotePort ? `:${remotePort}` : ''}` : '');
|
||
const details = `local=${localType || '-'}(${localIp || '-'}${localPort ? `:${localPort}` : ''}); remote=${remoteType || '-'}(${remoteIp || '-'}${remotePort ? `:${remotePort}` : ''})`;
|
||
|
||
if (localType === 'relay' || remoteType === 'relay') {
|
||
const label = turnCandidateAddress
|
||
? `через TURN (${turnCandidateAddress})`
|
||
: (relayProto ? `через TURN (${relayProto})` : 'через TURN');
|
||
return { label, details, localIp, remoteIp, turnCandidateAddress };
|
||
}
|
||
if (localType || remoteType) {
|
||
const sameLan = localIp && remoteIp && (
|
||
(localIp.startsWith('10.') && remoteIp.startsWith('10.'))
|
||
|| (localIp.startsWith('192.168.') && remoteIp.startsWith('192.168.'))
|
||
|| (localIp.startsWith('172.16.') && remoteIp.startsWith('172.16.'))
|
||
);
|
||
return {
|
||
label: sameLan ? 'напрямую в локальной сети' : 'напрямую через интернет',
|
||
details,
|
||
localIp,
|
||
remoteIp,
|
||
turnCandidateAddress: '',
|
||
};
|
||
}
|
||
return { label: '', details, localIp, remoteIp, turnCandidateAddress: '' };
|
||
} catch {
|
||
return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' };
|
||
}
|
||
}
|
||
|
||
function startTransportProbe(call) {
|
||
if (!call?.timers || !call.pc) return;
|
||
if (call.timers.transportProbe) {
|
||
clearInterval(call.timers.transportProbe);
|
||
call.timers.transportProbe = null;
|
||
}
|
||
|
||
const refresh = async () => {
|
||
if (!calls.has(call.callId) || call.phase === 'ended') return;
|
||
if (!call.pc || call.pc.connectionState !== 'connected') return;
|
||
const route = await detectConnectionRoute(call);
|
||
if (!route.label || route.label === call.connectionRouteLabel) return;
|
||
call.connectionRouteLabel = route.label;
|
||
call.connectionRouteDetails = route.details || '';
|
||
setActiveStatus(call);
|
||
await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label);
|
||
};
|
||
|
||
void refresh();
|
||
call.timers.transportProbe = window.setInterval(() => {
|
||
void refresh();
|
||
}, 4000);
|
||
}
|
||
|
||
function startReconnectFlow(call, reason = 'disconnected') {
|
||
if (!call || call.phase === 'ended') return;
|
||
if (!call.connectedAtMs) return;
|
||
if (!call.pc) return;
|
||
if (call.reconnectInProgress) return;
|
||
|
||
call.reconnectInProgress = true;
|
||
call.reconnectAttempts = 0;
|
||
recordCallTimeline(call, 'reconnect_start', `reason=${reason}`);
|
||
setStatus(call, 'Связь прервалась. Переподключаем…', 'reconnecting');
|
||
void emitDebug(call, 'warn', 'peer_connection_reconnect_start', `reason=${reason}`);
|
||
|
||
const maxAttempts = 6;
|
||
const attemptDelayMs = 2500;
|
||
const totalDeadlineMs = 17000;
|
||
|
||
call.timers.reconnectDeadline = setTimeout(() => {
|
||
if (!calls.has(call.callId) || call.phase === 'ended') return;
|
||
if (call.pc?.connectionState === 'connected') return;
|
||
stopReconnectFlow(call);
|
||
void finalizeCall(call, { localReasonCode: 'error', debugReason: 'reconnect_timeout' });
|
||
}, totalDeadlineMs);
|
||
|
||
const runAttempt = async () => {
|
||
if (!calls.has(call.callId) || call.phase === 'ended') return;
|
||
if (!call.pc || call.pc.connectionState === 'connected') {
|
||
stopReconnectFlow(call);
|
||
return;
|
||
}
|
||
|
||
call.reconnectAttempts += 1;
|
||
recordCallTimeline(call, 'reconnect_attempt', `attempt=${call.reconnectAttempts}`);
|
||
try {
|
||
const offer = await call.pc.createOffer({ iceRestart: true });
|
||
await call.pc.setLocalDescription(offer);
|
||
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
||
await emitDebug(call, 'info', 'peer_connection_reconnect_offer_sent', `attempt=${call.reconnectAttempts}`);
|
||
} catch (error) {
|
||
await emitDebug(call, 'warn', 'peer_connection_reconnect_offer_failed', `attempt=${call.reconnectAttempts}; error=${toErrorText(error)}`);
|
||
}
|
||
|
||
if (call.pc?.connectionState === 'connected') {
|
||
stopReconnectFlow(call);
|
||
return;
|
||
}
|
||
if (call.reconnectAttempts >= maxAttempts) {
|
||
stopReconnectFlow(call);
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: 'reconnect_attempts_exhausted' });
|
||
return;
|
||
}
|
||
call.timers.reconnectStep = setTimeout(() => {
|
||
void runAttempt();
|
||
}, attemptDelayMs);
|
||
};
|
||
|
||
void runAttempt();
|
||
}
|
||
|
||
async function finalizeCall(call, {
|
||
localReasonCode = 'error',
|
||
debugReason = '',
|
||
notifyRemoteHangup = false,
|
||
suppressRemoteSignal = false,
|
||
suppressReports = false,
|
||
suppressSummary = false,
|
||
} = {}) {
|
||
if (!call) return;
|
||
recordCallTimeline(
|
||
call,
|
||
'finalize_begin',
|
||
`reason=${localReasonCode || ''}; debug=${debugReason || ''}; phase=${call.phase || ''}`,
|
||
String(localReasonCode || '') === 'completed' ? 'info' : 'warn',
|
||
);
|
||
const diagnosticsBeforeClose = getCallDiagnosticsContext(call);
|
||
cleanupTimers(call);
|
||
stopReconnectFlow(call);
|
||
stopTone();
|
||
if (String(debugReason || '') === 'remote_hangup' && call.direction === 'in') {
|
||
dismissCallUiLocally(call);
|
||
}
|
||
|
||
const shouldNotifyRemoteFailure =
|
||
!suppressRemoteSignal
|
||
&& !notifyRemoteHangup
|
||
&& Boolean(call.remoteSessionId)
|
||
&& String(localReasonCode || '') !== 'completed'
|
||
&& String(debugReason || '') !== 'remote_hangup';
|
||
|
||
if ((notifyRemoteHangup || shouldNotifyRemoteFailure) && call.remoteSessionId) {
|
||
try {
|
||
const dataValue = notifyRemoteHangup
|
||
? ''
|
||
: `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`;
|
||
await sendSignal(call, TYPES.HANGUP, dataValue);
|
||
} catch {}
|
||
}
|
||
|
||
await closeMedia(call);
|
||
if (String(localReasonCode || '') === 'completed') {
|
||
await emitDebug(call, 'info', 'debug_connection_success', debugReason || 'completed');
|
||
}
|
||
if (debugReason) {
|
||
await emitDebug(call, 'info', 'call_finalize', `${localReasonCode}:${debugReason}`);
|
||
}
|
||
|
||
const reasonText = debugReason || localReasonCode;
|
||
if (!suppressReports && String(localReasonCode || '') !== 'completed') {
|
||
const failureStage = call.phase || '';
|
||
const failureContext = {
|
||
failureStage,
|
||
connectedBeforeFailure: Boolean(call.connectedAtMs),
|
||
...diagnosticsBeforeClose,
|
||
};
|
||
if (call.direction === 'out') {
|
||
await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText, failureContext);
|
||
} else if (call.direction === 'in') {
|
||
await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText, failureContext);
|
||
}
|
||
if (String(localReasonCode || '') === 'busy') {
|
||
await sendCallDeliveryReport(call, 'call_busy', 'call_busy', reasonText, failureContext);
|
||
} else if (String(localReasonCode || '') === 'declined') {
|
||
await sendCallDeliveryReport(call, 'call_declined', 'call_declined', reasonText, failureContext);
|
||
}
|
||
if (String(localReasonCode || '') === 'error') {
|
||
await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText, failureContext);
|
||
}
|
||
}
|
||
|
||
if (!suppressSummary) {
|
||
await sendOutgoingCallSummaryDm(call, localReasonCode);
|
||
}
|
||
|
||
call.phase = 'ended';
|
||
call.statusText = 'Звонок завершён';
|
||
if (String(localReasonCode || '') === 'busy') {
|
||
call.statusText = 'Пользователь занят';
|
||
}
|
||
recordCallTimeline(call, 'finalize_end', `reason=${localReasonCode || ''}; status=${call.statusText || ''}`);
|
||
notifyCallState();
|
||
|
||
const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0;
|
||
if (finalHoldMs > 0) {
|
||
window.setTimeout(() => {
|
||
calls.delete(call.callId);
|
||
if (activeCallId === call.callId) {
|
||
activeCallId = '';
|
||
}
|
||
notifyCallState();
|
||
}, finalHoldMs);
|
||
return;
|
||
}
|
||
|
||
calls.delete(call.callId);
|
||
if (activeCallId === call.callId) {
|
||
activeCallId = '';
|
||
}
|
||
notifyCallState();
|
||
}
|
||
|
||
async function emitDebug(call, level, message, details = '') {
|
||
if (!call?.debugRunId || typeof debugReporter !== 'function') return;
|
||
try {
|
||
await debugReporter({
|
||
runId: call.debugRunId,
|
||
level,
|
||
message,
|
||
details,
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
async function sendSignal(call, type, data = '') {
|
||
if (!call.remoteSessionId) return;
|
||
const signalName = getSignalTypeName(type);
|
||
const signalType = getCallSignalType(type);
|
||
const login = String(state?.session?.login || '').trim();
|
||
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
|
||
if (!login || !storagePwd || !signalType) {
|
||
throw new Error('Call SendSignal: missing login/storagePwd/signalType');
|
||
}
|
||
try {
|
||
await authService.sendSignal({
|
||
toLogin: call.peerLogin,
|
||
targetMode: SIGNAL_TARGET_SINGLE,
|
||
targetSessionId: call.remoteSessionId,
|
||
signalType,
|
||
signalRequestId: call.callId,
|
||
data: encodeCallSignalData(call.callId, type, data),
|
||
storagePwd,
|
||
includeClientSignature: true,
|
||
});
|
||
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
|
||
await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`);
|
||
} catch (error) {
|
||
if (String(error?.code || '').toUpperCase() === 'SESSION_NOT_FOUND') {
|
||
recordCallTimeline(
|
||
call,
|
||
`signal_out_${signalName}_session_not_found`,
|
||
`toSession=${call.remoteSessionId}; op=${String(error?.op || '')}; status=${String(error?.status || '')}`,
|
||
'warn',
|
||
);
|
||
}
|
||
recordCallTimeline(call, `signal_out_${signalName}_failed`, `toSession=${call.remoteSessionId}; error=${toErrorText(error)}`, 'warn');
|
||
await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error));
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function broadcastSignal(call, type, data = '') {
|
||
const signalName = getSignalTypeName(type);
|
||
const signalType = getCallSignalType(type);
|
||
const login = String(state?.session?.login || '').trim();
|
||
const storagePwd = String(state?.session?.storagePwdInMemory || '').trim();
|
||
if (!call?.peerLogin || !login || !storagePwd || !signalType) {
|
||
throw new Error('Call SendSignal broadcast: missing peer/login/storagePwd/signalType');
|
||
}
|
||
try {
|
||
const response = await authService.sendSignal({
|
||
toLogin: call.peerLogin,
|
||
targetMode: SIGNAL_TARGET_ALL,
|
||
signalType,
|
||
signalRequestId: call.callId,
|
||
data: encodeCallSignalData(call.callId, type, data),
|
||
storagePwd,
|
||
includeClientSignature: true,
|
||
});
|
||
recordCallTimeline(
|
||
call,
|
||
`signal_broadcast_${signalName}`,
|
||
`ws=${Number(response?.deliveredWsSessions || response?.deliveredCount || 0)}; push=${Number(response?.deliveredWebPushSessions || response?.deliveredFcmSessions || 0)}`,
|
||
);
|
||
return response || {};
|
||
} catch (error) {
|
||
recordCallTimeline(call, `signal_broadcast_${signalName}_failed`, toErrorText(error), 'warn');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function notifyPeerSessionsAboutConnectStart(call) {
|
||
const winnerSessionId = String(call?.remoteSessionId || '').trim();
|
||
if (!call || !winnerSessionId) return;
|
||
const data = buildConnectStartData(winnerSessionId);
|
||
try {
|
||
await broadcastSignal(call, TYPES.CONNECT_START, data);
|
||
recordCallTimeline(call, 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`);
|
||
await emitDebug(call, 'info', 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`);
|
||
} catch (error) {
|
||
recordCallTimeline(call, 'connect_start_broadcast_failed', `winnerSession=${winnerSessionId}; error=${toErrorText(error)}`, 'warn');
|
||
await emitDebug(call, 'warn', 'connect_start_broadcast_failed', toErrorText(error));
|
||
}
|
||
}
|
||
|
||
function ensureReservedVideoTransceiver(call) {
|
||
const pc = call?.pc || null;
|
||
if (!pc) return null;
|
||
if (call.videoTransceiver) return call.videoTransceiver;
|
||
const transceivers = pc.getTransceivers?.() || [];
|
||
const existing = transceivers.find((tr) => tr?.mid !== null && tr?.mid !== undefined && (tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video'))
|
||
|| transceivers.find((tr) => tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video')
|
||
|| null;
|
||
if (existing) {
|
||
call.videoTransceiver = existing;
|
||
call.videoSender = existing?.sender || call.videoSender || null;
|
||
return existing;
|
||
}
|
||
const created = pc.addTransceiver('video', { direction: 'sendrecv' });
|
||
call.videoTransceiver = created;
|
||
call.videoSender = created?.sender || null;
|
||
return created;
|
||
}
|
||
|
||
function ensureRemoteTrackObservers(call, track) {
|
||
if (!call || !track || typeof track !== 'object') return;
|
||
if (!call.remoteTrackObserverIds) {
|
||
call.remoteTrackObserverIds = new Set();
|
||
}
|
||
if (call.remoteTrackObserverIds.has(track.id)) return;
|
||
call.remoteTrackObserverIds.add(track.id);
|
||
track.onended = () => {
|
||
if (track.kind === 'video' && call.remoteMediaStream) {
|
||
try { call.remoteMediaStream.removeTrack(track); } catch {}
|
||
}
|
||
notifyCallState();
|
||
};
|
||
track.onmute = () => notifyCallState();
|
||
track.onunmute = () => notifyCallState();
|
||
}
|
||
|
||
function ensureRemoteMediaStream(call) {
|
||
if (call?.remoteMediaStream) return call.remoteMediaStream;
|
||
const stream = new MediaStream();
|
||
if (call) {
|
||
call.remoteMediaStream = stream;
|
||
}
|
||
return stream;
|
||
}
|
||
|
||
function syncRemoteReceiverVideoTracks(call) {
|
||
const pc = call?.pc || null;
|
||
if (!pc) return false;
|
||
const remoteStream = ensureRemoteMediaStream(call);
|
||
let changed = false;
|
||
const receivers = pc.getReceivers?.() || [];
|
||
receivers.forEach((receiver) => {
|
||
const track = receiver?.track || null;
|
||
if (!track || track.kind !== 'video' || track.readyState === 'ended') return;
|
||
const hasTrack = remoteStream.getVideoTracks().some((existing) => existing.id === track.id);
|
||
if (!hasTrack) {
|
||
try {
|
||
remoteStream.addTrack(track);
|
||
changed = true;
|
||
} catch {}
|
||
}
|
||
ensureRemoteTrackObservers(call, track);
|
||
});
|
||
if (call.remoteAudio) {
|
||
call.remoteAudio.srcObject = remoteStream;
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
async function ensurePeerConnection(call) {
|
||
if (call.pc) return call.pc;
|
||
|
||
const iceServers = await resolveIceServers(call);
|
||
const pc = new RTCPeerConnection({
|
||
iceServers,
|
||
});
|
||
|
||
if (call.debugMode && call.debugRole === 'initiator') {
|
||
const dc = pc.createDataChannel('debug-ping');
|
||
dc.onopen = () => {
|
||
try { dc.send('ping'); } catch {}
|
||
void emitDebug(call, 'info', 'debug_datachannel_open', 'sent ping');
|
||
};
|
||
dc.onmessage = (evt) => {
|
||
void emitDebug(call, 'info', 'debug_datachannel_message', String(evt?.data || ''));
|
||
};
|
||
}
|
||
|
||
pc.ondatachannel = (evt) => {
|
||
const ch = evt?.channel;
|
||
if (!ch) return;
|
||
ch.onmessage = (msg) => {
|
||
const incoming = String(msg?.data || '');
|
||
void emitDebug(call, 'info', 'debug_datachannel_message_in', incoming);
|
||
if (incoming === 'ping') {
|
||
try { ch.send('pong'); } catch {}
|
||
}
|
||
};
|
||
};
|
||
|
||
pc.onicecandidate = async (event) => {
|
||
if (!event.candidate || !call.remoteSessionId) return;
|
||
call.localIceSentCount = Number(call.localIceSentCount || 0) + 1;
|
||
if (call.localIceSentCount <= 3) {
|
||
recordCallTimeline(
|
||
call,
|
||
'ice_local_candidate',
|
||
`count=${call.localIceSentCount}; type=${event.candidate?.type || ''}; protocol=${event.candidate?.protocol || ''}`,
|
||
);
|
||
}
|
||
try {
|
||
await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate));
|
||
} catch {}
|
||
};
|
||
|
||
pc.onconnectionstatechange = () => {
|
||
const state = pc.connectionState;
|
||
recordCallTimeline(
|
||
call,
|
||
'pc_connection_state',
|
||
`state=${state || ''}; ice=${pc.iceConnectionState || ''}; signal=${pc.signalingState || ''}`,
|
||
state === 'failed' ? 'warn' : 'info',
|
||
);
|
||
if (state === 'connected') {
|
||
stopReconnectFlow(call);
|
||
if (call.timers?.incomingConnect20s) {
|
||
clearTimeout(call.timers.incomingConnect20s);
|
||
call.timers.incomingConnect20s = null;
|
||
}
|
||
if (!call.connectedAtMs) {
|
||
call.connectedAtMs = nowMs();
|
||
}
|
||
refreshVideoSlotReady(call);
|
||
setActiveStatus(call);
|
||
startTransportProbe(call);
|
||
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
|
||
if (call.direction === 'out' && !call.connectionSuccessReported) {
|
||
call.connectionSuccessReported = true;
|
||
void (async () => {
|
||
const route = await detectConnectionRoute(call);
|
||
const candidateAnalytics = await collectIceCandidateAnalytics(call);
|
||
if (route?.label) {
|
||
call.connectionRouteLabel = route.label;
|
||
}
|
||
call.connectionRouteDetails = route?.details || '';
|
||
await sendCallDeliveryReport(
|
||
call,
|
||
'call_connected',
|
||
'call_connected_success',
|
||
`connected:${route?.label || 'unknown_route'}`,
|
||
{
|
||
reportBy: 'initiator',
|
||
routeLabel: route?.label || '',
|
||
routeDetails: route?.details || '',
|
||
localIp: route?.localIp || '',
|
||
remoteIp: route?.remoteIp || '',
|
||
turnCandidateAddress: route?.turnCandidateAddress || '',
|
||
...candidateAnalytics,
|
||
},
|
||
);
|
||
})();
|
||
}
|
||
return;
|
||
}
|
||
if (state === 'failed') {
|
||
const failedDetails = `failed;ice=${pc.iceConnectionState || ''};gather=${pc.iceGatheringState || ''};signal=${pc.signalingState || ''}`;
|
||
if (call.connectedAtMs) {
|
||
startReconnectFlow(call, 'failed');
|
||
return;
|
||
}
|
||
void emitDebug(call, 'warn', 'peer_connection_closed', failedDetails);
|
||
void finalizeCall(call, { localReasonCode: 'error', debugReason: failedDetails });
|
||
return;
|
||
}
|
||
if (state === 'disconnected' && call.phase !== 'ended') {
|
||
if (call.connectedAtMs) {
|
||
startReconnectFlow(call, 'disconnected');
|
||
return;
|
||
}
|
||
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
|
||
return;
|
||
}
|
||
if (state === 'closed' && call.phase !== 'ended') {
|
||
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
|
||
if (call.connectedAtMs) {
|
||
void finalizeCall(call, { localReasonCode: 'error', debugReason: state });
|
||
return;
|
||
}
|
||
void finalizeCall(call, { localReasonCode: 'error', debugReason: state });
|
||
}
|
||
};
|
||
|
||
pc.onsignalingstatechange = () => {
|
||
const state = String(pc.signalingState || '');
|
||
recordCallTimeline(call, 'pc_signaling_state', `state=${state || ''}`);
|
||
if (state === 'stable') {
|
||
if (syncRemoteReceiverVideoTracks(call)) {
|
||
notifyCallState();
|
||
}
|
||
}
|
||
if (state === 'stable' && call.pendingNegotiationReason) {
|
||
void renegotiateCall(call, `signaling_stable:${call.pendingNegotiationReason}`);
|
||
}
|
||
};
|
||
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||
call.localStream = stream;
|
||
call.audioSenders = [];
|
||
call.videoSender = null;
|
||
call.videoTransceiver = null;
|
||
call.connectionRouteLabel = '';
|
||
call.connectionRouteDetails = '';
|
||
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
|
||
stream.getTracks().forEach((track) => {
|
||
track.enabled = track.kind === 'audio' ? !call.muted : true;
|
||
const sender = pc.addTrack(track, stream);
|
||
if (track.kind === 'audio') {
|
||
call.audioSenders.push(sender);
|
||
}
|
||
});
|
||
const blackTrack = ensureBlackVideoTrack(call);
|
||
if (blackTrack) {
|
||
const existingVideoTracks = stream.getVideoTracks?.() || [];
|
||
existingVideoTracks.forEach((track) => {
|
||
if (track.id !== blackTrack.id) {
|
||
try { stream.removeTrack(track); } catch {}
|
||
}
|
||
});
|
||
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
||
stream.addTrack(blackTrack);
|
||
}
|
||
const sender = pc.addTrack(blackTrack, stream);
|
||
call.videoSender = sender;
|
||
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
|
||
bindVideoSenderStream(call, sender);
|
||
await applyVideoSenderEncodingProfile(sender, 'placeholder');
|
||
} else {
|
||
ensureReservedVideoTransceiver(call);
|
||
}
|
||
} catch (e) {
|
||
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
|
||
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
|
||
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
|
||
throw e;
|
||
}
|
||
|
||
pc.ontrack = (evt) => {
|
||
const trackKind = String(evt?.track?.kind || '');
|
||
recordCallTimeline(call, 'remote_track_received', `kind=${trackKind || '-'}; streams=${evt?.streams?.length || 0}`);
|
||
let remoteStream = call.remoteMediaStream || null;
|
||
if (evt?.streams?.[0]) {
|
||
remoteStream = evt.streams[0];
|
||
} else {
|
||
if (!remoteStream) remoteStream = new MediaStream();
|
||
if (evt?.track && !remoteStream.getTracks().some((track) => track.id === evt.track.id)) {
|
||
remoteStream.addTrack(evt.track);
|
||
}
|
||
}
|
||
call.remoteMediaStream = remoteStream;
|
||
if (trackKind === 'video') {
|
||
call.videoEverActivated = true;
|
||
}
|
||
if (!call.remoteAudio) {
|
||
const audio = new Audio();
|
||
audio.autoplay = true;
|
||
audio.playsInline = true;
|
||
call.remoteAudio = audio;
|
||
}
|
||
if (evt?.track) ensureRemoteTrackObservers(call, evt.track);
|
||
call.remoteAudio.srcObject = remoteStream;
|
||
syncRemoteReceiverVideoTracks(call);
|
||
void call.remoteAudio.play?.().catch?.(() => {});
|
||
notifyCallState();
|
||
};
|
||
|
||
call.pc = pc;
|
||
if (!Array.isArray(call.pendingRemoteIceCandidates)) {
|
||
call.pendingRemoteIceCandidates = [];
|
||
}
|
||
return pc;
|
||
}
|
||
|
||
function queueNegotiationReason(call, reason = '') {
|
||
if (!call) return '';
|
||
const normalized = String(reason || '').trim() || 'pending';
|
||
call.pendingNegotiationReason = normalized;
|
||
return normalized;
|
||
}
|
||
|
||
async function renegotiateCall(call, reason = '') {
|
||
if (!call?.pc || !call.remoteSessionId) return false;
|
||
const queuedReason = queueNegotiationReason(call, reason);
|
||
if (call.negotiationInProgress) {
|
||
recordCallTimeline(call, 'renegotiate_queued_busy', `reason=${queuedReason || '-'}`);
|
||
return false;
|
||
}
|
||
const pc = call.pc;
|
||
if (pc.signalingState !== 'stable') {
|
||
recordCallTimeline(call, 'renegotiate_queued_unstable', `state=${pc.signalingState}; reason=${queuedReason || '-'}`);
|
||
return false;
|
||
}
|
||
const activeReason = String(call.pendingNegotiationReason || queuedReason || 'pending').trim() || 'pending';
|
||
call.pendingNegotiationReason = '';
|
||
call.negotiationInProgress = true;
|
||
try {
|
||
recordCallTimeline(call, 'renegotiate_start', `reason=${activeReason || '-'}`);
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
||
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${activeReason || '-'}`);
|
||
return true;
|
||
} catch (error) {
|
||
if (!call.pendingNegotiationReason) {
|
||
call.pendingNegotiationReason = activeReason;
|
||
}
|
||
throw error;
|
||
} finally {
|
||
call.negotiationInProgress = false;
|
||
if (call.pendingNegotiationReason && call.pc?.signalingState === 'stable') {
|
||
queueMicrotask(() => {
|
||
void renegotiateCall(call, `queued:${call.pendingNegotiationReason}`);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async function onAccept(call) {
|
||
if (!call) return;
|
||
if (call.initialOfferInProgress || call.initialOfferSent) {
|
||
await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`);
|
||
return;
|
||
}
|
||
recordCallTimeline(call, 'offer_start', `remoteSession=${call.remoteSessionId || ''}`);
|
||
call.initialOfferInProgress = true;
|
||
cleanupTimers(call);
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
try {
|
||
await notifyPeerSessionsAboutConnectStart(call);
|
||
const pc = await ensurePeerConnection(call);
|
||
ensureReservedVideoTransceiver(call);
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
call.initialOfferSent = true;
|
||
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
||
await emitDebug(call, 'info', 'offer_sent', 'offer created and sent');
|
||
} finally {
|
||
call.initialOfferInProgress = false;
|
||
}
|
||
}
|
||
|
||
function ensureIncomingNotification(peerLogin) {
|
||
if (typeof window === 'undefined') return;
|
||
const text = `Вам звонит ${peerLogin}`;
|
||
try {
|
||
if ('Notification' in window && Notification.permission === 'granted') {
|
||
new Notification('SHiNE: входящий звонок', { body: text });
|
||
}
|
||
} catch {}
|
||
try {
|
||
if ('vibrate' in navigator) {
|
||
navigator.vibrate([180, 70, 180, 70, 260]);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function isIncomingCallPushFresh(payload) {
|
||
const expiresAtMs = Number(payload?.expiresAtMs || 0);
|
||
if (Number.isFinite(expiresAtMs) && expiresAtMs > 0 && Date.now() > expiresAtMs) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function isCallPushForCurrentSession(payload = {}) {
|
||
const targetSessionId = String(payload?.targetSessionId || '').trim();
|
||
if (!targetSessionId) return true;
|
||
const currentSessionId = String(state?.session?.sessionId || '').trim();
|
||
return Boolean(currentSessionId) && currentSessionId === targetSessionId;
|
||
}
|
||
|
||
async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
||
const callId = String(payload?.callId || '').trim();
|
||
const fromLogin = String(payload?.fromLogin || '').trim();
|
||
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||
const type = Number(payload?.type || TYPES.INVITE);
|
||
if (type === TYPES.CONNECT_START) {
|
||
const call = getCall(callId);
|
||
if (!call || call.direction !== 'in' || call.phase === 'ended') return call;
|
||
const winnerSessionId = parseConnectStartWinnerSessionId(payload?.data || '');
|
||
const currentSessionId = String(state?.session?.sessionId || '').trim();
|
||
recordCallTimeline(call, 'connect_start_broadcast_received', `winnerSession=${winnerSessionId || '-'}; currentSession=${currentSessionId || '-'}`);
|
||
if (winnerSessionId && currentSessionId && winnerSessionId === currentSessionId) {
|
||
await emitDebug(call, 'info', 'connect_start_kept_on_winner_session', winnerSessionId);
|
||
return call;
|
||
}
|
||
dismissCallUiLocally(call);
|
||
await finalizeCall(call, {
|
||
localReasonCode: 'completed',
|
||
debugReason: 'answered_elsewhere_session_selected',
|
||
suppressRemoteSignal: true,
|
||
suppressReports: true,
|
||
suppressSummary: true,
|
||
});
|
||
return call;
|
||
}
|
||
if (!callId || !fromLogin || !fromSessionId) return null;
|
||
|
||
if (activeCallId && activeCallId !== callId) {
|
||
try {
|
||
await authService.sendSignal({
|
||
toLogin: fromLogin,
|
||
targetMode: SIGNAL_TARGET_SINGLE,
|
||
targetSessionId: fromSessionId,
|
||
signalType: getCallSignalType(TYPES.DECLINE_BUSY),
|
||
signalRequestId: callId,
|
||
data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'),
|
||
storagePwd: state?.session?.storagePwdInMemory || '',
|
||
includeClientSignature: true,
|
||
});
|
||
} catch {}
|
||
return null;
|
||
}
|
||
|
||
let call = getCall(callId);
|
||
if (!call) {
|
||
call = {
|
||
callId,
|
||
peerLogin: fromLogin,
|
||
direction: 'in',
|
||
phase: 'incoming',
|
||
statusText: `Вам звонит ${fromLogin}`,
|
||
remoteSessionId: fromSessionId,
|
||
timers: {},
|
||
startedAtMs: nowMs(),
|
||
connectedAtMs: 0,
|
||
pc: null,
|
||
localStream: null,
|
||
localVideoTrack: null,
|
||
remoteMediaStream: null,
|
||
audioSenders: [],
|
||
videoSender: null,
|
||
videoTransceiver: null,
|
||
muted: false,
|
||
cameraEnabled: false,
|
||
videoEverActivated: false,
|
||
connectionRouteLabel: '',
|
||
reconnectInProgress: false,
|
||
reconnectAttempts: 0,
|
||
debugMode: false,
|
||
debugRunId: '',
|
||
debugRole: '',
|
||
pendingRemoteIceCandidates: [],
|
||
pendingNegotiationReason: '',
|
||
initialOfferInProgress: false,
|
||
initialOfferSent: false,
|
||
timelineEvents: [],
|
||
timelineSeq: 0,
|
||
timelineBaseMs: nowMs(),
|
||
};
|
||
calls.set(callId, call);
|
||
recordCallTimeline(call, 'incoming_invite_created', `from=${fromLogin}; session=${fromSessionId}; source=${source}`);
|
||
} else if (!call.remoteSessionId && fromSessionId) {
|
||
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
|
||
}
|
||
|
||
activeCallId = callId;
|
||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||
ensureIncomingNotification(fromLogin);
|
||
|
||
try {
|
||
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
||
} catch {}
|
||
|
||
if (!call.timers.incoming20s) {
|
||
call.timers.incoming20s = setTimeout(async () => {
|
||
if (!calls.has(callId)) return;
|
||
try {
|
||
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
||
} catch {}
|
||
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
||
}, 20000);
|
||
}
|
||
return call;
|
||
}
|
||
|
||
export function setCallDebugReporter(fn) {
|
||
debugReporter = typeof fn === 'function' ? fn : null;
|
||
}
|
||
|
||
export function subscribeCallState(listener) {
|
||
if (typeof listener !== 'function') {
|
||
return () => {};
|
||
}
|
||
callStateListeners.add(listener);
|
||
try {
|
||
listener(getCallStateSnapshot());
|
||
} catch {}
|
||
return () => {
|
||
callStateListeners.delete(listener);
|
||
};
|
||
}
|
||
|
||
export function getActiveCallState() {
|
||
return getCallStateSnapshot();
|
||
}
|
||
|
||
async function applyMicState(call) {
|
||
if (!call) return;
|
||
const muted = Boolean(call.muted);
|
||
const audioTracks = call.localStream?.getAudioTracks?.() || [];
|
||
audioTracks.forEach((track) => {
|
||
track.enabled = !muted;
|
||
});
|
||
|
||
const senders = Array.isArray(call.audioSenders) && call.audioSenders.length > 0
|
||
? call.audioSenders
|
||
: (call.pc?.getSenders?.() || []);
|
||
const sourceTrack = audioTracks[0] || null;
|
||
|
||
for (const sender of senders) {
|
||
if (!sender || typeof sender.replaceTrack !== 'function') continue;
|
||
try {
|
||
if (muted) {
|
||
if (sender.track) {
|
||
await sender.replaceTrack(null);
|
||
}
|
||
} else if (sourceTrack) {
|
||
if (sender.track !== sourceTrack) {
|
||
await sender.replaceTrack(sourceTrack);
|
||
}
|
||
sourceTrack.enabled = true;
|
||
}
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
async function ensureCameraTrack(call) {
|
||
const existingTrack = call?.localVideoTrack;
|
||
if (existingTrack && existingTrack.readyState === 'live') {
|
||
existingTrack.enabled = true;
|
||
return existingTrack;
|
||
}
|
||
const videoStream = await navigator.mediaDevices.getUserMedia({ video: CAMERA_CONSTRAINTS, audio: false });
|
||
const videoTrack = videoStream.getVideoTracks?.()[0] || null;
|
||
if (!videoTrack) {
|
||
throw new Error('Не удалось получить видеотрек');
|
||
}
|
||
const streamTracks = videoStream.getTracks?.() || [];
|
||
streamTracks.forEach((track) => {
|
||
if (track !== videoTrack) {
|
||
try { track.stop(); } catch {}
|
||
}
|
||
});
|
||
if (!call.localStream) {
|
||
call.localStream = new MediaStream();
|
||
}
|
||
const currentLocalVideoTracks = call.localStream.getVideoTracks?.() || [];
|
||
currentLocalVideoTracks.forEach((track) => {
|
||
if (track.id !== videoTrack.id) {
|
||
try { call.localStream.removeTrack(track); } catch {}
|
||
try { track.stop(); } catch {}
|
||
}
|
||
});
|
||
call.localStream.addTrack(videoTrack);
|
||
call.localVideoTrack = videoTrack;
|
||
call.videoEverActivated = true;
|
||
return videoTrack;
|
||
}
|
||
|
||
function ensureBlackVideoTrack(call) {
|
||
const existingTrack = call?.placeholderBlackVideoTrack || null;
|
||
if (existingTrack && existingTrack.readyState === 'live') {
|
||
existingTrack.enabled = true;
|
||
return existingTrack;
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 160;
|
||
canvas.height = 90;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx || typeof canvas.captureStream !== 'function') {
|
||
return null;
|
||
}
|
||
ctx.fillStyle = '#000000';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
const stream = canvas.captureStream(1);
|
||
const track = stream.getVideoTracks?.()[0] || null;
|
||
if (!track) return null;
|
||
call.placeholderBlackCanvas = canvas;
|
||
call.placeholderBlackStream = stream;
|
||
call.placeholderBlackVideoTrack = track;
|
||
return track;
|
||
}
|
||
|
||
async function applyVideoSenderEncodingProfile(sender, mode = 'camera') {
|
||
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
|
||
return;
|
||
}
|
||
try {
|
||
const params = sender.getParameters() || {};
|
||
const encodings = Array.isArray(params.encodings) && params.encodings.length > 0
|
||
? [...params.encodings]
|
||
: [{}];
|
||
const nextEncoding = { ...encodings[0] };
|
||
if (mode === 'placeholder') {
|
||
nextEncoding.maxBitrate = 12000;
|
||
nextEncoding.maxFramerate = 1;
|
||
nextEncoding.scaleResolutionDownBy = 1;
|
||
nextEncoding.active = true;
|
||
} else {
|
||
delete nextEncoding.maxBitrate;
|
||
delete nextEncoding.maxFramerate;
|
||
delete nextEncoding.scaleResolutionDownBy;
|
||
nextEncoding.active = true;
|
||
}
|
||
params.encodings = [nextEncoding];
|
||
await sender.setParameters(params);
|
||
} catch {}
|
||
}
|
||
|
||
async function disableCameraTrack(call) {
|
||
const videoTrack = call?.localVideoTrack || null;
|
||
if (videoTrack) {
|
||
try { videoTrack.enabled = false; } catch {}
|
||
try { call.localStream?.removeTrack?.(videoTrack); } catch {}
|
||
try { videoTrack.stop(); } catch {}
|
||
}
|
||
call.localVideoTrack = null;
|
||
}
|
||
|
||
function bindVideoSenderStream(call, sender) {
|
||
if (!call?.localStream || !sender || typeof sender.setStreams !== 'function') return;
|
||
try {
|
||
sender.setStreams(call.localStream);
|
||
} catch {}
|
||
}
|
||
|
||
async function applyCameraState(call) {
|
||
if (!call?.pc) return false;
|
||
const pc = call.pc;
|
||
let videoTransceiver = call.videoTransceiver || null;
|
||
if (!videoTransceiver) {
|
||
const transceivers = pc.getTransceivers?.() || [];
|
||
videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
|
||
call.videoTransceiver = videoTransceiver;
|
||
call.videoSender = videoTransceiver?.sender || call.videoSender || null;
|
||
}
|
||
if (call.cameraEnabled) {
|
||
const videoTrack = await ensureCameraTrack(call);
|
||
if (!videoTransceiver) {
|
||
videoTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
|
||
call.videoTransceiver = videoTransceiver;
|
||
call.videoSender = videoTransceiver?.sender || null;
|
||
}
|
||
try {
|
||
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
||
videoTransceiver.direction = 'sendrecv';
|
||
}
|
||
} catch {}
|
||
if (videoTransceiver?.sender) {
|
||
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||
call.videoSender = videoTransceiver.sender;
|
||
bindVideoSenderStream(call, videoTransceiver.sender);
|
||
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'camera');
|
||
} else {
|
||
const sender = pc.addTrack(videoTrack, call.localStream);
|
||
call.videoSender = sender;
|
||
bindVideoSenderStream(call, sender);
|
||
await applyVideoSenderEncodingProfile(sender, 'camera');
|
||
}
|
||
videoTrack.enabled = true;
|
||
} else {
|
||
const blackTrack = ensureBlackVideoTrack(call);
|
||
if (call.localStream && blackTrack) {
|
||
const existingVideoTracks = call.localStream.getVideoTracks?.() || [];
|
||
existingVideoTracks.forEach((track) => {
|
||
if (track.id !== blackTrack.id) {
|
||
try { call.localStream.removeTrack(track); } catch {}
|
||
}
|
||
});
|
||
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
||
call.localStream.addTrack(blackTrack);
|
||
}
|
||
}
|
||
try {
|
||
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
||
videoTransceiver.direction = 'sendrecv';
|
||
}
|
||
} catch {}
|
||
if (videoTransceiver?.sender) {
|
||
try { await videoTransceiver.sender.replaceTrack(blackTrack || null); } catch {}
|
||
call.videoSender = videoTransceiver.sender;
|
||
bindVideoSenderStream(call, videoTransceiver.sender);
|
||
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'placeholder');
|
||
}
|
||
await disableCameraTrack(call);
|
||
}
|
||
notifyCallState();
|
||
return true;
|
||
}
|
||
|
||
export async function setMicMuted(muted) {
|
||
const call = getActiveCall();
|
||
if (!call) return;
|
||
call.muted = Boolean(muted);
|
||
await applyMicState(call);
|
||
notifyCallState();
|
||
}
|
||
|
||
export async function toggleMicMuted() {
|
||
const call = getActiveCall();
|
||
if (!call) return;
|
||
await setMicMuted(!call.muted);
|
||
}
|
||
|
||
export async function setCameraEnabled(enabled) {
|
||
const call = getActiveCall();
|
||
if (!call || !call.pc) return false;
|
||
const nextEnabled = Boolean(enabled);
|
||
if (Boolean(call.cameraEnabled) === nextEnabled) return true;
|
||
call.cameraEnabled = nextEnabled;
|
||
try {
|
||
await applyCameraState(call);
|
||
notifyCallState();
|
||
return true;
|
||
} catch (error) {
|
||
call.cameraEnabled = !nextEnabled;
|
||
notifyCallState();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function toggleCameraEnabled() {
|
||
const call = getActiveCall();
|
||
if (!call) return false;
|
||
return setCameraEnabled(!call.cameraEnabled);
|
||
}
|
||
|
||
export async function toggleSpeakerMode() {
|
||
const call = getActiveCall();
|
||
if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false;
|
||
const audio = call.remoteAudio;
|
||
if (Boolean(call.speakerEnabled)) {
|
||
try {
|
||
await audio.setSinkId('');
|
||
call.speakerEnabled = false;
|
||
notifyCallState();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const options = call.speakerSinkId ? { deviceId: call.speakerSinkId } : undefined;
|
||
const selected = await navigator.mediaDevices.selectAudioOutput(options);
|
||
const deviceId = String(selected?.deviceId || '').trim();
|
||
if (!deviceId) return false;
|
||
await audio.setSinkId(deviceId);
|
||
call.speakerSinkId = deviceId;
|
||
call.speakerEnabled = true;
|
||
notifyCallState();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) {
|
||
const cleanCallId = String(callId || '').trim();
|
||
const cleanPeerLogin = String(peerLogin || '').trim();
|
||
const cleanPeerSessionId = String(peerSessionId || '').trim();
|
||
if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return;
|
||
|
||
let call = getCall(cleanCallId);
|
||
if (!call) {
|
||
call = {
|
||
callId: cleanCallId,
|
||
peerLogin: cleanPeerLogin,
|
||
direction: 'in',
|
||
phase: 'incoming',
|
||
statusText: 'Debug: responder ждёт offer',
|
||
remoteSessionId: cleanPeerSessionId,
|
||
timers: {},
|
||
startedAtMs: nowMs(),
|
||
connectedAtMs: 0,
|
||
pc: null,
|
||
localStream: null,
|
||
localVideoTrack: null,
|
||
remoteMediaStream: null,
|
||
audioSenders: [],
|
||
videoSender: null,
|
||
videoTransceiver: null,
|
||
muted: false,
|
||
cameraEnabled: false,
|
||
videoEverActivated: false,
|
||
connectionRouteLabel: '',
|
||
debugMode: true,
|
||
debugRunId: String(runId || '').trim(),
|
||
debugRole: 'responder',
|
||
pendingRemoteIceCandidates: [],
|
||
pendingNegotiationReason: '',
|
||
initialOfferInProgress: false,
|
||
initialOfferSent: false,
|
||
};
|
||
calls.set(cleanCallId, call);
|
||
}
|
||
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
|
||
if (typeof call.initialOfferInProgress !== 'boolean') call.initialOfferInProgress = false;
|
||
if (typeof call.initialOfferSent !== 'boolean') call.initialOfferSent = false;
|
||
|
||
activeCallId = cleanCallId;
|
||
await emitDebug(call, 'info', 'debug_prepare_responder', `peerSessionId=${cleanPeerSessionId}`);
|
||
setStatus(call, 'Debug: responder готов, ждём offer', 'incoming');
|
||
}
|
||
|
||
export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin, peerSessionId }) {
|
||
const cleanCallId = String(callId || '').trim();
|
||
const cleanPeerLogin = String(peerLogin || '').trim();
|
||
const cleanPeerSessionId = String(peerSessionId || '').trim();
|
||
if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return;
|
||
|
||
const call = {
|
||
callId: cleanCallId,
|
||
peerLogin: cleanPeerLogin,
|
||
direction: 'out',
|
||
phase: 'connecting',
|
||
statusText: 'Debug: старт соединения',
|
||
remoteSessionId: cleanPeerSessionId,
|
||
timers: {},
|
||
startedAtMs: nowMs(),
|
||
connectedAtMs: 0,
|
||
pc: null,
|
||
localStream: null,
|
||
localVideoTrack: null,
|
||
remoteMediaStream: null,
|
||
audioSenders: [],
|
||
videoSender: null,
|
||
videoTransceiver: null,
|
||
muted: false,
|
||
cameraEnabled: false,
|
||
videoEverActivated: false,
|
||
connectionRouteLabel: '',
|
||
debugMode: true,
|
||
debugRunId: String(runId || '').trim(),
|
||
debugRole: 'initiator',
|
||
pendingRemoteIceCandidates: [],
|
||
pendingNegotiationReason: '',
|
||
initialOfferInProgress: false,
|
||
initialOfferSent: false,
|
||
timelineEvents: [],
|
||
timelineSeq: 0,
|
||
timelineBaseMs: nowMs(),
|
||
};
|
||
|
||
calls.set(cleanCallId, call);
|
||
activeCallId = cleanCallId;
|
||
recordCallTimeline(call, 'debug_initiator_created', `peer=${cleanPeerLogin}; session=${cleanPeerSessionId}`);
|
||
notifyCallState();
|
||
await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`);
|
||
try {
|
||
await onAccept(call);
|
||
} catch (error) {
|
||
await emitDebug(call, 'error', 'debug_initiator_start_failed', toErrorText(error));
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: toErrorText(error) });
|
||
}
|
||
}
|
||
|
||
export async function startOutgoingCall(peerLogin) {
|
||
const cleanPeer = String(peerLogin || '').trim();
|
||
if (!cleanPeer) return;
|
||
|
||
const active = getActiveCall();
|
||
if (active) {
|
||
throw new Error(`Уже есть активный звонок с ${active.peerLogin || 'другим пользователем'}`);
|
||
}
|
||
|
||
const callId = makeCallId();
|
||
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
|
||
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
|
||
if (!preflightOk) {
|
||
throw new Error('Сервер временно недоступен');
|
||
}
|
||
|
||
const call = {
|
||
callId,
|
||
peerLogin: cleanPeer,
|
||
direction: 'out',
|
||
phase: 'searching',
|
||
statusText: 'Ищем пользователя…',
|
||
remoteSessionId: '',
|
||
timers: {},
|
||
startedAtMs: nowMs(),
|
||
connectedAtMs: 0,
|
||
pc: null,
|
||
localStream: null,
|
||
localVideoTrack: null,
|
||
remoteMediaStream: null,
|
||
audioSenders: [],
|
||
videoSender: null,
|
||
videoTransceiver: null,
|
||
muted: false,
|
||
cameraEnabled: false,
|
||
videoEverActivated: false,
|
||
connectionRouteLabel: '',
|
||
reconnectInProgress: false,
|
||
reconnectAttempts: 0,
|
||
debugMode: false,
|
||
debugRunId: '',
|
||
debugRole: '',
|
||
pendingRemoteIceCandidates: [],
|
||
pendingNegotiationReason: '',
|
||
initialOfferInProgress: false,
|
||
initialOfferSent: false,
|
||
timelineEvents: [],
|
||
timelineSeq: 0,
|
||
timelineBaseMs: nowMs(),
|
||
};
|
||
calls.set(callId, call);
|
||
activeCallId = callId;
|
||
recordCallTimeline(call, 'outgoing_created', `peer=${cleanPeer}`);
|
||
setStatus(call, 'Ищем пользователя…', 'searching');
|
||
|
||
call.timers.total35s = setTimeout(() => {
|
||
if (!calls.has(callId)) return;
|
||
if (!call.connectedAtMs) {
|
||
recordCallTimeline(call, 'total_timeout_fired', 'total_timeout_35s', 'warn');
|
||
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' });
|
||
}
|
||
}, 35000);
|
||
|
||
try {
|
||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
||
recordCallTimeline(
|
||
call,
|
||
'invite_broadcast_ok',
|
||
`ws=${Number(call.inviteDelivery?.deliveredWsSessions || 0)}; push=${Number(call.inviteDelivery?.deliveredWebPushSessions || call.inviteDelivery?.deliveredFcmSessions || 0)}`,
|
||
);
|
||
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
||
setStatus(call, 'Вызываем…', 'ringing');
|
||
}
|
||
scheduleOutgoingAckTimeout(call);
|
||
} catch (error) {
|
||
recordCallTimeline(call, 'invite_broadcast_failed', toErrorText(error), 'warn');
|
||
const text = String(error?.message || '').toUpperCase();
|
||
const isNotAuth = text.includes('NOT_AUTHENTICATED');
|
||
if (isNotAuth) {
|
||
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
||
if (recovered) {
|
||
try {
|
||
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
||
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
||
setStatus(call, 'Вызываем…', 'ringing');
|
||
}
|
||
scheduleOutgoingAckTimeout(call);
|
||
return;
|
||
} catch {}
|
||
}
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: 'invite_failed:not_authenticated_after_retry' });
|
||
throw new Error('Сервер временно недоступен');
|
||
}
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
export async function handleIncomingCallInvite(evt) {
|
||
await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' });
|
||
}
|
||
|
||
export async function handleIncomingCallSignalViaSendSignal(evt) {
|
||
const payload = evt?.payload || {};
|
||
const signalType = String(payload?.signalType || '').trim();
|
||
if (!signalType.startsWith('call_')) return false;
|
||
|
||
const decoded = decodeCallSignalData(payload?.data || '');
|
||
if (!Number.isFinite(decoded.type)) return true;
|
||
|
||
const normalizedEvent = {
|
||
payload: {
|
||
callId: decoded.callId || String(payload?.signalRequestId || '').trim(),
|
||
fromLogin: payload?.fromLogin,
|
||
fromSessionId: payload?.fromSessionId,
|
||
type: decoded.type,
|
||
data: decoded.data,
|
||
},
|
||
};
|
||
|
||
if (decoded.type === TYPES.INVITE || decoded.type === TYPES.CONNECT_START) {
|
||
await handleIncomingInvitePayload(normalizedEvent.payload, { source: 'send_signal' });
|
||
return true;
|
||
}
|
||
|
||
await handleIncomingCallSignal(normalizedEvent);
|
||
return true;
|
||
}
|
||
|
||
export async function acceptIncomingCall() {
|
||
const call = getActiveCall();
|
||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
|
||
call.phase = 'connecting';
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
cleanupTimers(call);
|
||
scheduleIncomingConnectTimeout(call);
|
||
await sendSignal(call, TYPES.ACCEPT, 'accept');
|
||
}
|
||
|
||
export async function declineIncomingCall() {
|
||
const call = getActiveCall();
|
||
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||
dismissCallUiLocally(call);
|
||
const declinePromise = (async () => {
|
||
try {
|
||
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
|
||
} catch {}
|
||
})();
|
||
await finalizeCall(call, {
|
||
localReasonCode: 'declined',
|
||
debugReason: 'declined_by_user',
|
||
suppressRemoteSignal: true,
|
||
});
|
||
await declinePromise;
|
||
}
|
||
|
||
export async function handleIncomingCallSignal(evt) {
|
||
const payload = evt?.payload || {};
|
||
const callId = String(payload.callId || '').trim();
|
||
const fromLogin = String(payload.fromLogin || '').trim();
|
||
const fromSessionId = String(payload.fromSessionId || '').trim();
|
||
const type = Number(payload.type);
|
||
const data = String(payload.data || '');
|
||
if (!callId || !fromLogin || !Number.isFinite(type)) return;
|
||
|
||
const call = getCall(callId);
|
||
if (!call) return;
|
||
const signalName = getSignalTypeName(type);
|
||
recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`);
|
||
if (call.direction === 'out') {
|
||
if (type === TYPES.RINGING) {
|
||
recordCallTimeline(call, 'ringing_received', `from=${fromSessionId || '-'}`);
|
||
} else if (type === TYPES.ACCEPT) {
|
||
if (fromSessionId) {
|
||
if (!call.remoteSessionId) {
|
||
setRemoteSessionId(call, fromSessionId, 'accept');
|
||
} else if (call.remoteSessionId !== fromSessionId) {
|
||
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
|
||
await emitDebug(
|
||
call,
|
||
'warn',
|
||
'accept_from_non_selected_session_ignored',
|
||
`selected=${call.remoteSessionId}; from=${fromSessionId}`,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
} else {
|
||
const remoteSessionLocked = Boolean(
|
||
call.initialOfferSent
|
||
|| call.connectedAtMs
|
||
|| call.phase === 'connecting'
|
||
|| call.phase === 'active'
|
||
|| call.phase === 'reconnecting',
|
||
);
|
||
const terminalSignalFromAnotherSession =
|
||
Boolean(call.remoteSessionId)
|
||
&& Boolean(fromSessionId)
|
||
&& call.remoteSessionId !== fromSessionId
|
||
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
|
||
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
|
||
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
|
||
recordCallTimeline(call, 'terminal_signal_before_lock_allowed', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
|
||
await emitDebug(
|
||
call,
|
||
'info',
|
||
'terminal_signal_from_non_selected_session_allowed_before_lock',
|
||
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
|
||
);
|
||
} else {
|
||
recordCallTimeline(call, 'signal_in_ignored_non_selected_session', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
|
||
await emitDebug(
|
||
call,
|
||
'info',
|
||
'signal_from_non_selected_session_ignored',
|
||
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
|
||
);
|
||
return;
|
||
}
|
||
}
|
||
if (!call.remoteSessionId && fromSessionId) {
|
||
setRemoteSessionId(call, fromSessionId, `first_${signalName}`);
|
||
}
|
||
}
|
||
} else if (!call.remoteSessionId) {
|
||
setRemoteSessionId(call, fromSessionId, `incoming_${signalName}`);
|
||
}
|
||
|
||
if (type === TYPES.RINGING) {
|
||
if (call.direction === 'out' && call.phase === 'searching') {
|
||
setStatus(call, 'Вызываем…', 'ringing');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.CONNECT_START) {
|
||
const winnerSessionId = parseConnectStartWinnerSessionId(data);
|
||
recordCallTimeline(call, 'connect_start_received', `winnerSession=${winnerSessionId || '-'}; currentRemoteSession=${call.remoteSessionId || '-'}`);
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.ACCEPT) {
|
||
if (call.direction !== 'out') {
|
||
await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);
|
||
return;
|
||
}
|
||
call.phase = 'connecting';
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
await onAccept(call);
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.DECLINE_BUSY) {
|
||
const normalized = data.trim().toLowerCase();
|
||
const isDeclined = normalized === 'decline' || normalized === 'declined';
|
||
await finalizeCall(call, {
|
||
localReasonCode: isDeclined ? 'declined' : 'busy',
|
||
debugReason: isDeclined ? 'declined_by_remote' : 'busy_by_remote',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.TIMEOUT) {
|
||
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'remote_timeout' });
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.HANGUP) {
|
||
await finalizeCall(call, {
|
||
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
|
||
debugReason: 'remote_hangup',
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.OFFER) {
|
||
try {
|
||
recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`);
|
||
const pc = await ensurePeerConnection(call);
|
||
const incomingOffer = new RTCSessionDescription(JSON.parse(data));
|
||
const localType = String(pc.localDescription?.type || '').trim().toLowerCase();
|
||
if (pc.signalingState !== 'stable' && localType === 'offer') {
|
||
recordCallTimeline(call, 'offer_glare_rollback', `state=${pc.signalingState}; from=${fromSessionId || '-'}`);
|
||
await pc.setLocalDescription({ type: 'rollback' });
|
||
}
|
||
await pc.setRemoteDescription(incomingOffer);
|
||
ensureReservedVideoTransceiver(call);
|
||
syncRemoteReceiverVideoTracks(call);
|
||
await flushPendingIceCandidates(call);
|
||
const answer = await pc.createAnswer();
|
||
await pc.setLocalDescription(answer);
|
||
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
||
refreshVideoSlotReady(call);
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
notifyCallState();
|
||
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
||
} catch (error) {
|
||
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: `offer_failed:${toErrorText(error)}` });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.ANSWER) {
|
||
try {
|
||
if (call.direction !== 'out') {
|
||
await emitDebug(call, 'warn', 'answer_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`);
|
||
return;
|
||
}
|
||
if (!call.pc) {
|
||
await emitDebug(call, 'warn', 'answer_ignored_without_pc', 'no local peer connection');
|
||
return;
|
||
}
|
||
const pc = call.pc;
|
||
const localType = String(pc.localDescription?.type || '').trim().toLowerCase();
|
||
if (localType !== 'offer') {
|
||
await emitDebug(call, 'warn', 'answer_ignored_without_local_offer', `localType=${localType || 'none'}`);
|
||
return;
|
||
}
|
||
if (pc.signalingState === 'stable' && pc.remoteDescription) {
|
||
await emitDebug(call, 'warn', 'answer_duplicate_ignored', 'remote description already set');
|
||
return;
|
||
}
|
||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
||
ensureReservedVideoTransceiver(call);
|
||
syncRemoteReceiverVideoTracks(call);
|
||
await flushPendingIceCandidates(call);
|
||
refreshVideoSlotReady(call);
|
||
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
notifyCallState();
|
||
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
|
||
} catch (error) {
|
||
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
|
||
await finalizeCall(call, { localReasonCode: 'error', debugReason: `answer_failed:${toErrorText(error)}` });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.ICE) {
|
||
try {
|
||
const candidate = JSON.parse(data);
|
||
call.remoteIceReceivedCount = Number(call.remoteIceReceivedCount || 0) + 1;
|
||
if (!call.pc) {
|
||
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
|
||
call.pendingRemoteIceCandidates.push(candidate);
|
||
if (call.remoteIceReceivedCount <= 3) {
|
||
recordCallTimeline(call, 'ice_remote_queued_no_pc', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
|
||
}
|
||
await emitDebug(call, 'info', 'ice_queued_before_pc', `queue=${call.pendingRemoteIceCandidates.length}`);
|
||
return;
|
||
}
|
||
const pc = call.pc;
|
||
if (!pc.remoteDescription) {
|
||
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
|
||
call.pendingRemoteIceCandidates.push(candidate);
|
||
if (call.remoteIceReceivedCount <= 3) {
|
||
recordCallTimeline(call, 'ice_remote_queued_no_remote_description', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
|
||
}
|
||
await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`);
|
||
return;
|
||
}
|
||
await pc.addIceCandidate(new RTCIceCandidate(candidate));
|
||
if (call.remoteIceReceivedCount <= 3) {
|
||
recordCallTimeline(call, 'ice_remote_applied', `count=${call.remoteIceReceivedCount}`);
|
||
}
|
||
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
|
||
} catch (error) {
|
||
await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error));
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function hangupActiveCall() {
|
||
if (!activeCallId) return;
|
||
const call = getCall(activeCallId);
|
||
dismissCallUiLocally(call);
|
||
recordCallTimeline(call, 'hangup_clicked', `connected=${Boolean(call?.connectedAtMs)}`);
|
||
await finalizeCall(call, {
|
||
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
|
||
debugReason: 'hangup_by_user',
|
||
notifyRemoteHangup: true,
|
||
});
|
||
}
|
||
|
||
export async function handleIncomingCallPush(payload = {}) {
|
||
if (!isCallPushForCurrentSession(payload)) return;
|
||
if (!isIncomingCallPushFresh(payload)) return;
|
||
await handleIncomingInvitePayload(payload, { source: 'push' });
|
||
}
|
||
|
||
export async function handleStopCallPush(payload = {}) {
|
||
if (!isCallPushForCurrentSession(payload)) return;
|
||
const callId = String(payload?.callId || '').trim();
|
||
if (!callId) return;
|
||
const call = getCall(callId);
|
||
if (!call) return;
|
||
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
||
const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push';
|
||
const currentSessionId = String(state?.session?.sessionId || '').trim();
|
||
if (fromSessionId && currentSessionId && fromSessionId === currentSessionId) {
|
||
await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason);
|
||
return;
|
||
}
|
||
recordCallTimeline(call, 'stop_call_push', `fromSession=${fromSessionId || '-'}; reason=${reason}`);
|
||
await finalizeCall(call, {
|
||
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
|
||
debugReason: `stop_call_push:${reason}`,
|
||
suppressRemoteSignal: true,
|
||
suppressReports: true,
|
||
suppressSummary: true,
|
||
});
|
||
}
|
||
|
||
export async function handleCallPushAction(action, payload = {}) {
|
||
if (!isCallPushForCurrentSession(payload)) return;
|
||
const normalized = String(action || '').trim().toLowerCase();
|
||
if (normalized !== 'accept' && normalized !== 'decline') return;
|
||
if (!isIncomingCallPushFresh(payload)) return;
|
||
const timeoutMs = resolveCallPreflightTimeoutMs();
|
||
const ok = await ensureSessionForCall({ timeoutMs, force: false });
|
||
if (!ok) {
|
||
throw new Error('Не удалось подключиться, вызов завершён');
|
||
}
|
||
await handleIncomingCallPush(payload);
|
||
if (normalized === 'accept') {
|
||
await acceptIncomingCall();
|
||
return;
|
||
}
|
||
await declineIncomingCall();
|
||
}
|