Files
helix-engage/src/state/sip-manager.ts
saridsa2 5632f15031 fix: P1 call-desk defects batch
- Mute persists across calls: sip-manager's "ended/failed" branch now
  resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
  the SIP track was unmuted, leaving the UI icon + toggle logic in a
  muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
  was missing an isRegistered check in its disabled prop, so it stayed
  clickable when SIP was down. Button now shows "Telephony unavailable"
  and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
  min constraint. Switched to a raw <input type="date"> with min set
  to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
  a "give me a quick summary of <caller>" request whenever the caller's
  leadId changes (new incoming call). Clears prior chat state so each
  caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:38:35 +05:30

127 lines
4.4 KiB
TypeScript

import { SIPClient } from '@/lib/sip-client';
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
// Singleton SIP client — survives React StrictMode remounts
let sipClient: SIPClient | null = null;
let connected = false;
let outboundPending = false;
let outboundActive = false;
let activeAgentId: string | null = null;
type StateUpdater = {
setConnectionStatus: (status: ConnectionStatus) => void;
setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void;
setCallUcid: (ucid: string | null) => void;
setIsMuted: (muted: boolean) => void;
setIsOnHold: (onHold: boolean) => void;
};
let stateUpdater: StateUpdater | null = null;
export function registerSipStateUpdater(updater: StateUpdater) {
stateUpdater = updater;
}
export function setOutboundPending(pending: boolean) {
outboundPending = pending;
}
export function isOutboundPending(): boolean {
return outboundPending;
}
export function connectSip(config: SIPConfig): void {
if (connected || sipClient?.isRegistered() || sipClient?.isConnected()) {
return;
}
if (!config.wsServer || !config.uri) {
console.warn('SIP config incomplete — wsServer and uri required');
return;
}
if (sipClient) {
sipClient.disconnect();
}
// Resolve agent identity for logging
try {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
activeAgentId = agentCfg.ozonetelAgentId ?? null;
const ext = config.uri?.match(/sip:(\d+)@/)?.[1] ?? 'unknown';
console.log(`[SIP] Connecting agent=${activeAgentId} ext=${ext} ws=${config.wsServer}`);
} catch {
console.log(`[SIP] Connecting uri=${config.uri}`);
}
connected = true;
stateUpdater?.setConnectionStatus('connecting');
sipClient = new SIPClient(
config,
(status) => stateUpdater?.setConnectionStatus(status),
(state, number, ucid) => {
// Auto-answer SIP when it's a bridge from our outbound call
if (state === 'ringing-in' && outboundPending) {
outboundPending = false;
outboundActive = true;
console.log('[SIP-MGR] Outbound bridge detected — auto-answering');
setTimeout(() => {
sipClient?.answer();
setTimeout(() => stateUpdater?.setCallState('active'), 300);
}, 500);
if (ucid) stateUpdater?.setCallUcid(ucid);
return;
}
console.log(`[SIP] ${activeAgentId} | state=${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outbound=${outboundActive}`);
stateUpdater?.setCallState(state);
if (!outboundActive && number !== undefined) {
stateUpdater?.setCallerNumber(number ?? null);
}
if (ucid) stateUpdater?.setCallUcid(ucid);
if (state === 'ended' || state === 'failed') {
// Reset both the SIP track AND the Recoil state — otherwise the
// UI icon + toggle-mute branch logic stay "muted" and the next
// call opens in a confusing half-muted state.
sipClient?.unmute();
sipClient?.unhold();
stateUpdater?.setIsMuted(false);
stateUpdater?.setIsOnHold(false);
outboundActive = false;
outboundPending = false;
}
},
);
sipClient.connect();
}
export function disconnectSip(force = false, reason = 'unspecified'): void {
// Guard: don't disconnect SIP during an active or pending call
// unless explicitly forced (e.g., logout, page unload).
// This prevents React re-render cycles from killing the
// SIP WebSocket mid-dial.
if (!force && (outboundPending || outboundActive)) {
console.log(`[SIP-MGR] Disconnect blocked — call in progress (reason=${reason})`);
return;
}
console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
sipClient?.disconnect();
sipClient = null;
connected = false;
outboundPending = false;
outboundActive = false;
activeAgentId = null;
stateUpdater?.setConnectionStatus('disconnected');
stateUpdater?.setCallUcid(null);
}
export function getSipClient(): SIPClient | null {
return sipClient;
}