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

825 lines
24 KiB
JavaScript
Raw 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 } 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;
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 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 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);
}
async function closeMedia(call) {
try { call.pc?.close(); } catch {}
try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {}
call.pc = null;
call.localStream = null;
}
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;
}
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();
if (notifyRemoteHangup && call.remoteSessionId) {
try {
await authService.callSignalToSession({
toLogin: call.peerLogin,
targetSessionId: call.remoteSessionId,
callId: call.callId,
type: TYPES.HANGUP,
data: '',
});
} 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}`);
}
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 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') {
stopReconnectFlow(call);
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
setStatus(call, 'Разговор идёт', 'active');
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
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;
stream.getTracks().forEach((track) => {
track.enabled = !call.muted;
pc.addTrack(track, stream);
});
} 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();
}
export async function setMicMuted(muted) {
const call = getActiveCall();
if (!call) return;
call.muted = Boolean(muted);
try {
call.localStream?.getAudioTracks()?.forEach((track) => {
track.enabled = !call.muted;
});
} catch {}
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,
muted: false,
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,
muted: false,
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,
muted: false,
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,
muted: false,
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) {
await finalizeCall(call, { localReasonCode: 'busy', debugReason: 'decline_or_busy' });
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,
});
}