UNPKG

20.5 kBJavaScriptView Raw
1const Emitter = require('events') ;
2const assert = require('assert') ;
3const only = require('only') ;
4const methods = require('sip-methods') ;
5const debug = require('debug')('drachtio:srf') ;
6const SipError = require('./sip_error');
7const { parseUri } = require('drachtio-sip').parser ;
8const sdpTransform = require('sdp-transform');
9
10
11/**
12 * Class representing a SIP Dialog.
13 *
14 * Note that instances of this class are not created directly by your code;
15 * rather they are returned from the {@link Srf#createUAC}, {@link Srf#createUAC}, and {@link Srf#createB2BUA}
16 * @class
17 * @extends EventEmitter
18 */
19class Dialog extends Emitter {
20
21 /**
22 * Constructor that is called internally by Srf when generating a Dialog instance.
23 * @param {Srf} srf - Srf instance that created this dialog
24 * @param {string} type - type of SIP dialog: 'uac', or 'uas'
25 * @param {Dialog~Options} opts
26 */
27 constructor(srf, type, opts) {
28 super() ;
29
30 const types = ['uas', 'uac'] ;
31 assert.ok(-1 !== types.indexOf(type), 'argument \'type\' must be one of ' + types.join(',')) ;
32
33 this.srf = srf ;
34 this.type = type ;
35 this.req = opts.req ;
36 this.res = opts.res ;
37 this.agent = this.res.agent ;
38 this.onHold = false ;
39 this.connected = true ;
40 this.queuedRequests = [];
41 this._queueRequests = false;
42
43 /**
44 * sip properties that uniquely identify this Dialog
45 * @type {Object}
46 * @property {String} callId - SIP Call-ID
47 * @property {String} localTag - tag generated by local side of the Dialog
48 * @property {String} remoteTag - tag generated by the remote side of the Dialog
49 */
50 this.sip = {
51 callId: this.res.get('Call-ID'),
52 remoteTag: 'uas' === type ?
53 this.req.getParsedHeader('from').params.tag : this.res.getParsedHeader('to').params.tag,
54 localTag: 'uas' === type ?
55 opts.sent.getParsedHeader('to').params.tag : this.req.getParsedHeader('from').params.tag
56 } ;
57
58 /**
59 * local side of the Dialog
60 * @type {Object}
61 * @property {String} uri - sip
62 * @property {String} sdp - session description protocol
63 */
64 this.local = {
65 uri: 'uas' === type ? opts.sent.getParsedHeader('Contact')[0].uri : this.req.uri,
66 sdp: 'uas' === type ? opts.sent.body : this.req.body,
67 contact: 'uas' === type ? opts.sent.get('Contact') : this.req.get('Contact')
68 } ;
69
70 /**
71 * remote side of the Dialog
72 * @type {Object}
73 * @property {String} uri - sip
74 * @property {String} sdp - session description protocol
75 */
76 this.remote = {
77 uri: 'uas' === type ? this.req.getParsedHeader('Contact')[0].uri : this.res.getParsedHeader('Contact')[0].uri,
78 sdp: 'uas' === type ? this.req.body : this.res.body
79 } ;
80
81 /*
82 * subscriptions created by this dialog
83 */
84 this.subscriptions = [];
85
86 // if this is a SUBSCRIBE, then we have our first subscription
87 if (this.req.method === 'SUBSCRIBE') {
88 this.addSubscription(this.req);
89 }
90 }
91
92 get id() {
93 return this.res.stackDialogId ;
94 }
95
96 get dialogType() {
97 return this.req.method ;
98 }
99
100 get subscribeEvent() {
101 return this.dialogType === 'SUBSCRIBE' ? this.req.get('Event') : null ;
102 }
103
104 get socket() {
105 return this.req.socket;
106 }
107
108 set queueRequests(enqueue) {
109 debug(`dialog ${this.id}: queueing requests: ${enqueue ? 'ON' : 'OFF'}`);
110 this._queueRequests = enqueue;
111 if (!enqueue) {
112 // process any queued messages
113 if (this.queuedRequests.length > 0) {
114 setImmediate(() => {
115 debug(`dialog ${this.id}: processing ${this.queuedRequests.length} queued requests`);
116 this.queuedRequests.forEach(({req, res}) => this.handle(req, res));
117 this.queueRequests = [];
118 });
119 }
120 }
121 }
122
123 toJSON() {
124 return only(this, 'id type sip local remote onHold') ;
125 }
126
127 toString() {
128 return this.toJSON().toString() ;
129 }
130
131 getCountOfSubscriptions() {
132 return this.subscriptions.length;
133 }
134
135 addSubscription(req) {
136 const to = req.getParsedHeader('To');
137 const u = parseUri(to.uri);
138 debug(`Dialog#addSubscription: to header: ${JSON.stringify(to)}, uri ${JSON.stringify(u)}`);
139 const entity = `${u.user}@${u.host}:${req.get('Event')}`;
140 this.subscriptions.push(entity);
141 debug(`Dialog#addSubscription: adding subscription ${entity}; current count ${this.subscriptions.length}`);
142 return this.subscriptions.length;
143 }
144
145 removeSubscription(uri, event) {
146 const u = parseUri(uri);
147 const entity = `${u.user}@${u.host}:${event}`;
148 const idx = this.subscriptions.indexOf(entity);
149 if (-1 === idx) {
150 console.error(`Dialog#removeSubscription: no subscription found for ${entity}: subs: ${this.subscriptions}`);
151 }
152 else {
153 this.subscriptions.splice(idx, 1);
154 }
155 return this.subscriptions.length;
156 }
157
158 /**
159 * destroy the sip dialog by generating a BYE request (in the case of INVITE dialog),
160 * or NOTIFY (in the case of SUBSCRIBE)
161 * @param {Object} [opts] configuration options
162 * @param {Object} [opts.headers] SIP headers to add to the outgoing BYE or NOTIFY
163 * @param {function} [callback] if provided, callback with signature <code>(err, msg)</code>
164 * that provides the BYE or NOTIFY message that was sent to terminate the dialog
165 * @return {Promise|Dialog} if no callback is supplied, otherwise a reference to the Dialog
166 */
167 destroy(opts, callback) {
168 opts = opts || {} ;
169 if (typeof opts === 'function') {
170 callback = opts ;
171 opts = {} ;
172 }
173 this.queuedRequests = [];
174
175 const __x = (callback) => {
176 if (this.dialogType === 'INVITE') {
177 this.agent.request({
178 method: 'BYE',
179 headers: opts.headers || {},
180 stackDialogId: this.id,
181 _socket: this.socket
182 }, (err, bye) => {
183 this.connected = false ;
184 this.srf.removeDialog(this) ;
185 callback(err, bye) ;
186 this.removeAllListeners();
187 }) ;
188 }
189 else if (this.dialogType === 'SUBSCRIBE') {
190 opts.headers = opts.headers || {} ;
191 opts.headers['subscription-state'] = 'terminated';
192 opts.headers['event'] = this.subscribeEvent ;
193 this.agent.request({
194 method: 'NOTIFY',
195 headers: opts.headers || {},
196 stackDialogId: this.id,
197 _socket: this.socket
198 }, (err, notify) => {
199 this.connected = false ;
200 this.srf.removeDialog(this) ;
201 callback(err, notify) ;
202 this.removeAllListeners();
203 }) ;
204 }
205 };
206
207 if (callback) {
208 __x(callback) ;
209 return this ;
210 }
211
212 return new Promise((resolve, reject) => {
213 __x((err, msg) => {
214 if (err) return reject(err);
215 resolve(msg);
216 });
217 });
218 }
219
220 /**
221 * modify the dialog session by changing attributes of the media connection
222 * @param {string} sdp - 'hold', 'unhold', or a session description protocol
223 * @param {function} [callback] - callback invoked with signature <code>(err)</code> when operation has completed
224 * @return {Promise|Dialog} if no callback is supplied, otherwise the function returns a reference to the Dialog
225 */
226 modify(sdp, callback) {
227
228 const __x = (callback) => {
229 switch (sdp) {
230 case 'hold':
231 this.local.sdp = this.local.sdp.replace(/a=sendrecv/, 'a=inactive') ;
232 this.onHold = true ;
233 break ;
234 case 'unhold':
235 if (this.onHold) {
236 this.local.sdp = this.local.sdp.replace(/a=inactive/, 'a=sendrecv') ;
237 }
238 else {
239 console.error('Dialog#modify: attempt to \'unhold\' session which is not on hold');
240 return process.nextTick(() => {
241 callback(new Error('attempt to unhold session that is not on hold'));
242 }) ;
243 }
244 break ;
245 default:
246 this.local.sdp = sdp ;
247 break ;
248 }
249
250 debug(`Dialog#modify: sending reINVITE for dialog id: ${this.id}, sdp: ${this.local.sdp}`) ;
251 this.agent.request({
252 method: 'INVITE',
253 stackDialogId: this.id,
254 body: this.local.sdp,
255 _socket: this.socket,
256 headers: {
257 'Contact': this.local.contact
258 }
259 }, (err, req) => {
260 if (err) return callback(err);
261 req.on('response', (res, ack) => {
262 debug(`Dialog#modifySession: received response to reINVITE with status ${res.status}`) ;
263 if (res.status >= 200) {
264 ack() ;
265 if (200 === res.status) {
266 this.remote.sdp = res.body ;
267 return callback(null, res.body);
268 }
269 callback(new SipError(res.status, res.reason)) ;
270 }
271 }) ;
272 }) ;
273 };
274
275 if (callback) {
276 __x(callback) ;
277 return this ;
278 }
279
280 return new Promise((resolve, reject) => {
281 __x((err, sdp) => {
282 if (err) return reject(err);
283 resolve(sdp);
284 });
285 });
286 }
287
288 /**
289 * send a request within a dialog.
290 * Note that you may also call <code>request.info(..)</code> as a shortcut
291 * to send an INFO message, <code>request.notify(..)</code>
292 * to send a NOTIFY, etc..
293 * @param {Object} [opts]
294 * @param {string} opts.method - SIP method to use for the request
295 * @param {Object} [opts.headers] - SIP headers to apply to the request
296 * @param {string} [opts.body] - body of the SIP request
297 * @param {function} [callback] - callback invoked with signature <code>(err, req)</code>
298 * when operation has completed
299 * @return {Promise|Dialog} if no callback is supplied a Promise that resolves to the response received,
300 * otherwise the function returns a reference to the Dialog
301 */
302 request(opts, callback) {
303 assert.ok(typeof opts.method === 'string' &&
304 -1 !== methods.indexOf(opts.method), '\'opts.method\' is required and must be a SIP method') ;
305
306 const __x = (callback) => {
307 const method = opts.method.toUpperCase() ;
308
309 this.agent.request({
310 method: method,
311 stackDialogId: this.id,
312 headers: opts.headers || {},
313 auth: opts.auth,
314 _socket: this.socket,
315 body: opts.body
316 }, (err, req) => {
317 if (err) {
318 return callback(err) ;
319 }
320
321 req.on('response', (res, ack) => {
322 if ('BYE' === method) {
323 this.srf.removeDialog(this) ;
324 }
325 if (res.status >= 200) {
326 if ('INVITE' === method) ack() ;
327
328 if (this.dialogType === 'SUBSCRIBE' && 'NOTIFY' === method &&
329 /terminated/.test(req.get('Subscription-State'))) {
330 debug('received response to a NOTIFY we sent terminating final subscription; dialog is ended') ;
331
332 const from = req.getParsedHeader('From');
333 if (this.removeSubscription(from.uri, req.get('Event')) === 0) {
334 this.connected = false ;
335 this.srf.removeDialog(this) ;
336 this.emit('destroy', req) ;
337 this.removeAllListeners();
338 }
339 }
340 callback(null, res) ;
341 }
342 }) ;
343 }) ;
344 } ;
345
346 if (callback) {
347 __x(callback) ;
348 return this ;
349 }
350
351 return new Promise((resolve, reject) => {
352 __x((err, res) => {
353 if (err) return reject(err);
354 resolve(res);
355 });
356 });
357 }
358
359 handle(req, res) {
360 debug(`dialog ${this.id}: handle: ${req.method}`) ;
361 if (this._queueRequests === true) {
362 debug(`dialog ${this.id}: queueing incoming request: ${req.method}`) ;
363 this.queuedRequests.push({req, res});
364 return;
365 }
366 const eventName = req.method.toLowerCase() ;
367 switch (req.method) {
368 case 'BYE':
369 let reason = 'normal release';
370 if (req.meta.source === 'application') {
371 if (req.has('Reason')) {
372 reason = req.get('Reason');
373 const arr = /text=\"(.*)\"/.exec(reason);
374 if (arr) reason = arr[1];
375 }
376 }
377 this.connected = false ;
378 this.srf.removeDialog(this) ;
379 res.send(200) ;
380 this.emit('destroy', req, reason) ;
381 this.removeAllListeners();
382 break ;
383
384 case 'INVITE':
385 const origRedacted = this.remote.sdp.replace(/^o=.*$/m, 'o=REDACTED') ;
386 const newRedacted = req.body.replace(/^o=.*$/m, 'o=REDACTED') ;
387 let refresh = false;
388 try {
389 if (this.listeners('refresh').length > 0) {
390 const sdp1 = sdpTransform.parse(this.remote.sdp);
391 const sdp2 = sdpTransform.parse(req.body);
392 refresh = sdp1.origin.sessionId === sdp2.origin.sessionId &&
393 sdp1.origin.sessionVersion === sdp2.origin.sessionVersion;
394 }
395 } catch (err) {
396 }
397 const hold = origRedacted.replace(/a=sendrecv\r\n/g, 'a=sendonly\r\n') === newRedacted &&
398 this.listeners('hold').length > 0;
399 const unhold = this.onHold === true &&
400 origRedacted.replace(/a=sendonly\r\n/g, 'a=sendrecv\r\n') === newRedacted &&
401 this.listeners('unhold').length > 0;
402 const modify = !hold && !unhold && !refresh ;
403 this.remote.sdp = req.body ;
404
405 if (refresh) {
406 this.emit('refresh', req);
407 }
408 else if (hold) {
409 this.local.sdp = this.local.sdp.replace(/a=sendrecv\r\n/g, 'a=recvonly\r\n') ;
410 this.onHold = true;
411 this.emit('hold', req) ;
412 }
413 else if (unhold) {
414 this.onHold = false;
415 this.local.sdp = this.local.sdp.replace(/a=recvonly\r\n/g, 'a=sendrecv\r\n') ;
416 this.emit('unhold', req) ;
417 }
418 if ((refresh || hold || unhold) || (modify && 0 === this.listeners('modify').length)) {
419 debug('responding with 200 OK to reINVITE') ;
420 res.send(200, {
421 body: this.local.sdp,
422 headers: {
423 'Contact': this.local.contact,
424 'Content-Type': 'application/sdp'
425 }
426 }) ;
427 }
428 else if (modify) {
429 this.emit('modify', req, res) ;
430 }
431 break ;
432
433 case 'NOTIFY':
434 // if this is a subscribe dialog and subscription-state: terminated, then remove the subscription
435 // and if there are no more subscriptions then remove the dialog
436 if (this.dialogType === 'SUBSCRIBE' &&
437 req.has('subscription-state') &&
438 /terminated/.test(req.get('subscription-state'))) {
439
440 setImmediate(() => {
441 const to = req.getParsedHeader('to');
442 if (this.removeSubscription(to.uri, req.get('Event')) === 0) {
443 debug('received a NOTIFY with Subscription-State terminated for final subscription; dialog is ended') ;
444 this.connected = false ;
445 this.srf.removeDialog(this) ;
446 this.emit('destroy', req) ;
447 }
448 }) ;
449 }
450 if (0 === this.listeners(eventName).length) {
451 res.send(200) ;
452 }
453 else {
454 this.emit(eventName, req, res) ;
455 }
456 break ;
457
458 case 'INFO':
459 case 'REFER':
460 case 'OPTIONS':
461 case 'MESSAGE':
462 case 'PUBLISH':
463 case 'UPDATE':
464
465 // N.B.: this is because an app may be using the Promises version
466 // of Srf#createUAS or Srf#createB2B and if so the 'then()' code
467 // may be enqueued at the back of the job queue right now if a very
468 // quick INFO or other request within the dialog just arrived.
469 // We need the dialog to be resolved in the calling app first so it
470 // has time to attach event handlers.
471 setImmediate(() => {
472 if (0 === this.listeners(eventName).length) res.send(200) ;
473 else this.emit(eventName, req, res);
474 });
475 break ;
476
477 case 'SUBSCRIBE':
478 if (req.has('Expires') && 0 === parseInt(req.get('Expires'))) {
479 res.send(202) ;
480 this.emit('unsubscribe', req, 'unsubscribe') ;
481 }
482 else {
483 if (0 === this.listeners('subscribe').length) {
484 res.send(489, 'Bad Event - no dialog handler');
485 }
486 else this.emit('subscribe', req, res);
487 }
488 break;
489
490 case 'ACK':
491 setImmediate(() => this.emit('ack', req));
492 break ;
493
494 default:
495 console.error(`Dialog#handle received invalid method within an INVITE dialog: ${req.method}`) ;
496 res.send(501) ;
497 break ;
498 }
499
500 }
501}
502
503module.exports = exports = Dialog ;
504
505methods.forEach((method) => {
506 Dialog.prototype[method.toLowerCase()] = (opts, cb) => {
507 opts = opts || {} ;
508 opts.method = method ;
509 return this.request(opts, cb) ;
510 };
511}) ;
512
513/**
514 * a <code>destroy</code> event is triggered when the Dialog is torn down from the far end
515 * @event Dialog#destroy
516 * @param {Object} msg - incoming BYE request message
517 */
518/**
519 * a <code>modify</code> event is triggered when the far end modifies the session by sending a re-INVITE.
520 * When an application adds a handler for this event it must generate
521 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
522 * When no handler is found for this event a 200 OK with the current local SDP
523 * will be automatically generated.
524 *
525 * @event Dialog#modify
526 * @param {Object} req - drachtio request object
527 * @param {Object} res - drachtio response object
528 * @memberOf Dialog
529 */
530/**
531 * a <code>refresh</code> event is triggered when the far end sends a session refresh.
532 * There is no need for the application to respond to this event; this is purely
533 * a notification.
534 * @event Dialog#refresh
535 * @param {Object} msg - incoming re-INVITE request message
536 */
537/**
538 * an <code>info</code> event is triggered when the far end sends an INFO message.
539 * When an application adds a handler for this event it must generate
540 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
541 * When no handler is found for this event a 200 OK will be automatically generated.
542 * @event Dialog#info
543 * @param {Object} req - drachtio request object
544 * @param {Object} res - drachtio response object
545 */
546/**
547 * a <code>notify</code> event is triggered when the far end sends a NOTIFY message.
548 * When an application adds a handler for this event it must generate
549 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
550 * When no handler is found for this event a 200 OK will be automatically generated.
551 * @event Dialog#notify
552 * @param {Object} req - drachtio request object
553 * @param {Object} res - drachtio response object
554 */
555/**
556 * an <code>options</code> event is triggered when the far end sends an OPTIONS message.
557 * When an application adds a handler for this event it must generate
558 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
559 * When no handler is found for this event a 200 OK will be automatically generated.
560 * @event Dialog#options
561 * @param {Object} req - drachtio request object
562 * @param {Object} res - drachtio response object
563 */
564/**
565 * an <code>update</code> event is triggered when the far end sends an UPDATE message.
566 * When an application adds a handler for this event it must generate
567 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
568 * When no handler is found for this event a 200 OK will be automatically generated.
569 * @event Dialog#update
570 * @param {Object} req - drachtio request object
571 * @param {Object} res - drachtio response object
572 */
573/**
574 * a <code>refer</code> event is triggered when the far end sends a REFER message.
575 * When an application adds a handler for this event it must generate
576 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
577 * When no handler is found for this event a 200 OK will be automatically generated.
578 * @event Dialog#refer
579 * @param {Object} req - drachtio request object
580 * @param {Object} res - drachtio response object
581 */
582/**
583 * a <code>message</code> event is triggered when the far end sends a MESSAGE message.
584 * When an application adds a handler for this event it must generate
585 * the SIP response by calling <code>res.send</code> on the provided drachtio response object.
586 * When no handler is found for this event a 200 OK will be automatically generated.
587 * @event Dialog#message
588 * @param {Object} req - drachtio request object
589 * @param {Object} res - drachtio response object
590 */
591