import { addSystemChatMessage, authService } from '../state.js'; const TYPES = { INVITE: 100, RINGING: 110, ACCEPT: 120, DECLINE_BUSY: 130, TIMEOUT: 140, HANGUP: 150, OFFER: 200, ANSWER: 210, ICE: 220, }; 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' }, ]); function nowMs() { return Date.now(); } 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 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; const callPhase = String(call.phase || '').trim(); return { callId: call.callId, peerLogin: call.peerLogin || '', direction: call.direction || 'out', phase: callPhase, statusText: call.statusText || '', muted: Boolean(call.muted), canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', canHangup: callPhase !== 'ended' && callPhase !== 'incoming', canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting', }; } function notifyCallState() { const snapshot = getCallStateSnapshot(); callStateListeners.forEach((listener) => { try { listener(snapshot); } catch {} }); } 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(); void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`); notifyCallState(); } function buildActiveStatusText(call) { const route = String(call?.connectionRouteLabel || '').trim(); return route ? `Разговор идёт (${route})` : 'Разговор идёт'; } 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, ...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, ...extra, }; return Object.entries(facts) .map(([k, v]) => `${k}=${String(v ?? '').replace(/,/g, ';')}`) .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 enabledLocalAudioTracks = localAudioTracks.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 || ''; 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('|'), 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()), ...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?.transportProbe) clearInterval(call.timers.transportProbe); call.timers.transportProbe = null; } 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; 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 { call.localStream?.getTracks?.()?.forEach((track) => { try { track.enabled = false; } catch {} try { track.stop(); } catch {} }); } catch {} try { if (call.remoteAudio) { try { call.remoteAudio.pause?.(); } catch {} try { call.remoteAudio.srcObject = null; } catch {} call.remoteAudio = null; } } catch {} try { pc?.close?.(); } catch {} call.pc = null; call.localStream = null; call.audioSenders = []; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; call.pendingRemoteIceCandidates = []; } 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; 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; 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(); } function pushCallSummary(call, summaryCode) { if (!call?.peerLogin) return; const outgoing = call.direction === 'out'; const from = outgoing ? 'out' : 'in'; if (summaryCode === 'busy') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (занято)' : `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'no_answer') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (нет ответа)' : `[Звонок] Пропущенный входящий от ${call.peerLogin}`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'declined') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (отклонён)' : `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'error') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (ошибка соединения)' : `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'completed') { const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())); const label = outgoing ? 'Исходящий' : 'Входящий'; addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, { from, kind: 'call-tech', }); } } async function finalizeCall(call, { localReasonCode = 'error', debugReason = '', notifyRemoteHangup = false, } = {}) { if (!call) return; const diagnosticsBeforeClose = getCallDiagnosticsContext(call); cleanupTimers(call); stopReconnectFlow(call); stopTone(); const shouldNotifyRemoteFailure = !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 authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type: TYPES.HANGUP, data: 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 (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); } } pushCallSummary(call, localReasonCode); call.phase = 'ended'; call.statusText = 'Звонок завершён'; notifyCallState(); 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; try { await authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type, data, }); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); } catch (error) { await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error)); throw error; } } 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; try { await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate)); } catch {} }; pc.onconnectionstatechange = () => { const state = pc.connectionState; if (state === 'connected') { stopReconnectFlow(call); if (!call.connectedAtMs) { call.connectedAtMs = nowMs(); } 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 }); } }; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); call.localStream = stream; call.audioSenders = []; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; 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); } }); } catch (e) { setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed'); await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e)); throw e; } pc.ontrack = (evt) => { const audio = new Audio(); audio.autoplay = true; audio.srcObject = evt.streams[0]; call.remoteAudio = audio; }; call.pc = pc; if (!Array.isArray(call.pendingRemoteIceCandidates)) { call.pendingRemoteIceCandidates = []; } return pc; } async function onAccept(call) { if (!call) return; if (call.initialOfferInProgress || call.initialOfferSent) { await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`); return; } call.initialOfferInProgress = true; cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); try { const pc = await ensurePeerConnection(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; } async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { const callId = String(payload?.callId || '').trim(); const fromLogin = String(payload?.fromLogin || '').trim(); const fromSessionId = String(payload?.fromSessionId || '').trim(); if (!callId || !fromLogin || !fromSessionId) return null; if (activeCallId && activeCallId !== callId) { try { await authService.callSignalToSession({ toLogin: fromLogin, targetSessionId: fromSessionId, callId, type: TYPES.DECLINE_BUSY, data: 'busy', }); } 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, audioSenders: [], muted: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, }; calls.set(callId, call); } else if (!call.remoteSessionId && fromSessionId) { call.remoteSessionId = fromSessionId; } 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 {} } } 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 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, audioSenders: [], muted: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'responder', pendingRemoteIceCandidates: [], 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, audioSenders: [], muted: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'initiator', pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, }; calls.set(cleanCallId, call); activeCallId = cleanCallId; 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 call = { callId, peerLogin: cleanPeer, direction: 'out', phase: 'searching', statusText: 'Ищем пользователя…', remoteSessionId: '', timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, audioSenders: [], muted: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', pendingRemoteIceCandidates: [], initialOfferInProgress: false, initialOfferSent: false, }; calls.set(callId, call); activeCallId = callId; setStatus(call, 'Ищем пользователя…', 'searching'); call.timers.ack10s = setTimeout(() => { if (!calls.has(callId)) return; if (call.phase === 'searching') { void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' }); } }, 10000); call.timers.total35s = setTimeout(() => { if (!calls.has(callId)) return; if (!call.connectedAtMs) { void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' }); } }, 35000); try { await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); } catch (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 acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); cleanupTimers(call); await sendSignal(call, TYPES.ACCEPT, 'accept'); } export async function declineIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; try { await sendSignal(call, TYPES.DECLINE_BUSY, 'decline'); } catch {} await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user' }); } 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; if (call.direction === 'out') { if (type === TYPES.RINGING) { if (!call.remoteSessionId && fromSessionId) { call.remoteSessionId = fromSessionId; } if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { await emitDebug( call, 'info', 'ringing_from_non_selected_session_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, ); return; } } else if (type === TYPES.ACCEPT) { if (fromSessionId) { if (!call.remoteSessionId || !call.initialOfferSent) { call.remoteSessionId = fromSessionId; } else if (call.remoteSessionId !== fromSessionId) { await emitDebug( call, 'warn', 'accept_from_non_selected_session_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, ); return; } } } else { if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { await emitDebug( call, 'info', 'signal_from_non_selected_session_ignored', `type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`, ); return; } if (!call.remoteSessionId && fromSessionId) { call.remoteSessionId = fromSessionId; } } } else if (!call.remoteSessionId) { call.remoteSessionId = fromSessionId; } if (type === TYPES.RINGING) { if (call.direction === 'out' && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } 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 { const pc = await ensurePeerConnection(call); await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); await flushPendingIceCandidates(call); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); setStatus(call, 'Соединяем…', 'connecting'); 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))); await flushPendingIceCandidates(call); setStatus(call, 'Соединяем…', 'connecting'); 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); if (!call.pc) { if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; call.pendingRemoteIceCandidates.push(candidate); 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); await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`); return; } await pc.addIceCandidate(new RTCIceCandidate(candidate)); 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); await finalizeCall(call, { localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer', debugReason: 'hangup_by_user', notifyRemoteHangup: true, }); } export async function handleIncomingCallPush(payload = {}) { if (!isIncomingCallPushFresh(payload)) return; await handleIncomingInvitePayload(payload, { source: 'push' }); } export async function handleStopCallPush(payload = {}) { const callId = String(payload?.callId || '').trim(); if (!callId) return; const call = getCall(callId); if (!call) return; const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push'; await finalizeCall(call, { localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer', debugReason: `stop_call_push:${reason}`, }); } export async function handleCallPushAction(action, payload = {}) { const normalized = String(action || '').trim().toLowerCase(); if (normalized !== 'accept' && normalized !== 'decline') return; if (!isIncomingCallPushFresh(payload)) return; await handleIncomingCallPush(payload); if (normalized === 'accept') { await acceptIncomingCall(); return; } await declineIncomingCall(); }