/** * Click-to-dial call session: originate on Asterisk, then follow the call * DETACHED from the HTTP request until it ends, and record the outcome as a * C_ContactActivity on the called user (phoneActivity.ts). * * Flow (classic click-to-dial): * 1. AMI Originate Channel=PJSIP/ → rings the agent's device directly. * NOT Local/@from-internal: FreePBX Follow-Me's announcement answers * a Local leg before anyone picks up, so Asterisk would report "answered" * and dial the target while the agent's phone is still ringing (verified). * 2. agent picks up → Asterisk runs Exten=@from-internal → dials out * 3. events on the channels that inherited __LOGSHIP_CALL_ID tell us what * happened (manager.conf `channelvars=LOGSHIP_CALL_ID` exposes the var on * every channel event as `ChanVariable: LOGSHIP_CALL_ID=`, and on the * destination side of DialBegin/DialEnd as `DestChanVariable: …`) * * State machine * WAIT_AGENT → Originate accepted; agent's phone rings * AGENT_ANSWERED → OriginateResponse Success (Reason 4) or DialEnd ANSWER on the agent device * AGENT_FAILED → OriginateResponse Failure (no answer / busy / …) → finalize * TARGET_RINGING → DialBegin from a channel of ours to a non-agent device * TALKING → DialEnd ANSWER on that destination * TARGET_FAILED → DialEnd BUSY/NOANSWER/CANCEL/CONGESTION/CHANUNAVAIL (trunk failover may dial again) * FINALIZED → every tracked channel hung up (+2 s grace) | socket lost | hard cap 3 h * * The HTTP promise resolves as soon as the Originate is ACCEPTED. Everything * afterwards is fire-and-forget with its own error handling and unref'd timers, * one TCP connection per call (nothing shared, nothing to reconnect, nothing * that can wedge the process). PM2 runs 2 instances: each instance only ever * tracks the calls it originated — foreign events are dropped by the uuid filter. */ import { randomUUID } from 'node:crypto' import { connectAmi, amiValue, amiChanVariable, type AmiClient, type AmiPacket } from './amiClient' import { createPhoneCallActivity, type PhoneCallContext, type PhoneCallOutcome, type TargetStatus } from './phoneActivity' import { listPbxExtensions } from './extensions' const HARD_CAP_MS = 3 * 60 * 60 * 1000 const HANGUP_GRACE_MS = 2000 const ORIGINATE_ACK_TIMEOUT_MS = 10000 const MAX_ACTIVE_CALLS = 20 /** After the originate is queued, wait this long for an EARLY failure of the * agent leg (device unreachable / rejected) so the click gets a real error * instead of a silent "ringing" — the agent's phone answers far later anyway. */ const EARLY_FAILURE_WINDOW_MS = 1500 type State = 'WAIT_AGENT' | 'AGENT_ANSWERED' | 'AGENT_FAILED' | 'TARGET_RINGING' | 'TALKING' | 'TARGET_FAILED' | 'FINALIZED' interface Tracker { ctx: PhoneCallContext state: State agentExt: string agentAnsweredAt: number | null targetUniqueid: string | null targetStatus: TargetStatus targetAnsweredAt: number | null targetHangupAt: number | null lastHangupAt: number | null hangupCause: string live: Set sawDial: boolean } const g = globalThis as any const active: Map = g.__dialTrackers ?? (g.__dialTrackers = new Map()) export const activeCallCount = () => active.size /** True while a click-to-dial for this extension is still ringing the agent's phone. */ export const hasPendingAgentLeg = (ext: string) => { for (const t of active.values()) if (t.agentExt === ext && t.state === 'WAIT_AGENT') return true return false } /** * Digits only; `(0)` after a country code dropped; `+` → `00`; separators * stripped. Returns '' when unusable. Feature codes (`*43`) only when allowed. */ export const normalizePhoneNumber = (raw: any, allowFeatureCodes = false): string => { let s = String(raw ?? '').trim() if (!s) return '' if (allowFeatureCodes && /^\*\d{2,3}$/.test(s)) return s s = s.replace(/\(0\)/g, '').replace(/^\+/, '00').replace(/[\s\-/().]/g, '') if (!/^\d{3,20}$/.test(s)) return '' return s } const mapDialStatus = (s: string): TargetStatus => { switch (String(s || '').toUpperCase()) { case 'ANSWER': return 'answered' case 'BUSY': return 'busy' case 'NOANSWER': return 'noanswer' case 'CANCEL': return 'cancel' case 'CONGESTION': return 'congestion' case 'CHANUNAVAIL': case 'INVALIDARGS': case 'DONTCALL': case 'TORTURE': return 'chanunavail' default: return 'unknown' } } const originateReason = (r: string): string => { switch (String(r || '')) { case '0': return 'Nebenstelle nicht erreichbar' case '1': return 'abgelehnt / aufgelegt' case '3': return 'keine Antwort' case '5': return 'besetzt' case '8': return 'Überlastung' default: return `Fehler (Reason ${r || '?'})` } } export interface StartCallInput { callerUserId: number callerName: string callerExtension: string orgId: number number: string targetUserId: number | null targetPartnerId: number | null targetLabel: string } export const startCall = async (input: StartCallInput): Promise<{ callId: string }> => { const config = useRuntimeConfig() const cfg = (config as any).asterisk || {} if (!cfg.enabled) throw createError({ statusCode: 503, statusMessage: 'Click-to-Dial ist deaktiviert' }) if (!cfg.secret) throw createError({ statusCode: 503, statusMessage: 'Telefonanlage nicht konfiguriert' }) if (active.size >= MAX_ACTIVE_CALLS) throw createError({ statusCode: 429, statusMessage: 'Zu viele aktive Anrufe' }) if (hasPendingAgentLeg(input.callerExtension)) throw createError({ statusCode: 409, statusMessage: 'Ihr Telefon klingelt bereits' }) const callId = randomUUID() const ctx: PhoneCallContext = { callId, startedAt: Date.now(), ...input } const agentRingTimeoutMs = Number(cfg.agentRingTimeoutMs || 30000) // Agent-side destinations: the extension's device(s) plus FreePBX Follow-Me // legs (Local/FMPR-@… = the extension itself, Local/FMGL-… = the // follow-me number list). Everything else dialed after the agent answered // is the target leg. const agentDevicePrefixes = [`PJSIP/${input.callerExtension}-`, `SIP/${input.callerExtension}-`, `Local/FMPR-${input.callerExtension}@`, 'Local/FMGL-'] const isAgentDevice = (ch: string) => agentDevicePrefixes.some((p) => ch.startsWith(p)) let ami: AmiClient try { ami = await connectAmi({ host: cfg.host, port: cfg.port, username: cfg.username, secret: cfg.secret }) } catch (err: any) { console.warn(`[dial ${callId}] AMI connect/login failed:`, err?.message || err) throw createError({ statusCode: 502, statusMessage: 'Telefonanlage nicht erreichbar' }) } const t: Tracker = { ctx, state: 'WAIT_AGENT', agentExt: input.callerExtension, agentAnsweredAt: null, targetUniqueid: null, targetStatus: 'unknown', targetAnsweredAt: null, targetHangupAt: null, lastHangupAt: null, hangupCause: '', live: new Set(), sawDial: false } let finalized = false let earlyFail: ((msg: string) => void) | null = null let graceTimer: NodeJS.Timeout | null = null let agentTimer: NodeJS.Timeout | null = null let hardCapTimer: NodeJS.Timeout | null = null let unsubscribe = () => {} const clearTimers = () => { if (graceTimer) clearTimeout(graceTimer) if (agentTimer) clearTimeout(agentTimer) if (hardCapTimer) clearTimeout(hardCapTimer) graceTimer = agentTimer = hardCapTimer = null } /** Tear down without writing anything (originate never got accepted). */ const abort = () => { finalized = true clearTimers() unsubscribe() active.delete(callId) try { ami.close() } catch { /* ignore */ } } const finalize = (note: string) => { if (finalized) return finalized = true clearTimers() unsubscribe() active.delete(callId) try { ami.close() } catch { /* ignore */ } const endedAt = t.targetHangupAt || t.lastHangupAt || Date.now() if (t.agentAnsweredAt && !t.sawDial && t.targetStatus === 'unknown') note = [note, 'kein Wählvorgang beobachtet'].filter(Boolean).join('; ') const outcome: PhoneCallOutcome = { agentAnswered: !!t.agentAnsweredAt, targetStatus: t.targetStatus, answeredAt: t.targetAnsweredAt, endedAt, hangupCause: t.hangupCause, note } t.state = 'FINALIZED' console.info(`[dial ${callId}] finalize: agent=${outcome.agentAnswered} target=${outcome.targetStatus} duration=${outcome.answeredAt ? Math.round((endedAt - outcome.answeredAt) / 1000) + 's' : '-'}${note ? ' (' + note + ')' : ''}`) createPhoneCallActivity(ctx, outcome).catch(() => {}) } const armGrace = () => { if (graceTimer) clearTimeout(graceTimer) graceTimer = setTimeout(() => { if (t.live.size === 0) finalize('') }, HANGUP_GRACE_MS) graceTimer.unref?.() } const isOurs = (evt: AmiPacket) => amiChanVariable(evt, 'LOGSHIP_CALL_ID') === callId || amiChanVariable(evt, 'LOGSHIP_CALL_ID', 'Dest') === callId const track = (uid: string) => { if (uid) t.live.add(uid) } const onEvent = (evt: AmiPacket) => { try { const name = amiValue(evt, 'Event') if (name === 'OriginateResponse') { if (amiValue(evt, 'ActionID') !== callId) return if (agentTimer) { clearTimeout(agentTimer); agentTimer = null } if (amiValue(evt, 'Response') === 'Success') { track(amiValue(evt, 'Uniqueid')) if (!t.agentAnsweredAt) t.agentAnsweredAt = Date.now() if (t.state === 'WAIT_AGENT') t.state = 'AGENT_ANSWERED' } else if (t.state === 'WAIT_AGENT') { t.state = 'AGENT_FAILED' const reason = originateReason(amiValue(evt, 'Reason')) t.hangupCause = `Anrufer-Leg: ${reason}` t.lastHangupAt = Date.now() if (earlyFail) { earlyFail(reason); earlyFail = null; abort(); return } finalize('') } return } if (!isOurs(evt)) return if (graceTimer) { clearTimeout(graceTimer); graceTimer = null } // a late channel showed up const uid = amiValue(evt, 'Uniqueid') const destUid = amiValue(evt, 'DestUniqueid') const destChan = amiValue(evt, 'DestChannel') if (name === 'Newchannel') { track(uid); return } if (name === 'DialBegin') { track(uid); track(destUid) if (isAgentDevice(destChan)) return // the agent leg ringing if (!t.agentAnsweredAt) return // follow-me / other legs before the agent picked up t.targetUniqueid = destUid || null t.targetStatus = 'unknown' t.sawDial = true t.state = 'TARGET_RINGING' return } if (name === 'DialEnd') { const status = mapDialStatus(amiValue(evt, 'DialStatus')) if (isAgentDevice(destChan)) { if (status === 'answered' && !t.agentAnsweredAt) { t.agentAnsweredAt = Date.now(); if (t.state === 'WAIT_AGENT') t.state = 'AGENT_ANSWERED' } return } if (t.targetUniqueid && destUid && destUid !== t.targetUniqueid) return if (t.state !== 'TARGET_RINGING' && t.state !== 'TARGET_FAILED') return t.targetStatus = status if (status === 'answered') { t.state = 'TALKING'; t.targetAnsweredAt = Date.now() } else t.state = 'TARGET_FAILED' return } if (name === 'Hangup') { t.live.delete(uid) t.lastHangupAt = Date.now() if (uid && uid === t.targetUniqueid) { t.targetHangupAt = Date.now() const txt = amiValue(evt, 'Cause-txt') if (txt) t.hangupCause = txt } else if (!t.hangupCause) { const txt = amiValue(evt, 'Cause-txt') if (txt) t.hangupCause = txt } if (t.live.size === 0) armGrace() return } } catch (err: any) { console.warn(`[dial ${callId}] event handling error:`, err?.message || err) } } // Subscribe BEFORE originating so no early event is missed. unsubscribe = ami.onEvent(onEvent) ami.onClosed((reason) => { if (!finalized) finalize(`AMI-Verbindung verloren: ${reason}`) }) hardCapTimer = setTimeout(() => finalize('Zeitlimit erreicht (3 h)'), HARD_CAP_MS) hardCapTimer.unref?.() agentTimer = setTimeout(() => { if (t.state === 'WAIT_AGENT') finalize('keine Rückmeldung der Telefonanlage zum Anrufer-Leg') }, agentRingTimeoutMs + 15000) agentTimer.unref?.() active.set(callId, t) // CallerID = the TARGET (name label + number): that is what the agent's phone // displays while it rings. Verified on the LogYou PBX: using the agent's own // extension as caller number makes the softphone reject the call (SIP 404 / // "Unallocated number") and Follow-Me is skipped — the target number works // and the outbound leg then uses the trunk's caller ID (FreePBX default for // click-to-dial). const cidName = `Anruf: ${(input.targetLabel || '').replace(/["<>\r\n]/g, '').substring(0, 40)}`.replace(/\s+/g, ' ').trim().substring(0, 60) // Channel: a device registered on THIS PBX is rung directly (PJSIP/); // anything else (e.g. an extension on the remote FreePBX reached through the // trunk route 2XX) must go through the dialplan → Local/@from-internal/n. let channelTemplate: string = cfg.channelTemplate || 'PJSIP/{ext}' if (!cfg.channelTemplate || cfg.channelTemplate === 'PJSIP/{ext}') { const devices = await listPbxExtensions().catch(() => []) if (devices.length > 0 && !devices.some((d) => d.ext === input.callerExtension)) channelTemplate = 'Local/{ext}@{context}/n' } const channel = channelTemplate.replace('{ext}', input.callerExtension).replace('{context}', cfg.context) try { const res = await ami.send('Originate', { Channel: channel, Exten: input.number, Context: cfg.context, Priority: 1, Timeout: agentRingTimeoutMs, CallerID: `"${cidName}" <${input.number}>`, Async: 'true', EarlyMedia: 'false', Variable: `__LOGSHIP_CALL_ID=${callId}` }, ORIGINATE_ACK_TIMEOUT_MS, callId) if (amiValue(res, 'Response') !== 'Success') { const msg = amiValue(res, 'Message') || 'Originate abgelehnt' abort() console.warn(`[dial ${callId}] originate rejected:`, msg) throw createError({ statusCode: 502, statusMessage: `Anruf abgelehnt: ${msg}` }) } } catch (err: any) { if (err?.statusCode) throw err abort() console.warn(`[dial ${callId}] originate failed:`, err?.message || err) throw createError({ statusCode: 502, statusMessage: 'Anruf konnte nicht gestartet werden' }) } console.info(`[dial ${callId}] originate accepted: ${channel} → ${input.number} (user ${input.callerUserId}, target user ${input.targetUserId ?? '-'} / partner ${input.targetPartnerId ?? '-'})`) // Fast-fail window: an unreachable / rejecting device fails within ~0.5 s. const early = await new Promise((resolve) => { const timer = setTimeout(() => { earlyFail = null; resolve(null) }, EARLY_FAILURE_WINDOW_MS) timer.unref?.() earlyFail = (msg) => { clearTimeout(timer); resolve(msg) } }) if (early) { console.warn(`[dial ${callId}] agent leg failed immediately: ${early}`) throw createError({ statusCode: 502, statusMessage: `Ihr Telefon (Nebenstelle ${input.callerExtension}) ist nicht erreichbar: ${early}` }) } return { callId } }