1382 lines
46 KiB
JavaScript
1382 lines
46 KiB
JavaScript
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 closeMedia(call) {
|
||
try { call.pc?.close(); } catch {}
|
||
try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {}
|
||
call.pc = null;
|
||
call.localStream = null;
|
||
call.audioSenders = [];
|
||
call.connectionRouteLabel = '';
|
||
call.connectionRouteDetails = '';
|
||
}
|
||
|
||
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;
|
||
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),
|
||
};
|
||
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') {
|
||
if (call.connectedAtMs) {
|
||
startReconnectFlow(call, 'failed');
|
||
return;
|
||
}
|
||
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
|
||
void finalizeCall(call, { localReasonCode: 'error', debugReason: 'failed' });
|
||
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;
|
||
return pc;
|
||
}
|
||
|
||
async function onAccept(call) {
|
||
cleanupTimers(call);
|
||
setStatus(call, 'Соединяем…', 'connecting');
|
||
const pc = await ensurePeerConnection(call);
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
||
await emitDebug(call, 'info', 'offer_sent', 'offer created and sent');
|
||
}
|
||
|
||
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 {}
|
||
}
|
||
|
||
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',
|
||
};
|
||
calls.set(cleanCallId, call);
|
||
}
|
||
|
||
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',
|
||
};
|
||
|
||
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: '',
|
||
};
|
||
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) {
|
||
const payload = evt?.payload || {};
|
||
const callId = String(payload.callId || '').trim();
|
||
const fromLogin = String(payload.fromLogin || '').trim();
|
||
const fromSessionId = String(payload.fromSessionId || '').trim();
|
||
if (!callId || !fromLogin || !fromSessionId) return;
|
||
|
||
if (activeCallId && activeCallId !== callId) {
|
||
try {
|
||
await authService.callSignalToSession({
|
||
toLogin: fromLogin,
|
||
targetSessionId: fromSessionId,
|
||
callId,
|
||
type: TYPES.DECLINE_BUSY,
|
||
data: 'busy',
|
||
});
|
||
} catch {}
|
||
return;
|
||
}
|
||
|
||
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: '',
|
||
};
|
||
calls.set(callId, call);
|
||
}
|
||
|
||
activeCallId = callId;
|
||
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||
ensureIncomingNotification(fromLogin);
|
||
|
||
try {
|
||
await sendSignal(call, TYPES.RINGING, 'ringing');
|
||
} catch {}
|
||
|
||
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);
|
||
}
|
||
|
||
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.remoteSessionId) call.remoteSessionId = fromSessionId;
|
||
|
||
if (type === TYPES.RINGING) {
|
||
if (call.direction === 'out' && call.phase === 'searching') {
|
||
setStatus(call, 'Вызываем…', 'ringing');
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === TYPES.ACCEPT) {
|
||
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)));
|
||
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 {
|
||
const pc = await ensurePeerConnection(call);
|
||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
||
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 pc = await ensurePeerConnection(call);
|
||
await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data)));
|
||
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,
|
||
});
|
||
}
|