441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
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);
|
||
}
|