SHiNE-server/shine-UI/js/services/call-service.js

1662 lines
56 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { addSystemChatMessage, authService, authorizeSession, state } 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 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 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 = 'Звонок завершён';
if (String(localReasonCode || '') === 'busy') {
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;
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 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,
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) {
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 {
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
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 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;
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();
}