@babblevoice/babble-drachtio-presence
Version:
A presence agent which will work with other modules in this family.
687 lines (576 loc) • 17.8 kB
JavaScript
/*
uassubscription.
A UAC (phone/watcher) subscribes to us.
*/
const { v4: uuidv4 } = require( "uuid" )
const sipauth = require( "@babblevoice/babble-drachtio-auth" )
const uasstore = require( "./uasstore.js" )
const doc = require( "./presencedocument.js" )
// @ts-ignore
const parseuri = require( "drachtio-srf" ).parseUri
const authtimeout = 5 * 1000
/**
*
* @param { object } res
* @returns { boolean }
*/
function isgoodresponce( res ) {
if( !res ) return false
if( !res.msg ) return false
if( 200 === res.msg.status ) return true
if( 202 === res.msg.status ) return true
if( 204 === res.msg.status ) return true
return false
}
class subscription {
#destroyed = false
/**
* @param { object } req - the initial request
* @param { object } res - srf response
* @param { object } options - our options object
* @param { object } options.srf - our srf object
* @param { object } options.em - event emitter
* @param { function } options.userlookup - async user lookup function - required
* @param { boolean } [ options.proxy = true ] - true = respond with 407 otherwise 401
* @param { number } [ options.authtimeout = 100000 ] - timeout in mS
* @param { object } [ options.registrar ]
*/
constructor( req, res, options ) {
/**
Our uuid for this registration - guaranteed to be unique.
*/
this.uuid = uuidv4()
/**
@private
*/
this._options = options
if( !this._options.authtimeout ) this._options.authtimeout = authtimeout
/**
This must be set for the store
*/
const suburi = parseuri( req.uri )
if( !suburi ) {
this.destroy()
res.send( 400, "Bad URI" )
return
}
this.uri = {
"user": suburi.user,
"host": suburi.host
}
this.expires = subscription.getexpires( req )
if( isNaN( this.expires ) ) {
this.destroy()
res.send( 400, "No valid expires" )
return
}
if( !req.has( "call-id" ) ) {
this.destroy()
res.send( 400, "No valid call-id" )
return
}
/**
* Have we at authed at any point.
* @private
*/
this._authed = false
/**
* Store any timers we need
* @private
*/
this._timers = {}
/**
* network details - source.
* @type { object }
*/
this.network = {}
this.network.source_address = req.source_address
this.network.source_port = req.source_port
this.network.protocol = req.protocol
this.callid = req.get( "call-id" )
/**
* fq = fully qualified call id (private to this module)
* @private
*/
this._fqcallid = subscription.getfqcallid( req )
if( !this._fqcallid ) {
this.destroy()
res.send( 400, "No valid call-id" )
return
}
uasstore.set( this )
/* All subscriptions are authed */
this._auth = sipauth.create( options.proxy )
if( this._auth.has( req ) ) {
if( options.registrar ) {
const auth = options.registrar.getauth( req )
if( auth ) {
this._auth = auth
}
}
this.#updateauth( req, res )
return
}
this._auth.requestauth( req, res )
/* a little time to auth */
this._timers.subauth = setTimeout( this._ontimeout.bind( this ), this._options.authtimeout )
}
/**
* In case a client creates a clash - make it per client.
* @param { object } req object from drachtio
* @returns { string | undefined }
*/
static getfqcallid( req ) {
if( !req.has( "call-id" ) ) return
return req.get( "call-id" ) + "@" + req.source_address + ":" + req.source_port
}
/**
* Check the relevent headers for the expires value
* @param { object } req
* @returns { number | NaN }
*/
static getexpires( req ) {
if ( !req.has( "Contact" ) ) return NaN
const contact = req.get( "Contact" )
if( Array.isArray( contact ) && contact[ 0 ].params && contact[ 0 ].params.expires ) {
const expires = parseInt( contact[ 0 ].params.expires )
if( !Number.isNaN( expires ) ) return expires
}
if( !req.has( "Expires" ) ) return NaN
const expiresheader = req.get( "Expires" )
return parseInt( expiresheader )
}
/**
*
* @param { object } req
* @param { object } res
* @param { object } options
* @returns
*/
static create( req, res, options ) {
return new subscription( req, res, options )
}
_ontimeout() {
this.destroy()
}
/**
*
* @param { object } req
* @param { object } res
*/
async #createdialog( req, res ) {
this.dialog = await this._options.srf.createUAS( req, res, {
headers: {
"Accept": this.accept
}
} )
this.dialog.on( "subscribe", ( req, res ) => {
this._authorization = this._auth.parseauthheaders( req )
if( !this._user || !this._auth.verifyauth( req, this._authorization, this._user.secret ) ) {
if( this._auth.stale ) {
this._timers.subauth = setTimeout( this._ontimeout.bind( this ), this._options.authtimeout )
return this._auth.requestauth( req, res )
}
this.destroy()
return res.send( 403, "Bad auth" )
}
res.send( 202 )
} )
/* The follow 2 will both be called - but hang off both */
this.dialog.on( "unsubscribe", () => {
this.destroy()
} )
this.dialog.on( "destroy", () => {
this.destroy()
} )
}
/**
*
* @param { object } req
* @param { object } res
* @returns { Promise< boolean > }
*/
async #updateauth( req, res ) {
this._authorization = this._auth.parseauthheaders( req )
if( !this._user ) {
this._user = await this._options.userlookup( this._authorization.username, this._authorization.realm )
if( !this._user ) {
res.send( 403, "User error" )
this.destroy()
return false
}
this._user.username = this._authorization.username
this._user.realm = this._authorization.realm
this._user.entity = this._authorization.username + "@" + this._authorization.realm
} else if( this._user.username != this._authorization.username ||
this._user.realm != this._authorization.realm ) {
/* username or realm cannot change in the same reg */
res.send( 403, "Inconsistent" )
this.destroy()
return false
}
if( !this._user || !this._auth.verifyauth( req, this._authorization, this._user.secret ) ) {
if( this._auth.stale ) {
clearTimeout( this._timers.subauth )
this._timers.subauth = setTimeout( this._ontimeout.bind( this ), this._options.authtimeout )
this._auth.requestauth( req, res )
return false
}
res.send( 403, "Bad auth verify" )
this.destroy()
return false
}
clearTimeout( this._timers.subauth )
/* We only (currently) support subscription within a domain */
if( this.uri.host !== this._authorization.realm ) {
res.send( 403, "Forbidden" )
this.destroy()
return false
}
uasstore.set( this )
return true
}
/**
* @param {object } req
* @param { object } res
* @returns { boolean }
*/
#checkexpires( req, res ) {
const expires = subscription.getexpires( req )
if( isNaN( expires ) ) {
res.send( 400, "Bad Expires" )
this.destroy()
return false
}
if( 0 === expires ) {
res.send( 200, "Ok" )
this.destroy()
return false
}
if( 90 > expires ) {
res.send( 423, "Interval Too Brief", {
headers: {
"Min-Expires": "90"
}
} )
this.destroy()
return false
}
if( 3600 < expires ) {
res.send( 200, "Ok", {
headers: {
Expires: ""+3600
}
} )
}
clearTimeout( this._timers.subexpire )
this._timers.subexpire = setTimeout( () => {
this.destroy()
}, expires * 1000 )
return true
}
/**
*
* @param { object } req
* @param { object } res
* @returns { Promise< boolean > }
*/
// eslint-disable-next-line complexity
async _update( req, res ) {
if( !( await this.#updateauth( req, res ) ) ) {
return false
}
this._authed = true
if( false === this.#checkexpires( req, res ) ) return false
/* It is not mandatory to require this header - default when one not provided */
let accept = "application/pidf+xml"
if( req.has( "Accept" ) ) accept = req.get( "Accept" )
const inaccepts = accept.split( "," )
let namedevent, info
for( const ac of inaccepts ) {
if( -1 !== [ "application/dialog-info+xml",
"application/xpidf+xml",
"application/pidf+xml" ].indexOf( ac ) ) {
if( !this._state ) {
if( !this.accept ) this.accept = ac
namedevent = "presence.subscribe.in"
info = {
"contenttype": ac,
"entity": this._user.entity,
"expires": this.expires,
"callid": this._fqcallid
}
this._state = {}
}
break
}
if( "application/simple-message-summary" === ac ) {
if( !this._state ) {
if( !this.accept ) this.accept = ac
namedevent = "presence.voicemail.in"
info = {
"contenttype": ac,
"entity": this._user.entity,
"expires": this.expires,
"callid": this._fqcallid
}
this._state = {}
}
break
}
}
if( !this.accept ) {
this.destroy()
res.send( 406, "Not Acceptable" )
return false
}
try {
await this.#createdialog( req, res )
this._options.em.emit( namedevent, info )
} catch ( e ) {
console.error( e )
this.destroy()
return false
}
return true
}
/* cleanup */
destroy() {
this.#destroyed = true
for( const t in this._timers ) {
if( this._timers[ t ] ) clearTimeout( this._timers[ t ] )
}
uasstore.delete( this )
if( this.dialog && this.dialog.connected ) this.dialog.destroy()
}
/**
* Send request to check-sync (reboot) the endpoint.
* @returns
*/
async notifychecksync( ) {
if( !this._authed ) return
if( !this.dialog ) return
if( this.#destroyed ) return
if( "application/simple-message-summary" !== this.accept ) return
const opts = {
"method": "NOTIFY",
"headers": {
"Content-Type": this.accept,
"Content-Length": 0,
"Event": "check-sync",
"Subscription-State": "terminated;reason=noresource"
}
}
try {
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) this.destroy()
} catch( e ) {
console.error( e )
this.destroy()
}
}
/* range of notify functions */
/**
*
* @param { object } info
* @param { number } info.new
* @param { number } info.old
* @param { number } info.newurgent
* @param { number } info.oldurgent
* @param { string } info.entity
* @param { string } info.reason
* @returns
*/
async notifyvoicemail( info ) {
if( !this._authed ) return
if( !this.dialog ) return
if( "init" === info.reason && this._init ) return
if( this.#destroyed ) return
if( "application/simple-message-summary" !== this.accept ) return
this._init = true
const waiting = 0 < info.new ? "yes":"no"
const body = [ `Messages-Waiting: ${waiting}`,
`Message-Account: sip:${info.entity}`,
`Voice-Message: ${info.new}/${info.old} ${info.newurgent}/${info.oldurgent}` ].join( "\r\n" ) + "\r\n"
const opts = {
"method": "NOTIFY",
"headers": {
"Content-Type": this.accept,
"Subscription-State": "active;expires=" + this.expires,
"Event": "message-summary",
"Content-Length": Buffer.byteLength( body )
},
body
}
try {
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) this.destroy()
} catch ( e ) {
console.error( e )
this.destroy()
}
}
/**
* @typedef { object } call
* @property { string } entity
* @property { object } sip - Object describing basic SIP params
* @property { string } sip.callid - Call-ID
* @property { "initiate" | "receive" } direction
* @property { "initiated" | "early" | "confirmed" | "terminated" } state
* @property { number } duration
* @property { boolean } hasmedia
* @property { object } remote - Object describing remote party
* @property { string } remote.display - Display name
* @property { string } remote.uri - URI
*
**/
/**
*
* @param { object } info
* @param { string } info.entity
* @param { string } info.display
* @param { number } info.callcount
* @param { Array< call > } [ info.full ] - If no calls should be emopty - or an array with full list of calls
* @param { call } [ info.partial ] - if present only send this one if we can - i.e. this is the one changed
* @param { string } [ info.callid ] send only to the subscription with call id - i.e. initial (used in calling function)
*/
// eslint-disable-next-line complexity
async notifydialog( info ) {
if( !this._authed ) return
if( !this.dialog ) return
if( this.#destroyed ) return
/* For certain document types on the phone is simply transmitted based on call count */
if( this.lastinfo ) {
await this.notifyregistration( {
"entity": this.lastinfo.entity,
"status": this.lastinfo.status,
"callcount": info.callcount
} )
}
if( this.#destroyed ) return
if( "application/dialog-info+xml" !== this.accept ) return
const opts = {
"method": "NOTIFY",
"headers": {
"Content-Type": this.accept,
"Subscription-State": "active;expires=" + this.expires,
"Event": "dialog"
}
}
if( !this.version ) this.version = 0
try {
if( Array.isArray( info.full ) ) {
const [ first, ...rest ] = info.full
opts.body = doc.createdialoginfoxml( this.version, "full", info.entity, info.display, first )
opts.headers[ "Content-Length" ] = Buffer.byteLength( opts.body )
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) {
this.destroy()
return
}
this.version++
for ( const dialog of rest ) {
opts.body = doc.createdialoginfoxml( this.version, "partial", info.entity, info.display, dialog )
opts.headers[ "Content-Length" ] = Buffer.byteLength( opts.body )
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) {
this.destroy()
return
}
this.version++
}
} else {
opts.body = doc.createdialoginfoxml( this.version, "partial", info.entity, info.display, info.partial )
opts.headers[ "Content-Length" ] = Buffer.byteLength( opts.body )
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) {
this.destroy()
return
}
this.version++
}
} catch ( e ) {
console.error( e )
this.destroy()
}
}
/**
*
* @param { object } info
*/
async #handlepidfxml( info ) {
const opts = {
"method": "NOTIFY",
"headers": {
"Content-Type": this.accept,
"Subscription-State": "active;expires=" + this.expires,
"Event": "presence"
}
}
if( "registered" == info.status ) {
if( 0 < info.callcount ) {
/* TODO we can pull out more infoamtion such as who they are taling with */
opts.body = doc.genpidfxml( info.entity, "closed", "Talking", "on-the-phone" )
} else {
opts.body = doc.genpidfxml( info.entity, "open", "Available", "" )
}
} else {
opts.body = doc.genpidfxml( info.entity, "closed", "Unavailable", "" )
}
opts.headers[ "Content-Length" ] = Buffer.byteLength( opts.body )
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) {
this.destroy()
}
}
/**
* polycom
* @param { object } info
* @param { string } info.entity
* @param { "registered"|"unregistered" } info.status
* @param { number } info.callcount
*/
async #handlexpidfxml( info ) {
const opts = {
"method": "NOTIFY",
"headers": {
"Content-Type": this.accept,
"Subscription-State": "active;expires=" + this.expires,
"Event": "presence"
}
}
if( "registered" == info.status ) {
if( 0 < info.callcount ) {
opts.body = doc.genxpidfxml( info.entity, "closed", "Busy", "busy" )
} else {
opts.body = doc.genxpidfxml( info.entity, "open", "Available", "online" )
}
} else {
opts.body = doc.genxpidfxml( info.entity, "closed", "Offline", "away" )
}
opts.headers[ "Content-Length" ] = Buffer.byteLength( opts.body )
const res = await this.dialog.request( opts )
if( !isgoodresponce( res ) ) this.destroy()
}
/**
*
* @param { object } info
* @param { string } info.entity
* @param { "registered"|"unregistered" } info.status
* @param { number } info.callcount
*/
async notifyregistration( info ) {
if( !this._authed ) return
if( !this.dialog ) return
if( this.#destroyed ) return
this.lastinfo = info
try {
if( "application/pidf+xml" === this.accept ) await this.#handlepidfxml( info )
else if( "application/xpidf+xml" === this.accept ) await this.#handlexpidfxml( info )
} catch( e ) {
console.error( e )
this.destroy()
}
}
}
module.exports = subscription