From 3ce66815090736f59a0274ea38ca76e4cba617d6 Mon Sep 17 00:00:00 2001 From: Antonio Date: Sun, 1 Sep 2019 15:25:48 +0300 Subject: [PATCH] Added jsdoc documentation for driver methods. Configured docma to generate artifacts --- README.md | 44 ++++--- dist/lib/driver.js | 281 ++++++++++++++++++++++++++++++++++----------- docma.json | 79 +++++++++++++ favicon.ico | Bin 0 -> 15086 bytes package.json | 2 + 5 files changed, 324 insertions(+), 82 deletions(-) create mode 100644 docma.json create mode 100644 favicon.ico diff --git a/README.md b/README.md index 00c66e9..454dcbf 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,12 @@ Create your own working BOT for Rocket.Chat, in seconds, at [glitch.com](https:/ Add your own Rocket.Chat BOT, running on your favorite Linux, MacOS or Windows system. First, make sure you have the latest version of [nodeJS](https://nodejs.org/) (nodeJS 8.x or higher). + ``` node -v v8.9.3 ``` + In a project directory, add Rocket.Chat.js.SDK as dependency: ``` @@ -27,7 +29,8 @@ npm install @rocket.chat/sdk --save ``` Next, create _easybot.js_ with the following: -``` + +```js const { driver } = require('@rocket.chat/sdk'); // customize the following with your server and BOT account information const HOST = 'myserver.com'; @@ -142,11 +145,15 @@ Access these modules by importing them from SDK, e.g: For Node 8 / ES5 -`const { driver, methodCache, api } = require('@rocket.chat/sdk')` +```js +const { driver, methodCache, api } = require('@rocket.chat/sdk') +``` For ES6 supporting platforms -`import { driver, methodCache, api } from '@rocket.chat/sdk'` +```js +import { driver, methodCache, api } from '@rocket.chat/sdk' +``` Any Rocket.Chat server method can be called via `driver.callMethod`, `driver.cacheCall` or `driver.asyncCall`. Server methods are not fully @@ -515,11 +522,18 @@ interactions (i.e. bots) locally while in development. ## Use as Dependency -`yarn add @rocket.chat/sdk` or `npm install --save @rocket.chat/sdk` +``` +yarn add @rocket.chat/sdk +``` +or + +``` +npm install --save @rocket.chat/sdk +``` ES6 module, using async -``` +```js import * as rocketchat from '@rocket.chat/sdk' const asteroid = await rocketchat.driver.connect({ host: 'localhost:3000' }) @@ -528,7 +542,7 @@ console.log('connected', asteroid) ES5 module, using callback -``` +```js const rocketchat = require('@rocket.chat/sdk') rocketchat.driver.connect({ host: 'localhost:3000' }, function (err, asteroid) { @@ -577,18 +591,22 @@ Rocket.Chat development you might do locally. The following will provision a default admin user on build, so it can be used to access the API, allowing SDK utils to prepare for and clean up tests. -- `git clone https://github.com/RocketChat/Rocket.Chat.git rc-sdk-test` -- `cd rc-sdk-test` -- `meteor npm install` -- `export ADMIN_PASS=pass; export ADMIN_USERNAME=sdk; export MONGO_URL='mongodb://localhost:27017/rc-sdk-test'; meteor` +``` +git clone https://github.com/RocketChat/Rocket.Chat.git rc-sdk-test +cd rc-sdk-test +meteor npm install +export ADMIN_PASS=pass; export ADMIN_USERNAME=sdk; export MONGO_URL='mongodb://localhost:27017/rc-sdk-test'; meteor +``` Using `yarn` to run local tests and build scripts is recommended. Do `npm install -g yarn` if you don't have it. Then setup the project: -- `git clone https://github.com/RocketChat/Rocket.Chat.js.SDK.git` -- `cd Rocket.Chat.js.SDK` -- `yarn` +``` +git clone https://github.com/RocketChat/Rocket.Chat.js.SDK.git +cd Rocket.Chat.js.SDK +yarn +``` ### Test and Build Scripts diff --git a/dist/lib/driver.js b/dist/lib/driver.js index b6a7814..357ea51 100644 --- a/dist/lib/driver.js +++ b/dist/lib/driver.js @@ -24,13 +24,14 @@ const settings = __importStar(require("./settings")); const methodCache = __importStar(require("./methodCache")); const message_1 = require("./message"); const log_1 = require("./log"); -/** Collection names */ + const _messageCollectionName = 'stream-room-messages'; const _messageStreamName = '__my_messages__'; /** * The integration property is applied as an ID on sent messages `bot.i` param * Should be replaced when connection is invoked by a package using the SDK * e.g. The Hubot adapter would pass its integration ID with credentials, like: + * @ignore */ exports.integrationId = settings.integrationId; /** @@ -39,36 +40,51 @@ exports.integrationId = settings.integrationId; * import { driver } from '@rocket.chat/sdk' * driver.connect() * driver.events.on('connected', () => console.log('driver connected')) + * @ignore */ exports.events = new events_1.EventEmitter(); /** * Asteroid subscriptions, exported for direct polling by adapters * Variable not initialised until `prepMeteorSubscriptions` called. + * @ignore */ exports.subscriptions = []; /** * Array of joined room IDs (for reactive queries) + * @ignore */ exports.joinedIds = []; /** - * Allow override of default logging with adapter's log instance + * @memberof module:driver + * @instance + * @description Replaces the default logger with one from a bot framework + * @param {Class|Object} externalLog - Class or object with `debug`, `info`, `warn`, `error` methods + * @returns {void} */ function useLog(externalLog) { log_1.replaceLog(externalLog); } exports.useLog = useLog; + /** - * Initialise asteroid instance with given options or defaults. - * Returns promise, resolved with Asteroid instance. Callback follows - * error-first-pattern. Error returned or promise rejected on timeout. - * Removes http/s protocol to get connection hostname if taken from URL. - * @example Use with callback + * @memberof module:driver + * @instance + * @description Initialize asteroid instance with given options or defaults. + * Callback follows error-first-pattern. + * Error returned or promise rejected on timeout. + * @param {Object} [options={}] - an object containing options to initialize Asteroid instance with + * @param {string} [options.host=''] - hostname to connect to. Removes http/s protocol if taken from URL + * @param {boolean} [options.useSsl=false] - whether the SSL is enabled for the host + * @param {number} [options.timeout] - timeframe after which the connection attempt will be considered as failed + * @returns {Promise} + * @throws {Error} Asteroid connection timeout + * @example Usage with callback * import { driver } from '@rocket.chat/sdk' * driver.connect({}, (err) => { * if (err) throw err * else console.log('connected') * }) - * @example Using promise + * @example Usage with promise * import { driver } from '@rocket.chat/sdk' * driver.connect() * .then(() => console.log('connected')) @@ -111,7 +127,12 @@ function connect(options = {}, callback) { }); } exports.connect = connect; -/** Remove all active subscriptions, logout and disconnect from Rocket.Chat */ +/** + * @memberof module:driver + * @instance + * @description Remove all active subscriptions, logout and disconnect from Rocket.Chat + * @returns {Promise} + */ function disconnect() { log_1.logger.info('Unsubscribing, logging out, disconnecting'); unsubscribeAll(); @@ -124,6 +145,7 @@ exports.disconnect = disconnect; /** * Setup method cache configs from env or defaults, before they are called. * @param asteroid The asteroid instance to cache method calls + * @ignore */ function setupMethodCache(asteroid) { methodCache.use(asteroid); @@ -141,9 +163,12 @@ function setupMethodCache(asteroid) { }); } /** - * Wraps method calls to ensure they return a Promise with caught exceptions. - * @param method The Rocket.Chat server method, to call through Asteroid - * @param params Single or array of parameters of the method to call + * @memberof module:driver + * @instance + * @description Wrap server method calls to always be async (return a Promise with caught exceptions) + * @param {any} method - the Rocket.Chat server method, to call through Asteroid + * @param {string|string[]} params - single or array of parameters of the method to call + * @returns {Promise} */ function asyncCall(method, params) { if (!Array.isArray(params)) @@ -163,11 +188,16 @@ function asyncCall(method, params) { } exports.asyncCall = asyncCall; /** - * Call a method as async via Asteroid, or through cache if one is created. - * If the method doesn't have or need parameters, it can't use them for caching - * so it will always call asynchronously. - * @param name The Rocket.Chat server method to call - * @param params Single or array of parameters of the method to call + * @memberof module:driver + * @instance + * @description Call a method as async ({@link module:driver#asyncCall|asyncCall}) via Asteroid, + * or through cache ({@link module:driver#cacheCall|cacheCall}) if one is created. + * + * If the method was called without parameters, they cannot be cached. + * As the result, the method will always be called asynchronously. + * @param {any} name - the Rocket.Chat server method to call through Asteroid + * @param {string|string[]} params - single or array of parameters of the method to call + * @returns {Promise} */ function callMethod(name, params) { return (methodCache.has(name) || typeof params === 'undefined') @@ -176,9 +206,12 @@ function callMethod(name, params) { } exports.callMethod = callMethod; /** - * Wraps Asteroid method calls, passed through method cache if cache is valid. - * @param method The Rocket.Chat server method, to call through Asteroid - * @param key Single string parameters only, required to use as cache key + * @memberof module:driver + * @instance + * @description Call Asteroid method calls with `methodCache`, if cache is valid + * @param {any} method - the Rocket.Chat server method, to call through Asteroid + * @param {string} key - single string parameters only, used as a cache key + * @returns {Promise} - Server results or cached results, if valid */ function cacheCall(method, key) { return methodCache.call(method, key) @@ -196,7 +229,17 @@ function cacheCall(method, key) { exports.cacheCall = cacheCall; // LOGIN AND SUBSCRIBE TO ROOMS // ----------------------------------------------------------------------------- -/** Login to Rocket.Chat via Asteroid */ +/** + * @memberof module:driver + * @instance + * @description Login to Rocket.Chat via Asteroid + * @param {Object} [credentials={}] - an object containing credentials to log in to Rocket.Chat + * @param {string} [credentials.username = ROCKETCHAT_USER] - username of the Rocket.Chat user + * @param {string} [credentials.email = ROCKETCHAT_USER] - email of the Rocket.Chat user. + * @param {string} [credentials.password=ROCKETCHAT_PASSWORD] - password of the Rocket.Chat user + * @param {boolean} [credentials.ldap=false] - whether LDAP is enabled for login + * @returns {Promise} + */ function login(credentials = { username: settings.username, password: settings.password, @@ -225,7 +268,12 @@ function login(credentials = { }); } exports.login = login; -/** Logout of Rocket.Chat via Asteroid */ +/** + * @memberof module:driver + * @instance + * @description Logout Rocket.Chat via Asteroid + * @returns {Promise} + * */ function logout() { return exports.asteroid.logout() .catch((err) => { @@ -235,9 +283,13 @@ function logout() { } exports.logout = logout; /** - * Subscribe to Meteor subscription - * Resolves with subscription (added to array), with ID property + * @memberof module:driver + * @instance + * @description Subscribe to Meteor subscription + * @param {string} topic - subscription topic + * @param {number} roomId - unique ID of the room to subscribe to * @todo - 3rd param of asteroid.subscribe is deprecated in Rocket.Chat? + * @returns {Promise} - Subscription instance (added to array), with ID property */ function subscribe(topic, roomId) { return new Promise((resolve, reject) => { @@ -252,7 +304,13 @@ function subscribe(topic, roomId) { }); } exports.subscribe = subscribe; -/** Unsubscribe from Meteor subscription */ +/** + * @memberof module:driver + * @instance + * @description Unsubscribe from Meteor subscription + * @param {any} subscription - Subscription instance to unsbscribe from + * @returns {Promise} + */ function unsubscribe(subscription) { const index = exports.subscriptions.indexOf(subscription); if (index === -1) @@ -263,14 +321,25 @@ function unsubscribe(subscription) { log_1.logger.info(`[${subscription.id}] Unsubscribed`); } exports.unsubscribe = unsubscribe; -/** Unsubscribe from all subscriptions in collection */ +/** + * @memberof module:driver + * @instance + * @description Unsubscribe from all subscriptions in collection + * @returns {Promise} + */ function unsubscribeAll() { exports.subscriptions.map((s) => unsubscribe(s)); } exports.unsubscribeAll = unsubscribeAll; /** - * Begin subscription to room events for user. - * Older adapters used an option for this method but it was always the default. + * @memberof module:driver + * @instance + * @description Begin subscription to room events for user + * + * > NOTE: Older adapters used an option for this method but it was always the default. + * @param {string} [topic=stream-room-messages] - subscription topic + * @param {number} [roomId=__my_messages__] - unique ID of the room to subscribe to + * @returns {Promise} - Subscription instance */ function subscribeToMessages() { return subscribe(_messageCollectionName, _messageStreamName) @@ -281,16 +350,23 @@ function subscribeToMessages() { } exports.subscribeToMessages = subscribeToMessages; /** - * Once a subscription is created, using `subscribeToMessages` this method - * can be used to attach a callback to changes in the message stream. - * This can be called directly for custom extensions, but for most usage (e.g. - * for bots) the respondToMessages is more useful to only receive messages + * @memberof module:driver + * @instance + * @description Attach a callback to changes in the message stream. + * + * This method should be used only after a subscription was created using + * {@link module:driver#subscribeToMessages|subscribeToMessages}. + * Fires callback with every change in subscriptions. + * + * > NOTE: This method can be called directly for custom extensions, but for most usage + * (e.g. for bots) the + * {@link module:driver#respondToMessages|respondToMessages} is more useful to only receive messages * matching configuration. * * If the bot hasn't been joined to any rooms at this point, it will attempt to * join now based on environment config, otherwise it might not receive any - * messages. It doesn't matter that this happens asynchronously because the - * bot's joined rooms can change after the reactive query is set up. + * messages. It doesn't matter that this happens asynchronously because the rooms + * the bot joined to can change after the reactive query is set up. * * @todo `reactToMessages` should call `subscribeToMessages` if not already * done, so it's not required as an arbitrary step for simpler adapters. @@ -298,10 +374,10 @@ exports.subscribeToMessages = subscribeToMessages; * `respondToMessages` calls `respondToMessages`, so all that's really * required is: * `driver.login(credentials).then(() => driver.respondToMessages(callback))` - * @param callback Function called with every change in subscriptions. - * - Uses error-first callback pattern - * - Second argument is the changed item - * - Third argument is additional attributes, such as `roomType` + * @param {Function} callback - function called with every change in subscriptions. + * - It uses error-first callback pattern + * - The second argument is the changed item + * - The third argument is additional attributes, such as `roomType` */ function reactToMessages(callback) { log_1.logger.info(`[reactive] Listening for change events in collection ${exports.messages.name}`); @@ -324,13 +400,23 @@ function reactToMessages(callback) { } exports.reactToMessages = reactToMessages; /** - * Proxy for `reactToMessages` with some filtering of messages based on config. - * - * @param callback Function called after filters run on subscription events. - * - Uses error-first callback pattern - * - Second argument is the changed item - * - Third argument is additional attributes, such as `roomType` - * @param options Sets filters for different event/message types. + * @memberof module:driver + * @instance + * @description Proxy for {@link module:driver#reactToMessages|reactToMessages} + * with some filtering of messages based on config. This is a more user-friendly method + * for bots to subscribe to a message stream. + * @param {Function} callback - function called after filters run on subscription events. + * - It uses error-first callback pattern + * - The second argument is the changed item + * - The third argument is additional attributes, such as `roomType` + * @param {Object} options - an object that sets filters for different event/message types + * @param {string[]} options.rooms - respond to only selected room/s (names or IDs). Ignored if `options.allPublic=true` + * If rooms are given as option or set in the environment with `ROCKETCHAT_ROOM` but have not been joined yet, + * this method will join to those rooms automatically. + * @param {boolean} options.allPublic - respond on all public channels. Ignored if `options.rooms=true` + * @param {boolean} options.dm - respond to messages in direct messages / private chats with the SDK user + * @param {boolean} options.livechat - respond to messages in Livechat rooms + * @param {boolean} options.edited - respond to edited messages */ function respondToMessages(callback, options = {}) { const config = Object.assign({}, settings, options); @@ -389,19 +475,36 @@ function respondToMessages(callback, options = {}) { exports.respondToMessages = respondToMessages; // PREPARE AND SEND MESSAGES // ----------------------------------------------------------------------------- -/** Get ID for a room by name (or ID). */ +/** + * @memberof module:driver + * @instance + * @description Get room's ID by its name + * @param {string} name - room's name or ID + * @returns {Promise} + */ function getRoomId(name) { return cacheCall('getRoomIdByNameOrId', name); } exports.getRoomId = getRoomId; -/** Get name for a room by ID. */ +/** + * @memberof module:driver + * @instance + * @description Get room's name by its ID + * @param {string} id - room's ID + * @returns {Promise} +*/ function getRoomName(id) { return cacheCall('getRoomNameById', id); } exports.getRoomName = getRoomName; /** - * Get ID for a DM room by its recipient's name. - * Will create a DM (with the bot) if it doesn't exist already. + * @memberof module:driver + * @instance + * @description Get ID for a DM room by its recipient's name. + * + * The call will create a DM (with the bot) if it doesn't exist yet. + * @param {string} username - recipient's username + * @returns {Promise} * @todo test why create resolves with object instead of simply ID */ function getDirectMessageRoomId(username) { @@ -409,7 +512,13 @@ function getDirectMessageRoomId(username) { .then((DM) => DM.rid); } exports.getDirectMessageRoomId = getDirectMessageRoomId; -/** Join the bot into a room by its name or ID */ +/** + * @memberof module:driver + * @instance + * @description Join the bot into a room using room's name or ID + * @param {string} room - room's name or ID + * @returns {Promise} + * */ function joinRoom(room) { return __awaiter(this, void 0, void 0, function* () { let roomId = yield getRoomId(room); @@ -424,7 +533,10 @@ function joinRoom(room) { }); } exports.joinRoom = joinRoom; -/** Exit a room the bot has joined */ +/** + * Exit a room the bot has joined + * @ignore + * */ function leaveRoom(room) { return __awaiter(this, void 0, void 0, function* () { let roomId = yield getRoomId(room); @@ -439,14 +551,24 @@ function leaveRoom(room) { }); } exports.leaveRoom = leaveRoom; -/** Join a set of rooms by array of names or IDs */ +/** + * @memberof module:driver + * @instance + * @description Join a set of rooms by array of room names or IDs + * @param {string[]} rooms - array of room names or IDs + * @returns {Promise} + * */ function joinRooms(rooms) { return Promise.all(rooms.map((room) => joinRoom(room))); } exports.joinRooms = joinRooms; /** - * Structure message content, optionally addressing to room ID. - * Accepts message text string or a structured message object. + * @memberof module:driver + * @instance + * @description Structure message content, optionally sending it to a specific room ID + * @param {string|Object} content - message text string or a structured message object + * @param {string} [roomId] - room's ID to send message content to + * @returns {Object} */ function prepareMessage(content, roomId) { const message = new message_1.Message(content, exports.integrationId); @@ -456,17 +578,24 @@ function prepareMessage(content, roomId) { } exports.prepareMessage = prepareMessage; /** - * Send a prepared message object (with pre-defined room ID). - * Usually prepared and called by sendMessageByRoomId or sendMessageByRoom. + * @memberof module:driver + * @instance + * @description Send a prepared message object (with a pre-defined room ID). + * Usually prepared and called by `sendMessageByRoomId` or `sendMessageByRoom`. + * @param {Object} message - structured message object + * @returns {Promise} */ function sendMessage(message) { return asyncCall('sendMessage', message); } exports.sendMessage = sendMessage; /** - * Prepare and send string/s to specified room ID. - * @param content Accepts message text string or array of strings. - * @param roomId ID of the target room to use in send. + * @memberof module:driver + * @instance + * @description Prepare and send string(s) to the specified room ID + * @param {string|string[]} content - message text string or array of strings + * @param {string} roomId - ID of the target room to use in send + * @returns {Promise|Promise[]} * @todo Returning one or many gets complicated with type checking not allowing * use of a property because result may be array, when you know it's not. * Solution would probably be to always return an array, even for single @@ -484,9 +613,12 @@ function sendToRoomId(content, roomId) { } exports.sendToRoomId = sendToRoomId; /** - * Prepare and send string/s to specified room name (or ID). - * @param content Accepts message text string or array of strings. - * @param room A name (or ID) to resolve as ID to use in send. + * @memberof module:driver + * @instance + * @description Prepare and send string(s) to the specified room name (or ID). + * @param {string|string[]} content - message text string or array of strings + * @param {string} room - name or ID of the target room to use in send + * @returns {Promise} */ function sendToRoom(content, room) { return getRoomId(room) @@ -494,9 +626,13 @@ function sendToRoom(content, room) { } exports.sendToRoom = sendToRoom; /** - * Prepare and send string/s to a user in a DM. - * @param content Accepts message text string or array of strings. - * @param username Name to create (or get) DM for room ID to use in send. + * @memberof module:driver + * @instance + * @description Prepare and send string(s) to a user in a DM + * @param {string|string[]} content - message text string or array of strings + * @param {string} username - name or ID of the target room to use in send. + * Creates DM room if it does not exist + * @returns {Promise} */ function sendDirectToUser(content, username) { return getDirectMessageRoomId(username) @@ -504,17 +640,24 @@ function sendDirectToUser(content, username) { } exports.sendDirectToUser = sendDirectToUser; /** - * Edit an existing message, replacing any attributes with those provided. + * @memberof module:driver + * @instance + * @description Edit an existing message, replacing any attributes with the provided ones + * @param {Object} message - structured message object. * The given message object should have the ID of an existing message. + * @returns {Object} */ function editMessage(message) { return asyncCall('updateMessage', message); } exports.editMessage = editMessage; /** - * Send a reaction to an existing message. Simple proxy for method call. - * @param emoji Accepts string like `:thumbsup:` to add 👍 reaction - * @param messageId ID for a previously sent message + * @memberof module:driver + * @instance + * @description Send a reaction to an existing message. Simple proxy for method call. + * @param {string} emoji - reaction emoji to add. For example, `:thumbsup:` to add 👍. + * @param {string} messageId - ID of the previously sent message + * @returns {Object} */ function setReaction(emoji, messageId) { return asyncCall('setReaction', [emoji, messageId]); diff --git a/docma.json b/docma.json new file mode 100644 index 0000000..6a31a99 --- /dev/null +++ b/docma.json @@ -0,0 +1,79 @@ +{ + "src": [{ + "driver": [ + "dist/index.js", + "dist/lib/driver.js" + ] + }, + { + "guide": "README.md" + }], + "dest": "docs/", + "clean": true, + "jsdoc": { + "encoding": "utf8", + "recurse": false, + "pedantic": false, + "access": null, + "package": null, + "module": true, + "undocumented": false, + "undescribed": false, + "ignored": false, + "hierarchy": true, + "sort": "alphabetic", + "relativePath": null, + "filter": null, + "allowUnknownTags": true, + "plugins": ["plugins/markdown"] + }, + "app": { + "title": "Rocket.Chat.js.SDK", + "routing": "path", + "entrance": "content:guide", + "favicon": "favicon.ico" + }, + "template": { + "options": { + "title": { + "label": "Rocket.Chat.js.SDK", + "href": "/guide" + }, + "symbols": { + "autoLink": true, + "params": "table", + "enums": "list", + "props": "list", + "meta": false + }, + "navbar": { + "menu": [ + { + "iconClass": "fas fa-puzzle-piece", + "label": "Driver Methods", + "href": "/api/driver" + }, + { + "iconClass": "fas fa-book", + "label": "SDK Guide", + "href": "/guide" + }, + { + "iconClass": "fab fa-rocketchat", + "label": "", + "href": "https://github.com/RocketChat/Rocket.Chat.js.SDK", + "target": "_blank" + } + ] + }, + "sidebar": { + "enabled": true, + "outline": "flat" + }, + "logo": { + "dark": "https://upload.wikimedia.org/wikipedia/commons/5/55/RocketChat_Logo_1024x1024.png", + "light": "https://upload.wikimedia.org/wikipedia/commons/5/55/RocketChat_Logo_1024x1024.png" + } + } + } +} \ No newline at end of file diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8b0899b1bf6c4ac4b7e8303bf403025652fcb7a6 GIT binary patch literal 15086 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