mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-10-04 02:04:12 +00:00
Applications currently have no way to observe a mid-call hold/un-hold.
The feature-server handles the in-dialog re-INVITE internally (re-modifies
the endpoint, returns 200) but never notifies the app, so a transcription
or agent-assist app can't pause/resume when a caller is held or retrieved.
This detects the hold state transition on a re-INVITE and delivers it over
the existing sipRequestWithinDialogHook channel as a verb:hook, plus emits
a local 'hold'/'unhold' event so in-process tasks (e.g. transcribe) can react.
- call-session.js: ordinary calls — reuse isOnhold() (a=sendonly/a=inactive).
- siprec-call-session.js: SIPREC overrides _onReinvite, so a separate handler
is required. SIPREC recording streams are sendonly by nature, so a stream
flipping to a=inactive is treated as the hold signal; the full multipart
body (SDP + rs-metadata) is always forwarded. Fires on transition only;
other re-INVITEs are delivered as event:"reinvite".
Delivery uses requestor.request('verb:hook', ...) — the same channel
_handleRefer uses — rather than a task-bound performHook, so events are not
dropped if the current task has ended. Enable per call with:
{"verb":"config","sipRequestWithinDialogHook":"/hold-events"}
Confirmed live against Cisco CUBE (Cisco-SIPGateway/IOS-17.12.7b) over SIPREC:
both recording streams flip sendonly<->inactive on hold/resume, each
transition emitted correctly.
The whole feature is opt-in behind the JAMBONES_HOLD_UNHOLD_EVENTS env var;
when it is unset, _notifyHoldState / _notifySipRecReinvite are no-ops (neither
the local event nor the hook fires).
Reinstates the feature previously reverted in #1563. The revert was due to the
unit test failing in the full suite: lib/config.js captures the env var once at
load time, and earlier suite files require config/session modules (flag unset)
before this test could set it, so the enabled-path assertions saw a disabled
feature. The test now force-reloads config + the session modules with the flag
set (and restores the module cache afterward), so it is robust to suite load order.
125 lines
5.3 KiB
JavaScript
125 lines
5.3 KiB
JavaScript
const InboundCallSession = require('./inbound-call-session');
|
|
const {createSipRecPayload} = require('../utils/siprec-utils');
|
|
const {CallStatus} = require('../utils/constants');
|
|
const {parseSiprecPayload} = require('../utils/siprec-utils');
|
|
const {JAMBONES_HOLD_UNHOLD_EVENTS} = require('../config');
|
|
/**
|
|
* @classdesc Subclass of InboundCallSession. This represents a CallSession that is
|
|
* established for an inbound SIPREC call.
|
|
* @extends InboundCallSession
|
|
*/
|
|
class SipRecCallSession extends InboundCallSession {
|
|
constructor(req, res) {
|
|
super(req, res);
|
|
|
|
const {sdp1, sdp2, metadata} = req.locals.siprec;
|
|
this.sdp1 = sdp1;
|
|
this.sdp2 = sdp2;
|
|
this.metadata = metadata;
|
|
}
|
|
|
|
async _onReinvite(req, res) {
|
|
try {
|
|
this.logger.info(req.payload, 'SipRec Re-INVITE payload');
|
|
const {sdp1: reSdp1, sdp2: reSdp2, metadata: reMetadata} = await parseSiprecPayload(req, this.logger);
|
|
this.sdp1 = reSdp1;
|
|
this.sdp2 = reSdp2;
|
|
this.metadata = reMetadata;
|
|
|
|
if (this.ep && this.ep2) {
|
|
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
|
const newSdp1 = await this.ep.modify(remoteSdp);
|
|
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
|
const newSdp2 = await this.ep2.modify(remoteSdp);
|
|
const combinedSdp = await createSipRecPayload(newSdp1, newSdp2, this.logger);
|
|
res.send(200, {body: combinedSdp});
|
|
this.logger.info({offer: req.body, answer: combinedSdp}, 'SipRec handling reINVITE');
|
|
this._notifySipRecReinvite(req, this.sdp1, this.sdp2);
|
|
}
|
|
else {
|
|
this.logger.info('got SipRec reINVITE but no endpoint and media has not been released');
|
|
res.send(488);
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(err, 'Error handling reinvite');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify the application of a SIPREC re-INVITE. SIPREC recording streams are
|
|
* sendonly by nature, so a stream flipping to a=inactive is treated as the
|
|
* (best-effort) hold signal; the full multipart body (SDP + rs-metadata) is
|
|
* always forwarded so the app can determine hold from the metadata itself.
|
|
* Delivered over the existing sipRequestWithinDialogHook channel; also emits
|
|
* a local 'hold'/'unhold' on transition (for optional transcribe auto-pause).
|
|
* Opt-in: only runs when JAMBONES_HOLD_UNHOLD_EVENTS is set.
|
|
*/
|
|
_notifySipRecReinvite(req, sdp1, sdp2) {
|
|
if (!JAMBONES_HOLD_UNHOLD_EVENTS) return;
|
|
const onHold = /a=inactive/.test(sdp1 || '') || /a=inactive/.test(sdp2 || '');
|
|
const changed = onHold !== !!this._onHold;
|
|
this._onHold = onHold;
|
|
if (changed) {
|
|
const event = onHold ? 'hold' : 'unhold';
|
|
this.logger.info({event, callSid: this.callSid}, 'SipRecCallSession: hold state changed');
|
|
this.emit(event);
|
|
}
|
|
// Delivered via requestor.request('verb:hook', ...) — task-independent, so
|
|
// the event survives even if the current task has ended (e.g. transcribe
|
|
// failed on a speech-credential error). callInfo.toJSON() merged in manually.
|
|
if (this.sipRequestWithinDialogHook && this.requestor) {
|
|
const params = {
|
|
...(this.callInfo.toJSON()),
|
|
sip_method: 'INVITE',
|
|
siprec: true,
|
|
event: changed ? (onHold ? 'hold' : 'unhold') : 'reinvite',
|
|
on_hold: onHold,
|
|
sip_body: req.body, // full multipart: SDP parts + rs-metadata XML
|
|
sip_headers: req.headers
|
|
};
|
|
this.requestor.request('verb:hook', this.sipRequestWithinDialogHook, params)
|
|
.catch((err) => this.logger.error({err}, 'SipRecCallSession:_notifySipRecReinvite - hook error'));
|
|
}
|
|
}
|
|
|
|
async answerSipRecCall() {
|
|
try {
|
|
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
|
this.ep = await this._createMediaEndpoint({remoteSdp});
|
|
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
|
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
|
this.ep2 = await this._createMediaEndpoint({remoteSdp});
|
|
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
|
await this.ep.bridge(this.ep2);
|
|
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
|
/*
|
|
this.logger.debug({
|
|
combinedSdp
|
|
}, 'SipRecCallSession:_answerSipRecCall - created SIPREC payload');
|
|
*/
|
|
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
|
headers: {
|
|
'Content-Type': 'application/sdp',
|
|
'X-Trace-ID': this.req.locals.traceId,
|
|
'X-Call-Sid': this.req.locals.callSid,
|
|
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
|
},
|
|
localSdp: combinedSdp
|
|
});
|
|
this.dlg.on('destroy', this._callerHungup.bind(this));
|
|
this.wrapDialog(this.dlg);
|
|
this.dlg.callSid = this.callSid;
|
|
this.emit('callStatusChange', {sipStatus: 200, sipReason: 'OK', callStatus: CallStatus.InProgress});
|
|
|
|
this.dlg.on('modify', this._onReinvite.bind(this));
|
|
this.dlg.on('refer', this._onRefer.bind(this));
|
|
} catch (err) {
|
|
this.logger.error({err}, 'SipRecCallSession:_answerSipRecCall error:');
|
|
if (this.res && !this.res.finalResponseSent) this.res.send(500);
|
|
this._callReleased();
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = SipRecCallSession;
|