mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-10-31 15:54:27 +00:00
feat: surface in-dialog hold/un-hold to the application (#1560)
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).
This commit is contained in:
1 parent
01827ff955
commit
edc6d6b208
6 files changed
+254
No files matched your search
@@ -0,0 +1,171 @@
|
||||
const test = require('tape');
|
||||
const clearModule = require('clear-module');
|
||||
|
||||
// ENCRYPTION_SECRET is required when loading the session modules (encrypt-decrypt.js
|
||||
// hashes it at require time). The npm `test` script sets it; guard here so the file
|
||||
// can also be run standalone.
|
||||
process.env.ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'foobar';
|
||||
|
||||
// The feature is opt-in behind JAMBONES_HOLD_UNHOLD_EVENTS, which lib/config.js
|
||||
// captures once at load time. Enable it before requiring the session modules so
|
||||
// the behavioural tests below exercise the notification path. The final test
|
||||
// reloads the modules with it unset to verify the guard.
|
||||
process.env.JAMBONES_HOLD_UNHOLD_EVENTS = '1';
|
||||
|
||||
const CallSession = require('../lib/session/call-session');
|
||||
const SipRecCallSession = require('../lib/session/siprec-call-session');
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// A minimal stand-in for a CallSession/SipRecCallSession instance, exposing only
|
||||
// what _notifyHoldState / _notifySipRecReinvite touch. We invoke the real prototype
|
||||
// methods against it so the hold-transition logic is exercised without spinning up
|
||||
// drachtio/freeswitch.
|
||||
function makeSession(overrides = {}) {
|
||||
const emitted = [];
|
||||
const hookCalls = [];
|
||||
return {
|
||||
_onHold: undefined,
|
||||
callSid: 'call-sid-abc',
|
||||
logger: {info: noop, error: noop, debug: noop},
|
||||
sipRequestWithinDialogHook: '/hold-events',
|
||||
callInfo: {toJSON: () => ({call_sid: 'call-sid-abc', account_sid: 'acct-sid-xyz'})},
|
||||
requestor: {
|
||||
request: (type, hook, params) => {
|
||||
hookCalls.push({type, hook, params});
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
emit: (event) => emitted.push(event),
|
||||
emitted,
|
||||
hookCalls,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const HOLD_SDP = 'v=0\r\no=- 1 1 IN IP4 1.1.1.1\r\ns=-\r\nc=IN IP4 1.1.1.1\r\nt=0 0\r\n' +
|
||||
'm=audio 4000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\na=sendonly\r\n';
|
||||
const INACTIVE_SDP = HOLD_SDP.replace('a=sendonly', 'a=inactive');
|
||||
const ACTIVE_SDP = HOLD_SDP.replace('a=sendonly', 'a=sendrecv');
|
||||
|
||||
const notifyHoldState = CallSession.prototype._notifyHoldState;
|
||||
const notifySipRecReinvite = SipRecCallSession.prototype._notifySipRecReinvite;
|
||||
|
||||
test('CallSession._notifyHoldState - fires on hold transition', (t) => {
|
||||
const s = makeSession();
|
||||
notifyHoldState.call(s, {body: HOLD_SDP, headers: {'call-id': 'x'}});
|
||||
|
||||
t.equal(s._onHold, true, 'tracks on-hold state');
|
||||
t.deepEqual(s.emitted, ['hold'], 'emits a single "hold" event');
|
||||
t.equal(s.hookCalls.length, 1, 'invokes the in-dialog hook once');
|
||||
|
||||
const {type, hook, params} = s.hookCalls[0];
|
||||
t.equal(type, 'verb:hook', 'delivered over the task-independent verb:hook channel');
|
||||
t.equal(hook, '/hold-events', 'uses the configured sipRequestWithinDialogHook');
|
||||
t.equal(params.event, 'hold', 'event = hold');
|
||||
t.equal(params.on_hold, true, 'on_hold = true');
|
||||
t.equal(params.sip_method, 'INVITE', 'sip_method = INVITE');
|
||||
t.equal(params.sip_body, HOLD_SDP, 'forwards the offered SDP');
|
||||
t.equal(params.call_sid, 'call-sid-abc', 'callInfo is merged in (call_sid present)');
|
||||
t.equal(params.account_sid, 'acct-sid-xyz', 'callInfo is merged in (account_sid present)');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('CallSession._notifyHoldState - a=inactive also counts as hold', (t) => {
|
||||
const s = makeSession();
|
||||
notifyHoldState.call(s, {body: INACTIVE_SDP, headers: {}});
|
||||
t.equal(s._onHold, true, 'a=inactive treated as hold');
|
||||
t.deepEqual(s.emitted, ['hold'], 'emits "hold"');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('CallSession._notifyHoldState - no-op when hold state is unchanged', (t) => {
|
||||
const s = makeSession({_onHold: true});
|
||||
notifyHoldState.call(s, {body: HOLD_SDP, headers: {}});
|
||||
t.equal(s.emitted.length, 0, 'does not re-emit when already on hold');
|
||||
t.equal(s.hookCalls.length, 0, 'does not re-invoke the hook when already on hold');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('CallSession._notifyHoldState - fires "unhold" on retrieve', (t) => {
|
||||
const s = makeSession({_onHold: true});
|
||||
notifyHoldState.call(s, {body: ACTIVE_SDP, headers: {}});
|
||||
t.equal(s._onHold, false, 'clears on-hold state');
|
||||
t.deepEqual(s.emitted, ['unhold'], 'emits "unhold"');
|
||||
t.equal(s.hookCalls[0].params.event, 'unhold', 'hook event = unhold');
|
||||
t.equal(s.hookCalls[0].params.on_hold, false, 'on_hold = false');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('CallSession._notifyHoldState - still emits locally when no hook configured', (t) => {
|
||||
const s = makeSession({sipRequestWithinDialogHook: undefined});
|
||||
notifyHoldState.call(s, {body: HOLD_SDP, headers: {}});
|
||||
t.deepEqual(s.emitted, ['hold'], 'local event still emitted');
|
||||
t.equal(s.hookCalls.length, 0, 'no hook invoked when unconfigured');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('SipRecCallSession._notifySipRecReinvite - detects hold via a=inactive on either stream', (t) => {
|
||||
const s = makeSession();
|
||||
notifySipRecReinvite.call(s, {body: 'multipart-body', headers: {}}, ACTIVE_SDP, INACTIVE_SDP);
|
||||
|
||||
t.equal(s._onHold, true, 'a=inactive on stream 2 => hold');
|
||||
t.deepEqual(s.emitted, ['hold'], 'emits "hold" on transition');
|
||||
t.equal(s.hookCalls.length, 1, 'invokes the hook');
|
||||
|
||||
const {params} = s.hookCalls[0];
|
||||
t.equal(params.siprec, true, 'marks payload as siprec');
|
||||
t.equal(params.event, 'hold', 'event = hold on transition');
|
||||
t.equal(params.on_hold, true, 'on_hold = true');
|
||||
t.equal(params.sip_body, 'multipart-body', 'forwards the full multipart body');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('SipRecCallSession._notifySipRecReinvite - non-transition reinvite still forwarded as event:reinvite', (t) => {
|
||||
const s = makeSession({_onHold: false});
|
||||
notifySipRecReinvite.call(s, {body: 'multipart-body', headers: {}}, ACTIVE_SDP, ACTIVE_SDP);
|
||||
|
||||
t.equal(s._onHold, false, 'stays off hold');
|
||||
t.equal(s.emitted.length, 0, 'no local event when state is unchanged');
|
||||
t.equal(s.hookCalls.length, 1, 'hook still invoked (SIPREC forwards every reinvite)');
|
||||
t.equal(s.hookCalls[0].params.event, 'reinvite', 'event = reinvite when there is no transition');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('SipRecCallSession._notifySipRecReinvite - emits "unhold" on retrieve transition', (t) => {
|
||||
const s = makeSession({_onHold: true});
|
||||
notifySipRecReinvite.call(s, {body: 'multipart-body', headers: {}}, ACTIVE_SDP, ACTIVE_SDP);
|
||||
|
||||
t.equal(s._onHold, false, 'clears on-hold state');
|
||||
t.deepEqual(s.emitted, ['unhold'], 'emits "unhold" on transition');
|
||||
t.equal(s.hookCalls[0].params.event, 'unhold', 'hook event = unhold');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('feature is inert unless JAMBONES_HOLD_UNHOLD_EVENTS is set', (t) => {
|
||||
// lib/config.js reads the env var at load time, so re-require the modules with
|
||||
// it unset to exercise the disabled path.
|
||||
const saved = process.env.JAMBONES_HOLD_UNHOLD_EVENTS;
|
||||
delete process.env.JAMBONES_HOLD_UNHOLD_EVENTS;
|
||||
clearModule('../lib/config');
|
||||
clearModule('../lib/session/call-session');
|
||||
clearModule('../lib/session/siprec-call-session');
|
||||
try {
|
||||
const CS = require('../lib/session/call-session');
|
||||
const SR = require('../lib/session/siprec-call-session');
|
||||
|
||||
const s1 = makeSession();
|
||||
CS.prototype._notifyHoldState.call(s1, {body: HOLD_SDP, headers: {}});
|
||||
t.equal(s1.emitted.length, 0, 'CallSession: no event emitted when disabled');
|
||||
t.equal(s1.hookCalls.length, 0, 'CallSession: no hook invoked when disabled');
|
||||
|
||||
const s2 = makeSession();
|
||||
SR.prototype._notifySipRecReinvite.call(s2, {body: 'multipart-body', headers: {}}, ACTIVE_SDP, INACTIVE_SDP);
|
||||
t.equal(s2.emitted.length, 0, 'SipRecCallSession: no event emitted when disabled');
|
||||
t.equal(s2.hookCalls.length, 0, 'SipRecCallSession: no hook invoked when disabled');
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.JAMBONES_HOLD_UNHOLD_EVENTS;
|
||||
else process.env.JAMBONES_HOLD_UNHOLD_EVENTS = saved;
|
||||
}
|
||||
t.end();
|
||||
});
|
||||
@@ -4,6 +4,7 @@ require('./ws-requestor-unit-test');
|
||||
require('./http-requestor-retry-test');
|
||||
require('./http-requestor-unit-test');
|
||||
require('./unit-tests');
|
||||
require('./hold-unhold-test');
|
||||
require('./docker_start');
|
||||
require('./create-test-db');
|
||||
require('./account-validation-tests');
|
||||
|
||||
Reference in new issue
Block a user