Обновить диагностику звонков и документацию деплоя

This commit is contained in:
AidarKC
2026-07-14 20:07:31 +04:00
parent 2a4c2c64c9
commit 118be418b5
10 changed files with 393 additions and 68 deletions
+239 -13
View File
@@ -1,4 +1,4 @@
import { addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
import { addAppLogEntry, addSignedMessageToChat, authService, authorizeSession, state } from '../state.js';
import { buildDmCallTechBlock } from './dm-tech-blocks.js';
const TYPES = {
@@ -28,11 +28,97 @@ const DEFAULT_ICE_SERVERS = Object.freeze([
{ urls: 'stun:stun.l.google.com:19302' },
]);
const CALL_SUMMARY_MIN_TOTAL_MS = 5000;
const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000;
const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000;
const INCOMING_CONNECT_TIMEOUT_MS = 20_000;
const MAX_TIMELINE_ENTRIES = 64;
function nowMs() {
return Date.now();
}
function sanitizeTimelineText(value) {
return String(value || '')
.replace(/\s+/g, ' ')
.replace(/[,]/g, ';')
.trim();
}
function getSignalTypeName(type) {
const normalized = Number(type);
if (normalized === TYPES.INVITE) return 'invite';
if (normalized === TYPES.RINGING) return 'ringing';
if (normalized === TYPES.ACCEPT) return 'accept';
if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy';
if (normalized === TYPES.TIMEOUT) return 'timeout';
if (normalized === TYPES.HANGUP) return 'hangup';
if (normalized === TYPES.OFFER) return 'offer';
if (normalized === TYPES.ANSWER) return 'answer';
if (normalized === TYPES.ICE) return 'ice';
return `type_${String(type || '')}`;
}
function ensureCallTimeline(call) {
if (!call) return;
if (!Array.isArray(call.timelineEvents)) call.timelineEvents = [];
if (!Number.isFinite(call.timelineSeq)) call.timelineSeq = 0;
if (!Number.isFinite(call.timelineBaseMs) || call.timelineBaseMs <= 0) {
call.timelineBaseMs = Number(call.startedAtMs || nowMs());
}
}
function recordCallTimeline(call, stage, details = '', level = 'info') {
if (!call) return;
ensureCallTimeline(call);
const ts = nowMs();
const deltaMs = Math.max(0, ts - Number(call.timelineBaseMs || ts));
const cleanStage = sanitizeTimelineText(stage) || 'unknown';
const cleanDetails = sanitizeTimelineText(details);
call.timelineSeq += 1;
call.timelineEvents.push({
seq: call.timelineSeq,
ts,
deltaMs,
stage: cleanStage,
details: cleanDetails,
});
if (call.timelineEvents.length > MAX_TIMELINE_ENTRIES) {
call.timelineEvents.splice(0, call.timelineEvents.length - MAX_TIMELINE_ENTRIES);
}
addAppLogEntry({
level,
source: 'call',
message: `[${call.callId || '?'}] +${deltaMs}ms ${cleanStage}`,
details: cleanDetails || buildCallFactsJson(call),
});
}
function buildTimelineSummary(call) {
const events = Array.isArray(call?.timelineEvents) ? call.timelineEvents : [];
if (!events.length) return '';
return events
.map((entry) => {
const head = `+${Number(entry?.deltaMs || 0)}:${sanitizeTimelineText(entry?.stage || '')}`;
const tail = sanitizeTimelineText(entry?.details || '');
return tail ? `${head}(${tail})` : head;
})
.join('|')
.slice(0, 1800);
}
function setRemoteSessionId(call, sessionId, reason = '') {
if (!call) return;
const next = String(sessionId || '').trim();
if (!next) return;
const prev = String(call.remoteSessionId || '').trim();
if (prev === next) {
recordCallTimeline(call, 'remote_session_confirmed', `session=${next}; reason=${reason || 'same'}`);
return;
}
call.remoteSessionId = next;
recordCallTimeline(call, 'remote_session_selected', `from=${prev || '-'}; to=${next}; reason=${reason || 'unknown'}`);
}
function resolveCallPreflightTimeoutMs() {
const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000);
return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000));
@@ -112,6 +198,16 @@ function isInviteUndelivered(call) {
return (wsDelivered + pushDelivered) <= 0;
}
function getInviteDeliveredCount(call) {
const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0);
const pushDelivered = Number(
call?.inviteDelivery?.deliveredWebPushSessions
|| call?.inviteDelivery?.deliveredFcmSessions
|| 0
);
return wsDelivered + pushDelivered;
}
function buildOutgoingCallSummaryText(call, summaryCode) {
if (!call || call.direction !== 'out') return '';
if (summaryCode === 'completed') {
@@ -492,6 +588,7 @@ function setStatus(call, statusText, phase = '') {
else if (call.phase === 'incoming') startTone('incoming');
else stopTone();
recordCallTimeline(call, 'status', `phase=${call.phase || ''}; text=${call.statusText || ''}`);
void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`);
notifyCallState();
}
@@ -531,6 +628,7 @@ function buildCallFactsJson(call, extra = {}) {
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
timeline: buildTimelineSummary(call),
...extra,
};
try {
@@ -560,6 +658,7 @@ function buildCallFactsLine(call, extra = {}) {
pcSignalingState: pc?.signalingState || '',
hasLocalStream: Boolean(call?.localStream),
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
timeline: buildTimelineSummary(call),
...extra,
};
return Object.entries(facts)
@@ -634,6 +733,7 @@ async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', e
reason: String(reason || '').trim(),
reportedAtMs: nowMs(),
reportedAtIso: toIsoTs(nowMs()),
timelineEntriesCount: Array.isArray(call?.timelineEvents) ? call.timelineEvents.length : 0,
...diagnostics,
...extraFacts,
});
@@ -648,10 +748,49 @@ 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?.incomingConnect20s) clearTimeout(call.timers.incomingConnect20s);
if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe);
call.timers.transportProbe = null;
}
function scheduleOutgoingAckTimeout(call) {
if (!call?.timers) return;
if (call.timers.ack10s) {
clearTimeout(call.timers.ack10s);
call.timers.ack10s = null;
}
const deliveredCount = getInviteDeliveredCount(call);
const timeoutMs = deliveredCount > 0
? OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS
: OUTGOING_NO_ACK_TIMEOUT_MS;
const debugReason = deliveredCount > 0 ? 'no_ack_after_delivery_25s' : 'no_ack_10s';
recordCallTimeline(call, 'ack_timeout_scheduled', `timeoutMs=${timeoutMs}; delivered=${deliveredCount}; reason=${debugReason}`);
call.timers.ack10s = setTimeout(() => {
if (!calls.has(call.callId)) return;
if (call.phase === 'searching' || call.phase === 'ringing') {
recordCallTimeline(call, 'ack_timeout_fired', `phase=${call.phase || ''}; reason=${debugReason}`, 'warn');
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason });
}
}, timeoutMs);
}
function scheduleIncomingConnectTimeout(call) {
if (!call?.timers) return;
if (call.timers.incomingConnect20s) {
clearTimeout(call.timers.incomingConnect20s);
}
recordCallTimeline(call, 'incoming_connect_timeout_scheduled', `timeoutMs=${INCOMING_CONNECT_TIMEOUT_MS}`);
call.timers.incomingConnect20s = setTimeout(() => {
if (!calls.has(call.callId)) return;
if (call.phase !== 'connecting') return;
recordCallTimeline(call, 'incoming_connect_timeout_fired', `phase=${call.phase || ''}`, 'warn');
void finalizeCall(call, {
localReasonCode: 'no_answer',
debugReason: 'incoming_connect_timeout_20s',
});
}, INCOMING_CONNECT_TIMEOUT_MS);
}
async function flushPendingIceCandidates(call) {
if (!call?.pc) return;
const pending = Array.isArray(call.pendingRemoteIceCandidates) ? call.pendingRemoteIceCandidates : [];
@@ -831,6 +970,7 @@ function startReconnectFlow(call, reason = 'disconnected') {
call.reconnectInProgress = true;
call.reconnectAttempts = 0;
recordCallTimeline(call, 'reconnect_start', `reason=${reason}`);
setStatus(call, 'Связь прервалась. Переподключаем…', 'reconnecting');
void emitDebug(call, 'warn', 'peer_connection_reconnect_start', `reason=${reason}`);
@@ -853,6 +993,7 @@ function startReconnectFlow(call, reason = 'disconnected') {
}
call.reconnectAttempts += 1;
recordCallTimeline(call, 'reconnect_attempt', `attempt=${call.reconnectAttempts}`);
try {
const offer = await call.pc.createOffer({ iceRestart: true });
await call.pc.setLocalDescription(offer);
@@ -888,10 +1029,19 @@ async function finalizeCall(call, {
suppressSummary = false,
} = {}) {
if (!call) return;
recordCallTimeline(
call,
'finalize_begin',
`reason=${localReasonCode || ''}; debug=${debugReason || ''}; phase=${call.phase || ''}`,
String(localReasonCode || '') === 'completed' ? 'info' : 'warn',
);
const diagnosticsBeforeClose = getCallDiagnosticsContext(call);
cleanupTimers(call);
stopReconnectFlow(call);
stopTone();
if (String(debugReason || '') === 'remote_hangup' && call.direction === 'in') {
dismissCallUiLocally(call);
}
const shouldNotifyRemoteFailure =
!suppressRemoteSignal
@@ -955,6 +1105,7 @@ async function finalizeCall(call, {
if (String(localReasonCode || '') === 'busy') {
call.statusText = 'Пользователь занят';
}
recordCallTimeline(call, 'finalize_end', `reason=${localReasonCode || ''}; status=${call.statusText || ''}`);
notifyCallState();
const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0;
@@ -990,6 +1141,7 @@ async function emitDebug(call, level, message, details = '') {
async function sendSignal(call, type, data = '') {
if (!call.remoteSessionId) return;
const signalName = getSignalTypeName(type);
try {
await authService.callSignalToSession({
toLogin: call.peerLogin,
@@ -998,8 +1150,18 @@ async function sendSignal(call, type, data = '') {
type,
data,
});
recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`);
await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`);
} catch (error) {
if (String(error?.code || '').toUpperCase() === 'SESSION_NOT_FOUND') {
recordCallTimeline(
call,
`signal_out_${signalName}_session_not_found`,
`toSession=${call.remoteSessionId}; op=${String(error?.op || '')}; status=${String(error?.status || '')}`,
'warn',
);
}
recordCallTimeline(call, `signal_out_${signalName}_failed`, `toSession=${call.remoteSessionId}; error=${toErrorText(error)}`, 'warn');
await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error));
throw error;
}
@@ -1038,6 +1200,14 @@ async function ensurePeerConnection(call) {
pc.onicecandidate = async (event) => {
if (!event.candidate || !call.remoteSessionId) return;
call.localIceSentCount = Number(call.localIceSentCount || 0) + 1;
if (call.localIceSentCount <= 3) {
recordCallTimeline(
call,
'ice_local_candidate',
`count=${call.localIceSentCount}; type=${event.candidate?.type || ''}; protocol=${event.candidate?.protocol || ''}`,
);
}
try {
await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate));
} catch {}
@@ -1045,8 +1215,18 @@ async function ensurePeerConnection(call) {
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
recordCallTimeline(
call,
'pc_connection_state',
`state=${state || ''}; ice=${pc.iceConnectionState || ''}; signal=${pc.signalingState || ''}`,
state === 'failed' ? 'warn' : 'info',
);
if (state === 'connected') {
stopReconnectFlow(call);
if (call.timers?.incomingConnect20s) {
clearTimeout(call.timers.incomingConnect20s);
call.timers.incomingConnect20s = null;
}
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
@@ -1115,6 +1295,7 @@ async function ensurePeerConnection(call) {
call.audioSenders = [];
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
stream.getTracks().forEach((track) => {
track.enabled = track.kind === 'audio' ? !call.muted : true;
const sender = pc.addTrack(track, stream);
@@ -1124,11 +1305,13 @@ async function ensurePeerConnection(call) {
});
} catch (e) {
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
throw e;
}
pc.ontrack = (evt) => {
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = evt.streams[0];
@@ -1148,6 +1331,7 @@ async function onAccept(call) {
await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`);
return;
}
recordCallTimeline(call, 'offer_start', `remoteSession=${call.remoteSessionId || ''}`);
call.initialOfferInProgress = true;
cleanupTimers(call);
setStatus(call, 'Соединяем…', 'connecting');
@@ -1237,10 +1421,14 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(callId, call);
recordCallTimeline(call, 'incoming_invite_created', `from=${fromLogin}; session=${fromSessionId}; source=${source}`);
} else if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
}
activeCallId = callId;
@@ -1396,10 +1584,14 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(cleanCallId, call);
activeCallId = cleanCallId;
recordCallTimeline(call, 'debug_initiator_created', `peer=${cleanPeerLogin}; session=${cleanPeerSessionId}`);
notifyCallState();
await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`);
try {
@@ -1449,28 +1641,36 @@ export async function startOutgoingCall(peerLogin) {
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
};
calls.set(callId, call);
activeCallId = callId;
recordCallTimeline(call, 'outgoing_created', `peer=${cleanPeer}`);
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) {
recordCallTimeline(call, 'total_timeout_fired', 'total_timeout_35s', 'warn');
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' });
}
}, 35000);
try {
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
recordCallTimeline(
call,
'invite_broadcast_ok',
`ws=${Number(call.inviteDelivery?.deliveredWsSessions || 0)}; push=${Number(call.inviteDelivery?.deliveredWebPushSessions || call.inviteDelivery?.deliveredFcmSessions || 0)}`,
);
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
setStatus(call, 'Вызываем…', 'ringing');
}
scheduleOutgoingAckTimeout(call);
} catch (error) {
recordCallTimeline(call, 'invite_broadcast_failed', toErrorText(error), 'warn');
const text = String(error?.message || '').toUpperCase();
const isNotAuth = text.includes('NOT_AUTHENTICATED');
if (isNotAuth) {
@@ -1478,6 +1678,10 @@ export async function startOutgoingCall(peerLogin) {
if (recovered) {
try {
call.inviteDelivery = await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
setStatus(call, 'Вызываем…', 'ringing');
}
scheduleOutgoingAckTimeout(call);
return;
} catch {}
}
@@ -1496,9 +1700,11 @@ export async function handleIncomingCallInvite(evt) {
export async function acceptIncomingCall() {
const call = getActiveCall();
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
call.phase = 'connecting';
setStatus(call, 'Соединяем…', 'connecting');
cleanupTimers(call);
scheduleIncomingConnectTimeout(call);
await sendSignal(call, TYPES.ACCEPT, 'accept');
}
@@ -1530,12 +1736,15 @@ export async function handleIncomingCallSignal(evt) {
const call = getCall(callId);
if (!call) return;
const signalName = getSignalTypeName(type);
recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`);
if (call.direction === 'out') {
if (type === TYPES.RINGING) {
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'ringing');
}
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
recordCallTimeline(call, 'signal_in_ringing_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1547,8 +1756,9 @@ export async function handleIncomingCallSignal(evt) {
} else if (type === TYPES.ACCEPT) {
if (fromSessionId) {
if (!call.remoteSessionId || !call.initialOfferSent) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, 'accept_before_offer_lock');
} else if (call.remoteSessionId !== fromSessionId) {
recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn');
await emitDebug(
call,
'warn',
@@ -1573,6 +1783,7 @@ export async function handleIncomingCallSignal(evt) {
&& (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP);
if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) {
if (terminalSignalFromAnotherSession && !remoteSessionLocked) {
recordCallTimeline(call, 'terminal_signal_before_lock_allowed', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1580,6 +1791,7 @@ export async function handleIncomingCallSignal(evt) {
`type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`,
);
} else {
recordCallTimeline(call, 'signal_in_ignored_non_selected_session', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`);
await emitDebug(
call,
'info',
@@ -1590,11 +1802,11 @@ export async function handleIncomingCallSignal(evt) {
}
}
if (!call.remoteSessionId && fromSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, `first_${signalName}`);
}
}
} else if (!call.remoteSessionId) {
call.remoteSessionId = fromSessionId;
setRemoteSessionId(call, fromSessionId, `incoming_${signalName}`);
}
if (type === TYPES.RINGING) {
@@ -1640,6 +1852,7 @@ export async function handleIncomingCallSignal(evt) {
if (type === TYPES.OFFER) {
try {
recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`);
const pc = await ensurePeerConnection(call);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
await flushPendingIceCandidates(call);
@@ -1677,6 +1890,7 @@ export async function handleIncomingCallSignal(evt) {
}
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
await flushPendingIceCandidates(call);
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
setStatus(call, 'Соединяем…', 'connecting');
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
} catch (error) {
@@ -1689,9 +1903,13 @@ export async function handleIncomingCallSignal(evt) {
if (type === TYPES.ICE) {
try {
const candidate = JSON.parse(data);
call.remoteIceReceivedCount = Number(call.remoteIceReceivedCount || 0) + 1;
if (!call.pc) {
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
call.pendingRemoteIceCandidates.push(candidate);
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_queued_no_pc', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
}
await emitDebug(call, 'info', 'ice_queued_before_pc', `queue=${call.pendingRemoteIceCandidates.length}`);
return;
}
@@ -1699,10 +1917,16 @@ export async function handleIncomingCallSignal(evt) {
if (!pc.remoteDescription) {
if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = [];
call.pendingRemoteIceCandidates.push(candidate);
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_queued_no_remote_description', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`);
}
await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`);
return;
}
await pc.addIceCandidate(new RTCIceCandidate(candidate));
if (call.remoteIceReceivedCount <= 3) {
recordCallTimeline(call, 'ice_remote_applied', `count=${call.remoteIceReceivedCount}`);
}
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
} catch (error) {
await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error));
@@ -1714,6 +1938,7 @@ export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
dismissCallUiLocally(call);
recordCallTimeline(call, 'hangup_clicked', `connected=${Boolean(call?.connectedAtMs)}`);
await finalizeCall(call, {
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'hangup_by_user',
@@ -1740,6 +1965,7 @@ export async function handleStopCallPush(payload = {}) {
await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason);
return;
}
recordCallTimeline(call, 'stop_call_push', `fromSession=${fromSessionId || '-'}; reason=${reason}`);
await finalizeCall(call, {
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
debugReason: `stop_call_push:${reason}`,