diff --git a/README.md b/README.md index 6966403..316ec01 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The supported Janus plugins are: - VideoRoom - SIP - Record&Play +- TextRoom (Janus API only) The library is available on [npm](https://www.npmjs.com/package/janode) and the source code is on [github](https://github.com/meetecho/janode). diff --git a/package.json b/package.json index 724bac2..c299f5a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "./plugins/recordplay": "./src/plugins/recordplay-plugin.js", "./plugins/sip": "./src/plugins/sip-plugin.js", "./plugins/streaming": "./src/plugins/streaming-plugin.js", + "./plugins/textroom": "./src/plugins/textroom-plugin.js", "./plugins/videoroom": "./src/plugins/videoroom-plugin.js" }, "files": [ diff --git a/src/plugins/textroom-plugin.js b/src/plugins/textroom-plugin.js new file mode 100644 index 0000000..6cb30d1 --- /dev/null +++ b/src/plugins/textroom-plugin.js @@ -0,0 +1,531 @@ +'use strict'; + +/** + * This module contains the implementation of the TextRoom plugin (ref. {@link https://janus.conf.meetecho.com/docs/textroom.html}). + * Notice this only covers what's possible via the Janus API: messages only sent via datachannels are not covered by this module. + * @module textroom-plugin + */ + +import Handle from '../handle.js'; + +/* The plugin ID exported in the plugin descriptor */ +const PLUGIN_ID = 'janus.plugin.textroom'; + +/* These are the requests defined for the Janus TextRoom API */ +const REQUEST_SETUP = 'setup'; +const REQUEST_ACK = 'ack'; +const REQUEST_RESTART = 'restart'; +const REQUEST_LIST_ROOMS = 'list'; +const REQUEST_LIST_PARTICIPANTS = 'listparticipants'; +const REQUEST_EXISTS = 'exists'; +const REQUEST_CREATE = 'create'; +const REQUEST_ALLOW = 'allowed'; +const REQUEST_ANNOUNCEMENT = 'announcement'; +const REQUEST_KICK = 'kick'; +const REQUEST_DESTROY = 'destroy'; + +/* These are the events/responses that the Janode plugin will manage */ +/* Some of them will be exported in the plugin descriptor */ +const PLUGIN_EVENT = { + ROOMS_LIST: 'textroom_list', + PARTICIPANTS_LIST: 'textroom_participants_list', + EXISTS: 'textroom_exists', + CREATED: 'textroom_created', + DESTROYED: 'textroom_destroyed', + SUCCESS: 'textroom_success', + ERROR: 'textroom_error', +}; + +/** + * The class implementing the TextRoom plugin (ref. {@link https://janus.conf.meetecho.com/docs/textroom.html}). + * Notice this only covers what's possible via the Janus API: messages only sent via datachannels are not covered by this module.
+ * + * It extends the base Janode Handle class and overrides the base "handleMessage" method.
+ * + * Moreover it defines many methods to support TextRoom operations. + * + * @hideconstructor + * @extends module:handle~Handle + */ +class TextRoomHandle extends Handle { + /** + * Create a Janode TextRoom handle. + * + * @param {module:session~Session} session - A reference to the parent session + * @param {number} id - The handle identifier + */ + constructor(session, id) { + super(session, id); + } + + /** + * The custom "handleMessage" needed for handling TextRoom messages. + * + * @private + * @param {Object} janus_message + * @returns {Object} A falsy value for unhandled events, a truthy value for handled events + */ + handleMessage(janus_message) { + const { plugindata, transaction } = janus_message; + if (plugindata && plugindata.data && plugindata.data.textroom) { + /** + * @type {TextRoomData} + */ + const message_data = plugindata.data; + const { textroom, error, error_code, room } = message_data; + + /* Prepare an object for the output Janode event */ + const janode_event = this._newPluginEvent(janus_message); + + /* Add room information if available */ + if (room) janode_event.data.room = room; + + /* The plugin will emit an event only if the handle does not own the transaction */ + /* That means that a transaction has already been closed or this is an async event */ + const emit = (this.ownsTransaction(transaction) === false); + + switch (textroom) { + + /* success response */ + case 'success': + /* Room exists API */ + if (typeof message_data.exists !== 'undefined') { + janode_event.data.exists = message_data.exists; + janode_event.event = PLUGIN_EVENT.EXISTS; + break; + } + /* Room list API */ + if (typeof message_data.list !== 'undefined') { + janode_event.data.list = message_data.list; + janode_event.event = PLUGIN_EVENT.ROOMS_LIST; + break; + } + /* Participants list API */ + if (typeof message_data.participants !== 'undefined') { + janode_event.data.participants = message_data.participants; + janode_event.event = PLUGIN_EVENT.PARTICIPANTS_LIST; + break; + } + + /* Generic success (might be token disable) */ + if (typeof message_data.allowed !== 'undefined') { + janode_event.data.list = message_data.allowed; + } + /* In this case the "event" field of the Janode event is "success" */ + janode_event.event = PLUGIN_EVENT.SUCCESS; + break; + + /* TextRoom room created */ + case 'created': + janode_event.event = PLUGIN_EVENT.CREATED; + janode_event.data.permanent = message_data.permanent; + break; + + /* TextRoom room destroyed */ + case 'destroyed': + janode_event.event = PLUGIN_EVENT.DESTROYED; + janode_event.data.permanent = message_data.permanent; + break; + + /* Generic event (e.g. errors) */ + case 'event': + /* TextRoom error */ + if (error) { + janode_event.event = PLUGIN_EVENT.ERROR; + janode_event.data = new Error(`${error_code} ${error}`); + /* In case of error, close a transaction */ + this.closeTransactionWithError(transaction, janode_event.data); + break; + } + /* Configuration success for this handle */ + if (typeof message_data.result !== 'undefined') { + if (message_data.result === 'ok') { + janode_event.event = PLUGIN_EVENT.SUCCESS; + } + break; + } + } + + /* The event has been handled */ + if (janode_event.event) { + /* Try to close the transaction */ + this.closeTransactionWithSuccess(transaction, janus_message); + /* If the transaction was not owned, emit the event */ + if (emit) this.emit(janode_event.event, janode_event.data); + return janode_event; + } + } + + /* The event has not been handled, return a falsy value */ + return null; + } + + /*----------*/ + /* USER API */ + /*----------*/ + + /* These are the APIs that users need to work with the textroom plugin */ + + /** + * Setup a datachannel connection. + * + * @returns {Promise} + */ + async setup() { + const body = { + request: REQUEST_SETUP, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Complete the setup or restart of a datachannel connection. + * + * @param {Object} params + * @param {RTCSessionDescription} params.jsep + * @returns {Promise} + */ + async ack(jsep) { + const body = { + request: REQUEST_ACK, + }; + + const response = await this.message(body, jsep); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Restart the setup of a datachannel connection. + * + * @returns {Promise} + */ + async restart() { + const body = { + request: REQUEST_RESTART, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /*----------------*/ + /* Management API */ + /*----------------*/ + + /* These are the APIs needed to manage textroom resources (rooms, forwarders ...) */ + + /** + * List available textroom rooms. + * + * @param {Object} params + * @param {string} [params.admin_key] - The admin key needed for invoking the API + * @returns {Promise} + */ + async list({ admin_key }) { + const body = { + request: REQUEST_LIST_ROOMS, + }; + if (typeof admin_key === 'string') body.admin_key = admin_key; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.ROOMS_LIST) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * List participants inside a room. + * + * @param {Object} params + * @param {number|string} params.room - The room where to execute the list + * @returns {Promise} + */ + async listParticipants({ room, secret }) { + const body = { + request: REQUEST_LIST_PARTICIPANTS, + room, + }; + if (typeof secret === 'string') body.secret = secret; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.PARTICIPANTS_LIST) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Check if a room exists. + * + * @param {Object} params + * @param {number|string} params.room - The involved room + * @returns {Promise} + */ + async exists({ room }) { + const body = { + request: REQUEST_EXISTS, + room, + }; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.EXISTS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Create a textroom room. + * + * @param {Object} params + * @param {number|string} params.room - The room identifier + * @param {string} [params.admin_key] - The admin key needed for invoking the API + * @param {string} [params.description] - A room description + * @param {string} [params.secret] - The secret to be used when managing the room + * @param {string} [params.pin] - The ping needed for joining the room + * @param {boolean} [params.is_private] - Set room as private (hidden in list) + * @param {boolean} [params.history] - Set number of messages to store as a history + * @param {boolean} [params.post] - Set HTTP backend to forward incoming chat messages to + * @param {boolean} [params.permanent] - Set to true to persist the room in the Janus config file + * @returns {Promise} + */ + async create({ room, admin_key, description, secret, pin, is_private, history, post, permanent }) { + const body = { + request: REQUEST_CREATE, + room, + }; + if (typeof admin_key === 'string') body.admin_key = admin_key; + if (typeof description === 'string') body.description = description; + if (typeof secret === 'string') body.secret = secret; + if (typeof pin === 'string') body.pin = pin; + if (typeof is_private === 'boolean') body.is_private = is_private; + if (typeof history === 'number') body.history = history; + if (typeof post === 'string') body.post = post; + if (typeof permanent === 'boolean') body.permanent = permanent; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.CREATED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Edit a textroom token list. + * + * @param {Object} params + * @param {number|string} params.room - The involved room + * @param {"enable"|"disable"|"add"|"remove"} params.action - The action to perform + * @param {string[]} params.list - The list of tokens to add/remove + * @param {string} [params.secret] - The optional secret needed to manage the room + * @returns {Promise} + */ + async allow({ room, action, list, secret }) { + const body = { + request: REQUEST_ALLOW, + room, + action, + }; + if (list && list.length > 0) body.allowed = list; + if (typeof secret === 'string') body.secret = secret; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Send an announcement to a textroom room. + * + * @param {Object} params + * @param {number|string} params.room - The involved room + * @param {string} params.text - The content of the announcement, as text + * @param {string} [params.secret] - The optional secret needed to manage the room + * @returns {Promise} + */ + async announcement({ room, text, secret }) { + const body = { + request: REQUEST_ANNOUNCEMENT, + room, + text, + }; + if (typeof secret === 'string') body.secret = secret; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Kick an user out from a room. + * + * @param {Object} params + * @param {number|string} params.room - The involved room + * @param {string} params.username - The user to kick out + * @param {string} [params.secret] - The optional secret needed for managing the room + * @returns {Promise} + */ + async kick({ room, username, secret }) { + const body = { + request: REQUEST_KICK, + room, + username, + }; + if (typeof secret === 'string') body.secret = secret; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.SUCCESS) { + /* Add data missing from Janus response */ + evtdata.room = body.room; + evtdata.username = body.username; + return evtdata; + } + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + + /** + * Destroy a textroom room. + * + * @param {Object} params + * @param {number|string} params.room - The room to destroy + * @param {boolean} [params.permanent] - Set to true to remove the room from the Janus config file + * @param {string} [params.secret] - The optional secret needed to manage the room + * @returns {Promise} + */ + async destroy({ room, permanent, secret }) { + const body = { + request: REQUEST_DESTROY, + room, + }; + if (typeof permanent === 'boolean') body.permanent = permanent; + if (typeof secret === 'string') body.secret = secret; + + const response = await this.message(body); + const { event, data: evtdata } = this._getPluginEvent(response); + if (event === PLUGIN_EVENT.DESTROYED) + return evtdata; + const error = new Error(`unexpected response to ${body.request} request`); + throw (error); + } + +} + +/** + * The payload of the plugin message (cfr. Janus docs). + * {@link https://janus.conf.meetecho.com/docs/textroom.html} + * + * @private + * @typedef {Object} TextRoomData + */ + +/** + * The response event for textroom WebRTC establishment. + * + * @typedef {Object} TEXTROOM_EVENT_SUCCESS + */ + +/** + * The response event for textroom room list request. + * + * @typedef {Object} TEXTROOM_EVENT_ROOMS_LIST + * @property {object[]} list - The list of the rooms as returned by Janus + */ + +/** + * The response event for textroom participants list request. + * + * @typedef {Object} TEXTROOM_EVENT_PARTICIPANTS_LIST + * @property {number|string} room - The involved room + * @property {object[]} participants - The list of participants as returned by Janus + */ + +/** + * The response event for textroom room exists request. + * + * @typedef {Object} TEXTROOM_EVENT_EXISTS + * @property {number|string} room - The involved room + * @property {boolean} exists - True if the rooms exists + */ + +/** + * The response event for textroom room create request. + * + * @typedef {Object} TEXTROOM_EVENT_CREATED + * @property {number|string} room - The created room + * @property {boolean} permanent - True if the room is being persisted in the Janus config file + */ + +/** + * The response event for textroom room destroy request. + * + * @typedef {Object} TEXTROOM_EVENT_DESTROYED + * @property {number|string} room - The destroyed room + * @property {boolean} permanent - True if the room removal is being persisted in the Janus config file + */ + +/** + * The response event for textroom participant kick request. + * + * @typedef {Object} TEXTROOM_EVENT_KICK_RESPONSE + * @property {number|string} room - The involved room + * @property {string} username - The username that has been kicked out + */ + +/** + * The response event for textroom ACL token edit request. + * + * @typedef {Object} TEXTROOM_EVENT_ALLOWED_RESPONSE + * @property {number|string} room - The involved room + * @property {string[]} list - The updated, complete, list of allowed tokens + */ + +/** + * The exported plugin descriptor. + * + * @type {Object} + * @property {string} id - The plugin identifier used when attaching to Janus + * @property {module:textroom-plugin~TextRoomHandle} Handle - The custom class implementing the plugin + * @property {Object} EVENT - The events emitted by the plugin + * @property {string} EVENT.TEXTROOM_ERROR {@link module:textroom-plugin~TextRoomHandle#event:TEXTROOM_ERROR TEXTROOM_ERROR} + */ +export default { + id: PLUGIN_ID, + Handle: TextRoomHandle, + + EVENT: { + /** + * Generic textroom error. + * + * @event module:textroom-plugin~TextRoomHandle#event:TEXTROOM_ERROR + * @type {Error} + */ + TEXTROOM_ERROR: PLUGIN_EVENT.ERROR, + }, +};