import { addChatMessage, 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(); let activeCallId = ''; let debugReporter = null; 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 toErrorText(error) { return error?.message || String(error || 'unknown'); } async function emitDebug(call, level, message, details = '') { if (!call?.debugRunId || typeof debugReporter !== 'function') return; try { await debugReporter({ runId: call.debugRunId, level, message, details, }); } catch {} } function setStatus(call, text) { call.status = text; addChatMessage(call.peerLogin || 'debug-peer', `[call] ${text}`); void emitDebug(call, 'info', `call_status: ${text}`, `callId=${call.callId}`); } function cleanupTimers(call) { if (call.timers?.ack5s) clearTimeout(call.timers.ack5s); if (call.timers?.total22s) clearTimeout(call.timers.total22s); if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); } async function closeMedia(call) { try { call.pc?.close(); } catch {} try { call.localStream?.getTracks()?.forEach((t) => t.stop()); } catch {} call.pc = null; call.localStream = null; } async function finishCall(call, reason, notifyRemote = false) { if (!call) return; cleanupTimers(call); if (notifyRemote && call.remoteSessionId) { try { await authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type: TYPES.HANGUP, data: '', }); } catch {} } await closeMedia(call); setStatus(call, `завершен: ${reason}`); if (String(reason || '').toLowerCase().includes('connected')) { await emitDebug(call, 'info', 'debug_connection_success', reason); } calls.delete(call.callId); if (activeCallId === call.callId) activeCallId = ''; } 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 pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); 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') { setStatus(call, 'соединение установлено'); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); } if (state === 'failed' || state === 'closed' || state === 'disconnected') { void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`); finishCall(call, `state=${state}`, false); } }; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); call.localStream = stream; stream.getTracks().forEach((track) => pc.addTrack(track, stream)); } catch (e) { setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`); await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(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); const pc = await ensurePeerConnection(call); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); setStatus(call, 'отправлен offer'); } export function setCallDebugReporter(fn) { debugReporter = typeof fn === 'function' ? fn : null; } 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', state: 'accepted', remoteSessionId: cleanPeerSessionId, timers: {}, startedAtMs: nowMs(), pc: null, localStream: null, 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'); } 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', state: 'accepted', remoteSessionId: cleanPeerSessionId, timers: {}, startedAtMs: nowMs(), pc: null, localStream: null, debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'initiator', }; calls.set(cleanCallId, call); activeCallId = cleanCallId; 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 finishCall(call, `debug start failed: ${toErrorText(error)}`, false); } } export async function startOutgoingCall(peerLogin) { const cleanPeer = String(peerLogin || '').trim(); if (!cleanPeer) return; if (activeCallId) { addChatMessage(cleanPeer, '[call] уже есть активный звонок'); return; } const callId = makeCallId(); const call = { callId, peerLogin: cleanPeer, direction: 'out', state: 'dialing', remoteSessionId: '', timers: {}, startedAtMs: nowMs(), pc: null, localStream: null, debugMode: false, debugRunId: '', debugRole: '', }; calls.set(callId, call); activeCallId = callId; setStatus(call, 'набираем...'); call.timers.ack5s = setTimeout(() => { if (call.state === 'dialing') { finishCall(call, 'нет ответа 5с', false); } }, 5000); call.timers.total22s = setTimeout(() => { finishCall(call, 'таймаут 22с', false); }, 22000); await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); } 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', state: 'incoming', remoteSessionId: fromSessionId, timers: {}, startedAtMs: nowMs(), pc: null, localStream: null, debugMode: false, debugRunId: '', debugRole: '', }; calls.set(callId, call); } activeCallId = callId; setStatus(call, `входящий звонок от ${fromLogin}`); try { await sendSignal(call, TYPES.RINGING, 'ringing'); } catch {} call.timers.incoming20s = setTimeout(async () => { await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s'); await finishCall(call, 'не ответили 20с', false); }, 20000); const accepted = window.confirm(`Вам звонит ${fromLogin}. Принять звонок?`); if (!accepted) { await sendSignal(call, TYPES.DECLINE_BUSY, 'decline'); await finishCall(call, 'отклонен', false); return; } call.state = 'accepted'; await sendSignal(call, TYPES.ACCEPT, 'accept'); setStatus(call, 'принят, ждём offer'); } 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) { call.state = 'ringing'; setStatus(call, 'идут гудки'); return; } if (type === TYPES.ACCEPT) { call.state = 'accepted'; setStatus(call, 'звонок принят'); await onAccept(call); return; } if (type === TYPES.DECLINE_BUSY) { await finishCall(call, 'занят/отклонено', false); return; } if (type === TYPES.TIMEOUT) { await finishCall(call, 'таймаут на стороне собеседника', false); return; } if (type === TYPES.HANGUP) { await finishCall(call, 'собеседник завершил звонок', false); 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, 'получен offer, отправлен answer'); await emitDebug(call, 'info', 'offer_processed', 'answer sent'); } catch (error) { await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error)); throw error; } return; } if (type === TYPES.ANSWER) { try { const pc = await ensurePeerConnection(call); await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); setStatus(call, 'получен answer'); await emitDebug(call, 'info', 'answer_processed', 'remote description set'); } catch (error) { await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error)); throw 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)); throw error; } } } export async function hangupActiveCall() { if (!activeCallId) return; const call = getCall(activeCallId); await finishCall(call, 'сброшен пользователем', true); }