diff --git a/lib/session/call-session.js b/lib/session/call-session.js index 794486f2..d073d643 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -458,6 +458,18 @@ class CallSession extends Emitter { return this.accountInfo?.account?.service_provider_sid; } + /** + * true when we are no longer anchored in the media path (media released to the SBC). + * Media release is a feature of the dial verb; when the current task is a dial we consult its + * media path, otherwise media is anchored at the media server for the life of the app. + */ + get isMediaReleased() { + if (this.currentTask?.name === TaskName.Dial) { + return this.currentTask.mediaPath !== MediaPath.FullMedia; + } + return false; + } + /** * returns true if this session was transferred from another server */ @@ -1784,26 +1796,46 @@ class CallSession extends Emitter { * media stream (via send_dtmf), which the SBC/rtpengine simply forwards. This is reliable * regardless of whether rtpengine is transcoding the call. Sending SIP INFO to the SBC * instead relies on rtpengine 'play DTMF' injection, which silently no-ops on calls that - * are not being transcoded (rtpengine has no DSP to synthesize the tone into). We fall back - * to SIP INFO only when we have no endpoint in the media path for the targeted leg, e.g. when - * media has been released to the SBC. + * are not being transcoded (rtpengine has no DSP to synthesize the tone into). * this.ep is the endpoint facing the caller (A) leg; this.currentTask.ep is the endpoint * facing the dialed (B) leg. + * + * We choose the path based on the media routing (this.isMediaReleased), not merely on whether + * an endpoint object exists. While anchored in the media path we send via send_dtmf, and if + * that fails we deliberately do NOT fall back to SIP INFO: the media path is the reliable one, + * so a failure here is a real error (falling back risks a silently-dropped or duplicated + * digit). We log and raise an alert instead. SIP INFO is used only once media has been + * released to the SBC, when there is no endpoint in the media path at all. */ - const ep = callSid === this.callSid ? this.ep : this.currentTask?.ep; - if (ep?.connected) { + if (!this.isMediaReleased) { + const ep = callSid === this.callSid ? this.ep : this.currentTask?.ep; try { + if (!ep?.connected) throw new Error('no connected media endpoint for the targeted leg'); this.logger.debug(`CallSession:_lccDtmf - sending ${digit}@${duration} via send_dtmf on ${ep.uuid}`); await ep.execute('send_dtmf', `${digit}@${duration}`); return; } catch (err) { - this.logger.error({err}, - 'CallSession:_lccDtmf - error sending DTMF via send_dtmf, falling back to SIP INFO'); + this.logger.error({err}, 'CallSession:_lccDtmf - error sending DTMF via send_dtmf'); + const {writeAlerts} = this.srf.locals; + if (writeAlerts) { + try { + writeAlerts({ + alert_type: 'dtmf-send-failure', + account_sid: this.accountSid, + service_provider_sid: this.serviceProviderSid, + message: `failed to send DTMF digit ${digit} to ${callSid === this.callSid ? 'caller' : 'called party'}`, + target_sid: this.callSid + }); + } catch (alertErr) { + this.logger.error({err: alertErr}, 'CallSession:_lccDtmf - error writing dtmf-send-failure alert'); + } + } + return; } } - /* fallback: signal the SBC to inject DTMF via SIP INFO (used when media is released - * and we have no endpoint in the media path for the targeted leg) + /* no endpoint in the media path (e.g. media released to the SBC): signal the SBC to inject + * DTMF via SIP INFO */ try { const dlg = callSid === this.callSid ? this.dlg : this.currentTask.dlg; diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index e4ff994c..bc65b113 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -176,6 +176,13 @@ class TaskDial extends Task { return this.data.exitMediaPath; } + /* current media routing for this dial: MediaPath.FullMedia (anchored at the media server), + * PartialMedia or NoMedia (media released to the SBC) + */ + get mediaPath() { + return this._mediaPath; + } + get summary() { if (this.target.length === 1) { const target = this.target[0];