diff --git a/.travis.yml b/.travis.yml index ec786d8..e3efadb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: node_js node_js: + - "6" - "5" - "4" - - "0.12.12" + - "0.12" after_success: - bash <(curl -s https://codecov.io/bash) cache: diff --git a/README.md b/README.md index 3b3262c..1ea1f16 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,16 @@ TelegramBot * [TelegramBot](#TelegramBot) * [new TelegramBot(token, [options])](#new_TelegramBot_new) + * [.initPolling()](#TelegramBot+initPolling) * [.stopPolling()](#TelegramBot+stopPolling) ⇒ Promise + * [.isPolling()](#TelegramBot+isPolling) ⇒ Boolean + * [.openWebHook()](#TelegramBot+openWebHook) + * [.closeWebHook()](#TelegramBot+closeWebHook) ⇒ Promise + * [.hasOpenWebHook()](#TelegramBot+hasOpenWebHook) ⇒ Boolean * [.getMe()](#TelegramBot+getMe) ⇒ Promise * [.setWebHook(url, [cert])](#TelegramBot+setWebHook) * [.getUpdates([timeout], [limit], [offset])](#TelegramBot+getUpdates) ⇒ Promise + * [.processUpdate(update)](#TelegramBot+processUpdate) * [.sendMessage(chatId, text, [options])](#TelegramBot+sendMessage) ⇒ Promise * [.answerInlineQuery(inlineQueryId, results, [options])](#TelegramBot+answerInlineQuery) ⇒ Promise * [.forwardMessage(chatId, fromChatId, messageId)](#TelegramBot+forwardMessage) ⇒ Promise @@ -122,14 +128,24 @@ Emits `message` when a message arrives. | token | String | | Bot Token | | [options] | Object | | | | [options.polling] | Boolean | Object | false | Set true to enable polling or set options | -| [options.polling.timeout] | String | Number | 10 | Polling time in seconds | -| [options.polling.interval] | String | Number | 2000 | Interval between requests in miliseconds | +| [options.polling.timeout] | String | Number | 10 | Timeout in seconds for long polling | +| [options.polling.interval] | String | Number | 300 | Interval between requests in miliseconds | +| [options.polling.autoStart] | Boolean | true | Start polling immediately | | [options.webHook] | Boolean | Object | false | Set true to enable WebHook or set options | -| [options.webHook.key] | String | | PEM private key to webHook server. | -| [options.webHook.cert] | String | | PEM certificate (public) to webHook server. | +| [options.webHook.port] | Number | 8443 | Port to bind to | +| [options.webHook.key] | String | | Path to file with PEM private key for webHook server. (Read synchronously!) | +| [options.webHook.cert] | String | | Path to file with PEM certificate (public) for webHook server. (Read synchronously!) | +| [options.webHook.autoOpen] | Boolean | true | Open webHook immediately | | [options.onlyFirstMatch] | Boolean | false | Set to true to stop after first match. Otherwise, all regexps are executed | | [options.request] | Object | | Options which will be added for all requests to telegram api. See https://github.com/request/request#requestoptions-callback for more information. | +| [options.baseApiUrl] | String | https://api.telegram.org | API Base URl; useful for proxying and testing | + + +### telegramBot.initPolling() +Start polling + +**Kind**: instance method of [TelegramBot](#TelegramBot) ### telegramBot.stopPolling() ⇒ Promise @@ -137,6 +153,32 @@ Stops polling after the last polling request resolves **Kind**: instance method of [TelegramBot](#TelegramBot) **Returns**: Promise - promise Promise, of last polling request + + +### telegramBot.isPolling() ⇒ Boolean +Return true if polling. Otherwise, false. + +**Kind**: instance method of [TelegramBot](#TelegramBot) + + +### telegramBot.openWebHook() +Open webhook + +**Kind**: instance method of [TelegramBot](#TelegramBot) + + +### telegramBot.closeWebHook() ⇒ Promise +Close webhook after closing all current connections + +**Kind**: instance method of [TelegramBot](#TelegramBot) +**Returns**: Promise - promise + + +### telegramBot.hasOpenWebHook() ⇒ Boolean +Return true if using webhook and it is open i.e. accepts connections. +Otherwise, false. + +**Kind**: instance method of [TelegramBot](#TelegramBot) ### telegramBot.getMe() ⇒ Promise @@ -172,6 +214,20 @@ Use this method to receive incoming updates using long polling | [limit] | Number | String | Limits the number of updates to be retrieved. | | [offset] | Number | String | Identifier of the first update to be returned. | + + +### telegramBot.processUpdate(update) +Process an update; emitting the proper events and executing regexp +callbacks. This method is useful should you be using a different +way to fetch updates, other than those provided by TelegramBot. + +**Kind**: instance method of [TelegramBot](#TelegramBot) +**See**: https://core.telegram.org/bots/api#update + +| Param | Type | +| --- | --- | +| update | Object | + ### telegramBot.sendMessage(chatId, text, [options]) ⇒ Promise diff --git a/package.json b/package.json index c314626..8a10d0a 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "bot" ], "scripts": { - "prepublish": "babel -d ./lib src", - "test": "istanbul cover ./node_modules/mocha/bin/_mocha -- -R spec --timeout 10000", + "build": "babel -d ./lib src", + "prepublish": "npm run build", + "test": "istanbul cover ./node_modules/mocha/bin/_mocha", "prepublish:test": "npm run prepublish && npm run test", "gen-doc": "jsdoc2md --src src/telegram.js -t README.hbs > README.md", - "eslint": "eslint ./src" + "eslint": "eslint ./src ./test", + "pretest": "npm run eslint && npm run build" }, "author": "Yago Pérez ", "license": "MIT", @@ -53,7 +55,8 @@ "istanbul": "^1.1.0-alpha.1", "jsdoc-to-markdown": "^1.3.3", "mocha": "^2.4.5", - "mocha-lcov-reporter": "^1.2.0" + "mocha-lcov-reporter": "^1.2.0", + "node-static": "^0.7.9" }, "repository": { "type": "git", diff --git a/src/telegram.js b/src/telegram.js index 1f65f0c..8d9be09 100644 --- a/src/telegram.js +++ b/src/telegram.js @@ -20,6 +20,11 @@ const _messageTypes = [ 'new_chat_photo', 'delete_chat_photo', 'group_chat_created' ]; +// enable cancellation +Promise.config({ + cancellation: true, +}); + class TelegramBot extends EventEmitter { static get messageTypes() { @@ -36,149 +41,75 @@ class TelegramBot extends EventEmitter { * @param {String} token Bot Token * @param {Object} [options] * @param {Boolean|Object} [options.polling=false] Set true to enable polling or set options - * @param {String|Number} [options.polling.timeout=10] Polling time in seconds - * @param {String|Number} [options.polling.interval=2000] Interval between requests in miliseconds + * @param {String|Number} [options.polling.timeout=10] Timeout in seconds for long polling + * @param {String|Number} [options.polling.interval=300] Interval between requests in miliseconds + * @param {Boolean} [options.polling.autoStart=true] Start polling immediately * @param {Boolean|Object} [options.webHook=false] Set true to enable WebHook or set options - * @param {String} [options.webHook.key] PEM private key to webHook server. - * @param {String} [options.webHook.cert] PEM certificate (public) to webHook server. + * @param {Number} [options.webHook.port=8443] Port to bind to + * @param {String} [options.webHook.key] Path to file with PEM private key for webHook server. (Read synchronously!) + * @param {String} [options.webHook.cert] Path to file with PEM certificate (public) for webHook server. (Read synchronously!) + * @param {Boolean} [options.webHook.autoOpen=true] Open webHook immediately * @param {Boolean} [options.onlyFirstMatch=false] Set to true to stop after first match. Otherwise, all regexps are executed * @param {Object} [options.request] Options which will be added for all requests to telegram api. * See https://github.com/request/request#requestoptions-callback for more information. + * @param {String} [options.baseApiUrl=https://api.telegram.org] API Base URl; useful for proxying and testing * @see https://core.telegram.org/bots/api */ constructor(token, options = {}) { super(); - this.options = options; this.token = token; - this.textRegexpCallbacks = []; - this.onReplyToMessages = []; + this.options = options; + this.options.baseApiUrl = options.baseApiUrl || 'https://api.telegram.org'; + this._textRegexpCallbacks = []; + this._onReplyToMessages = []; if (options.polling) { - this.initPolling(); + const autoStart = options.polling.autoStart; + if (typeof autoStart === 'undefined' || autoStart === true) { + this.initPolling(); + } } if (options.webHook) { - this._WebHook = new TelegramBotWebHook(token, options.webHook, this.processUpdate.bind(this)); + const autoOpen = options.webHook.autoOpen; + if (typeof autoOpen === 'undefined' || autoOpen === true) { + this.openWebHook(); + } } } - initPolling() { - if (this._polling) { - this._polling.abort = true; - this._polling.lastRequest.cancel('Polling restart'); - } - this._polling = new TelegramBotPolling(this.token, this.options.polling, this.processUpdate.bind(this)); - } - /** - * Stops polling after the last polling request resolves - * - * @return {Promise} promise Promise, of last polling request + * Generates url with bot token and provided path/method you want to be got/executed by bot + * @param {String} path + * @return {String} url + * @private + * @see https://core.telegram.org/bots/api#making-requests */ - stopPolling() { - if (this._polling) { - return this._polling.stopPolling(); - } - return Promise.resolve(); - } - - processUpdate(update) { - debug('Process Update %j', update); - const message = update.message; - const editedMessage = update.edited_message; - const channelPost = update.channel_post; - const editedChannelPost = update.edited_channel_post; - const inlineQuery = update.inline_query; - const chosenInlineResult = update.chosen_inline_result; - const callbackQuery = update.callback_query; - - if (message) { - debug('Process Update message %j', message); - this.emit('message', message); - const processMessageType = messageType => { - if (message[messageType]) { - debug('Emtting %s: %j', messageType, message); - this.emit(messageType, message); - } - }; - TelegramBot.messageTypes.forEach(processMessageType); - if (message.text) { - debug('Text message'); - this.textRegexpCallbacks.some(reg => { - debug('Matching %s with %s', message.text, reg.regexp); - const result = reg.regexp.exec(message.text); - if (result) { - debug('Matches %s', reg.regexp); - reg.callback(message, result); - // returning truthy value exits .some - return this.options.onlyFirstMatch; - } - }); - } - if (message.reply_to_message) { - // Only callbacks waiting for this message - this.onReplyToMessages.forEach(reply => { - // Message from the same chat - if (reply.chatId === message.chat.id) { - // Responding to that message - if (reply.messageId === message.reply_to_message.message_id) { - // Resolve the promise - reply.callback(message); - } - } - }); - } - } else if (editedMessage) { - debug('Process Update edited_message %j', editedMessage); - this.emit('edited_message', editedMessage); - if (editedMessage.text) { - this.emit('edited_message_text', editedMessage); - } - if (editedMessage.caption) { - this.emit('edited_message_caption', editedMessage); - } - } else if (channelPost) { - debug('Process Update channel_post %j', channelPost); - this.emit('channel_post', channelPost); - } else if (editedChannelPost) { - debug('Process Update edited_channel_post %j', editedChannelPost); - this.emit('edited_channel_post', editedChannelPost); - if (editedChannelPost.text) { - this.emit('edited_channel_post_text', editedChannelPost); - } - if (editedChannelPost.caption) { - this.emit('edited_channel_post_caption', editedChannelPost); - } - } else if (inlineQuery) { - debug('Process Update inline_query %j', inlineQuery); - this.emit('inline_query', inlineQuery); - } else if (chosenInlineResult) { - debug('Process Update chosen_inline_result %j', chosenInlineResult); - this.emit('chosen_inline_result', chosenInlineResult); - } else if (callbackQuery) { - debug('Process Update callback_query %j', callbackQuery); - this.emit('callback_query', callbackQuery); - } - } - - // used so that other funcs are not non-optimizable - _safeParse(json) { - try { - return JSON.parse(json); - } catch (err) { - throw new Error(`Error parsing Telegram response: ${String(json)}`); - } + _buildURL(_path) { + return `${this.options.baseApiUrl}/bot${this.token}/${_path}`; } + /** + * Fix 'reply_markup' parameter by making it JSON-serialized, as + * required by the Telegram Bot API + * @param {Object} obj Object; either 'form' or 'qs' + * @private + * @see https://core.telegram.org/bots/api#sendmessage + */ _fixReplyMarkup(obj) { const replyMarkup = obj.reply_markup; if (replyMarkup && typeof replyMarkup !== 'string') { - // reply_markup must be passed as JSON stringified to Telegram obj.reply_markup = JSON.stringify(replyMarkup); } } - // request-promise + /** + * Make request against the API + * @param {String} _path API endpoint + * @param {Object} [options] + * @private + * @return {Promise} + */ _request(_path, options = {}) { if (!this.token) { throw new Error('Telegram Bot Token not provided!'); @@ -194,6 +125,7 @@ class TelegramBot extends EventEmitter { if (options.qs) { this._fixReplyMarkup(options.qs); } + options.url = this._buildURL(_path); options.simple = false; options.resolveWithFullResponse = true; @@ -202,31 +134,148 @@ class TelegramBot extends EventEmitter { return request(options) .then(resp => { if (resp.statusCode !== 200) { - throw new Error(`${resp.statusCode} ${resp.body}`); + const error = new Error(`${resp.statusCode} ${resp.body}`); + error.response = resp; + throw error; + } + + let data; + + try { + data = JSON.parse(resp.body); + } catch (err) { + const error = new Error(`Error parsing Telegram response: ${resp.body}`); + error.response = resp; + throw error; } - const data = this._safeParse(resp.body); if (data.ok) { return data.result; } - throw new Error(`${data.error_code} ${data.description}`); + const error = new Error(`${data.error_code} ${data.description}`); + error.response = resp; + error.response.body = data; + throw error; }); } /** - * Generates url with bot token and provided path/method you want to be got/executed by bot - * @return {String} url - * @param {String} path + * Format data to be uploaded; handles file paths, streams and buffers + * @param {String} type + * @param {String|stream.Stream|Buffer} data + * @return {Array} formatted + * @return {Object} formatted[0] formData + * @return {String} formatted[1] fileId * @private - * @see https://core.telegram.org/bots/api#making-requests */ - _buildURL(_path) { - return URL.format({ - protocol: 'https', - host: 'api.telegram.org', - pathname: `/bot${this.token}/${_path}` - }); + _formatSendData(type, data) { + let formData; + let fileName; + let fileId; + if (data instanceof stream.Stream) { + fileName = URL.parse(path.basename(data.path.toString())).pathname; + formData = {}; + formData[type] = { + value: data, + options: { + filename: qs.unescape(fileName), + contentType: mime.lookup(fileName) + } + }; + } else if (Buffer.isBuffer(data)) { + const filetype = fileType(data); + if (!filetype) { + throw new Error('Unsupported Buffer file type'); + } + formData = {}; + formData[type] = { + value: data, + options: { + filename: `data.${filetype.ext}`, + contentType: filetype.mime + } + }; + } else if (fs.existsSync(data)) { + fileName = path.basename(data); + formData = {}; + formData[type] = { + value: fs.createReadStream(data), + options: { + filename: fileName, + contentType: mime.lookup(fileName) + } + }; + } else { + fileId = data; + } + return [formData, fileId]; + } + + /** + * Start polling + */ + initPolling() { + if (this._polling) { + this._polling.stopPolling({ + cancel: true, + reason: 'Polling restart', + }); + } + this._polling = new TelegramBotPolling(this._request.bind(this), this.options.polling, this.processUpdate.bind(this)); + } + + /** + * Stops polling after the last polling request resolves + * @return {Promise} promise Promise, of last polling request + */ + stopPolling() { + if (!this._polling) { + return Promise.resolve(); + } + const polling = this._polling; + delete this._polling; + return polling.stopPolling(); + } + + /** + * Return true if polling. Otherwise, false. + * @return {Boolean} + */ + isPolling() { + return !!this._polling; + } + + /** + * Open webhook + */ + openWebHook() { + if (this._webHook) { + return; + } + this._webHook = new TelegramBotWebHook(this.token, this.options.webHook, this.processUpdate.bind(this)); + } + + /** + * Close webhook after closing all current connections + * @return {Promise} promise + */ + closeWebHook() { + if (!this._webHook) { + return Promise.resolve(); + } + const webHook = this._webHook; + delete this._webHook; + return webHook.close(); + } + + /** + * Return true if using webhook and it is open i.e. accepts connections. + * Otherwise, false. + * @return {Boolean} + */ + hasOpenWebHook() { + return !!this._webHook; } /** @@ -284,6 +333,93 @@ class TelegramBot extends EventEmitter { return this._request('getUpdates', { form }); } + /** + * Process an update; emitting the proper events and executing regexp + * callbacks. This method is useful should you be using a different + * way to fetch updates, other than those provided by TelegramBot. + * @param {Object} update + * @see https://core.telegram.org/bots/api#update + */ + processUpdate(update) { + debug('Process Update %j', update); + const message = update.message; + const editedMessage = update.edited_message; + const channelPost = update.channel_post; + const editedChannelPost = update.edited_channel_post; + const inlineQuery = update.inline_query; + const chosenInlineResult = update.chosen_inline_result; + const callbackQuery = update.callback_query; + + if (message) { + debug('Process Update message %j', message); + this.emit('message', message); + const processMessageType = messageType => { + if (message[messageType]) { + debug('Emitting %s: %j', messageType, message); + this.emit(messageType, message); + } + }; + TelegramBot.messageTypes.forEach(processMessageType); + if (message.text) { + debug('Text message'); + this._textRegexpCallbacks.some(reg => { + debug('Matching %s with %s', message.text, reg.regexp); + const result = reg.regexp.exec(message.text); + if (!result) { + return false; + } + debug('Matches %s', reg.regexp); + reg.callback(message, result); + // returning truthy value exits .some + return this.options.onlyFirstMatch; + }); + } + if (message.reply_to_message) { + // Only callbacks waiting for this message + this._onReplyToMessages.forEach(reply => { + // Message from the same chat + if (reply.chatId === message.chat.id) { + // Responding to that message + if (reply.messageId === message.reply_to_message.message_id) { + // Resolve the promise + reply.callback(message); + } + } + }); + } + } else if (editedMessage) { + debug('Process Update edited_message %j', editedMessage); + this.emit('edited_message', editedMessage); + if (editedMessage.text) { + this.emit('edited_message_text', editedMessage); + } + if (editedMessage.caption) { + this.emit('edited_message_caption', editedMessage); + } + } else if (channelPost) { + debug('Process Update channel_post %j', channelPost); + this.emit('channel_post', channelPost); + } else if (editedChannelPost) { + debug('Process Update edited_channel_post %j', editedChannelPost); + this.emit('edited_channel_post', editedChannelPost); + if (editedChannelPost.text) { + this.emit('edited_channel_post_text', editedChannelPost); + } + if (editedChannelPost.caption) { + this.emit('edited_channel_post_caption', editedChannelPost); + } + } else if (inlineQuery) { + debug('Process Update inline_query %j', inlineQuery); + this.emit('inline_query', inlineQuery); + } else if (chosenInlineResult) { + debug('Process Update chosen_inline_result %j', chosenInlineResult); + this.emit('chosen_inline_result', chosenInlineResult); + } else if (callbackQuery) { + debug('Process Update callback_query %j', callbackQuery); + this.emit('callback_query', callbackQuery); + } + } + /** * Send text message. * @param {Number|String} chatId Unique identifier for the message recipient @@ -330,49 +466,6 @@ class TelegramBot extends EventEmitter { return this._request('forwardMessage', { form }); } - _formatSendData(type, data) { - let formData; - let fileName; - let fileId; - if (data instanceof stream.Stream) { - fileName = URL.parse(path.basename(data.path.toString())).pathname; - formData = {}; - formData[type] = { - value: data, - options: { - filename: qs.unescape(fileName), - contentType: mime.lookup(fileName) - } - }; - } else if (Buffer.isBuffer(data)) { - const filetype = fileType(data); - if (!filetype) { - throw new Error('Unsupported Buffer file type'); - } - formData = {}; - formData[type] = { - value: data, - options: { - filename: `data.${filetype.ext}`, - contentType: filetype.mime - } - }; - } else if (fs.existsSync(data)) { - fileName = path.basename(data); - formData = {}; - formData[type] = { - value: fs.createReadStream(data), - options: { - filename: fileName, - contentType: mime.lookup(fileName) - } - }; - } else { - fileId = data; - } - return [formData, fileId]; - } - /** * Send photo * @param {Number|String} chatId Unique identifier for the message recipient @@ -737,11 +830,7 @@ class TelegramBot extends EventEmitter { */ getFileLink(fileId) { return this.getFile(fileId) - .then(resp => URL.format({ - protocol: 'https', - host: 'api.telegram.org', - pathname: `/file/bot${this.token}/${resp.file_path}` - })); + .then(resp => `${this.options.baseApiUrl}/file/bot${this.token}/${resp.file_path}`); } /** @@ -776,7 +865,7 @@ class TelegramBot extends EventEmitter { * the `msg` and the result of executing `regexp.exec` on message text. */ onText(regexp, callback) { - this.textRegexpCallbacks.push({ regexp, callback }); + this._textRegexpCallbacks.push({ regexp, callback }); } /** @@ -787,7 +876,7 @@ class TelegramBot extends EventEmitter { * message. */ onReplyToMessage(chatId, messageId, callback) { - this.onReplyToMessages.push({ + this._onReplyToMessages.push({ chatId, messageId, callback diff --git a/src/telegramPolling.js b/src/telegramPolling.js index fa0bd44..70b2516 100644 --- a/src/telegramPolling.js +++ b/src/telegramPolling.js @@ -1,48 +1,71 @@ -const Promise = require('bluebird'); const debug = require('debug')('node-telegram-bot-api'); -const request = require('request-promise'); -const URL = require('url'); const ANOTHER_WEB_HOOK_USED = 409; + class TelegramBotPolling { - - constructor(token, options = {}, callback) { - // enable cancellation - Promise.config({ - cancellation: true, - }); - + /** + * Handles polling against the Telegram servers. + * + * @param {Function} request Function used to make HTTP requests + * @param {Boolean|Object} options Polling options + * @param {Number} [options.timeout=10] Timeout in seconds for long polling + * @param {Number} [options.interval=300] Interval between requests in milliseconds + * @param {Function} callback Function for processing a new update + * @see https://core.telegram.org/bots/api#getupdates + */ + constructor(request, options = {}, callback) { + /* eslint-disable no-param-reassign */ if (typeof options === 'function') { - callback = options; // eslint-disable-line no-param-reassign - options = {}; // eslint-disable-line no-param-reassign + callback = options; + options = {}; + } else if (typeof options === 'boolean') { + options = {}; } + /* eslint-enable no-param-reassign */ - this.offset = 0; - this.token = token; + this.request = request; + this.options = options; + this.options.timeout = options.timeout || 10; + this.options.interval = (typeof options.interval === 'number') ? options.interval : 300; this.callback = callback; - this.timeout = options.timeout || 10; - this.interval = (typeof options.interval === 'number') ? options.interval : 300; - this.lastUpdate = 0; - this.lastRequest = null; - this.abort = false; + this._offset = 0; + this._lastUpdate = 0; + this._lastRequest = null; + this._abort = false; + this._pollingTimeout = null; this._polling(); } - stopPolling() { - this.abort = true; + /** + * Stop polling + * @param {Object} [options] + * @param {Boolean} [options.cancel] Cancel current request + * @param {String} [options.reason] Reason for stopping polling + */ + stopPolling(options = {}) { + this._abort = true; + clearTimeout(this._pollingTimeout); + if (options.cancel) { + const reason = options.reason || 'Polling stop'; + return this._lastRequest.cancel(reason); + } // wait until the last request is fulfilled - return this.lastRequest; + return this._lastRequest; } + /** + * Invokes polling (with recursion!) + * @private + */ _polling() { - this.lastRequest = this + this._lastRequest = this ._getUpdates() .then(updates => { - this.lastUpdate = Date.now(); + this._lastUpdate = Date.now(); debug('polling data %j', updates); updates.forEach(update => { - this.offset = update.update_id; - debug('updated offset: %s', this.offset); + this._offset = update.update_id; + debug('updated offset: %s', this._offset); this.callback(update); }); }) @@ -51,83 +74,46 @@ class TelegramBotPolling { throw err; }) .finally(() => { - if (this.abort) { + if (this._abort) { debug('Polling is aborted!'); } else { - debug('setTimeout for %s miliseconds', this.interval); - setTimeout(() => this._polling(), this.interval); + debug('setTimeout for %s miliseconds', this.options.interval); + this._pollingTimeout = setTimeout(() => this._polling(), this.options.interval); } }); } - // used so that other funcs are not non-optimizable - _safeParse(json) { - try { - return JSON.parse(json); - } catch (err) { - throw new Error(`Error parsing Telegram response: ${String(json)}`); - } - } - + /** + * Unset current webhook. Used when we detect that a webhook has been set + * and we are trying to poll. Polling and WebHook are mutually exclusive. + * @see https://core.telegram.org/bots/api#getting-updates + * @private + */ _unsetWebHook() { - return request({ - url: URL.format({ - protocol: 'https', - host: 'api.telegram.org', - pathname: `/bot${this.token}/setWebHook` - }), - simple: false, - resolveWithFullResponse: true - }) - .promise() - .then(resp => { - if (!resp) { - throw new Error(resp); - } - return []; - }); + return this.request('setWebHook'); } + /** + * Retrieve updates + */ _getUpdates() { const opts = { qs: { - offset: this.offset + 1, - limit: this.limit, - timeout: this.timeout + offset: this._offset + 1, + limit: this.options.limit, + timeout: this.options.timeout }, - url: URL.format({ - protocol: 'https', - host: 'api.telegram.org', - pathname: `/bot${this.token}/getUpdates` - }), - simple: false, - resolveWithFullResponse: true, - forever: true, }; debug('polling with options: %j', opts); - return request(opts) - .promise() - .timeout((10 + this.timeout) * 1000) - .then(resp => { - if (resp.statusCode === ANOTHER_WEB_HOOK_USED) { + return this.request('getUpdates', opts) + .catch(err => { + if (err.response.statusCode === ANOTHER_WEB_HOOK_USED) { return this._unsetWebHook(); } - - if (resp.statusCode !== 200) { - throw new Error(`${resp.statusCode} ${resp.body}`); - } - - const data = this._safeParse(resp.body); - - if (data.ok) { - return data.result; - } - - throw new Error(`${data.error_code} ${data.description}`); + throw err; }); } - } module.exports = TelegramBotPolling; diff --git a/src/telegramWebHook.js b/src/telegramWebHook.js index 9239481..87feed6 100644 --- a/src/telegramWebHook.js +++ b/src/telegramWebHook.js @@ -3,19 +3,30 @@ const https = require('https'); const http = require('http'); const fs = require('fs'); const bl = require('bl'); +const Promise = require('bluebird'); + class TelegramBotWebHook { - + /** + * Sets up a webhook to receive updates + * + * @param {String} token Telegram API token + * @param {Boolean|Object} options WebHook options + * @param {Number} [options.port=8443] Port to bind to + * @param {Function} callback Function for process a new update + */ constructor(token, options, callback) { - this.token = token; - this.callback = callback; - this.regex = new RegExp(this.token); - // define opts if (typeof options === 'boolean') { options = {}; // eslint-disable-line no-param-reassign } - options.port = options.port || 8443; + + this.token = token; + this.options = options; + this.options.port = options.port || 8443; + this.callback = callback; + this._regex = new RegExp(this.token); + this._webServer = null; if (options.key && options.cert) { // HTTPS Server debug('HTTPS WebHook enabled'); @@ -44,7 +55,10 @@ class TelegramBotWebHook { } } - // pipe+parse body + /** + * Handle request body by passing it to 'callback' + * @private + */ _parseBody = (err, body) => { if (err) { return debug(err); @@ -58,13 +72,18 @@ class TelegramBotWebHook { return null; } - // bound req listener + /** + * Listener for 'request' event on server + * @private + * @see https://nodejs.org/docs/latest/api/http.html#http_http_createserver_requestlistener + * @see https://nodejs.org/docs/latest/api/https.html#https_https_createserver_options_requestlistener + */ _requestListener = (req, res) => { debug('WebHook request URL: %s', req.url); debug('WebHook request headers: %j', req.headers); // If there isn't token on URL - if (!this.regex.test(req.url)) { + if (!this._regex.test(req.url)) { debug('WebHook request unauthorized'); res.statusCode = 401; res.end(); @@ -80,6 +99,19 @@ class TelegramBotWebHook { } } + /** + * Close the webHook + * @return {Promise} + */ + close() { + const self = this; + return new Promise(function closePromise(resolve, reject) { + self._webServer.close(function closeCb(error) { + if (error) return reject(error); + return resolve(); + }); + }); + } } module.exports = TelegramBotWebHook; diff --git a/test/README.md b/test/README.md index 4e8f583..72321d3 100644 --- a/test/README.md +++ b/test/README.md @@ -1,10 +1,18 @@ Running the tests: ```bash +# Token to be used export TEST_TELEGRAM_TOKEN= + # User Id which you want to send the messages. export TEST_USER_ID= + # Group Id which to use in some of the tests, e.g. for TelegramBot#getChat() export TEST_GROUP_ID= + +# Game short name which to use in some of the tests, e.g. TelegramBot#sendGame() +export TEST_GAME_SHORT_NAME= + +# Run tests npm run test ``` diff --git a/test/data/audio.mp3 b/test/data/audio.mp3 new file mode 100644 index 0000000..11cd785 Binary files /dev/null and b/test/data/audio.mp3 differ diff --git a/test/bot.gif b/test/data/photo.gif similarity index 100% rename from test/bot.gif rename to test/data/photo.gif diff --git a/test/sticker.webp b/test/data/sticker.webp similarity index 100% rename from test/sticker.webp rename to test/data/sticker.webp diff --git a/test/data/video.mp4 b/test/data/video.mp4 new file mode 100644 index 0000000..8b2dbd8 Binary files /dev/null and b/test/data/video.mp4 differ diff --git a/test/data/voice.ogg b/test/data/voice.ogg new file mode 100644 index 0000000..0d7f43e Binary files /dev/null and b/test/data/voice.ogg differ diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 7f189aa..0000000 --- a/test/index.js +++ /dev/null @@ -1,673 +0,0 @@ -const TelegramPolling = require('../lib/telegramPolling'); -const Telegram = require('../lib/telegram'); -const Promise = require('bluebird'); -const request = require('request-promise'); -const assert = require('assert'); -const fs = require('fs'); -const is = require('is'); - -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; -const TOKEN = process.env.TEST_TELEGRAM_TOKEN; -if (!TOKEN) { - throw new Error('Bot token not provided'); -} - -// Telegram service if not User Id -const USERID = process.env.TEST_USER_ID || 777000; -const GROUPID = process.env.TEST_GROUP_ID || -1001075450562; - -describe('Telegram', function telegramSuite() { - describe('#setWebHook', function setWebHookSuite() { - it('should set a webHook', function test() { - const bot = new Telegram(TOKEN); - // Google IP ¯\_(ツ)_/¯ - return bot - .setWebHook('216.58.210.174') - .then(resp => { - assert.equal(resp, true); - }); - }); - - it('should set a webHook with certificate', function test() { - const bot = new Telegram(TOKEN); - const cert = `${__dirname}/../examples/crt.pem`; - return bot - .setWebHook('216.58.210.174', cert) - .then(resp => { - assert.equal(resp, true); - }); - }); - - it('should delete the webHook', function test() { - const bot = new Telegram(TOKEN); - return bot - .setWebHook('') - .then(resp => { - assert.equal(resp, true); - }); - }); - }); - - describe('#WebHook', function WebHookSuite() { - it('should reject request if same token not provided', function test() { - const bot = new Telegram(TOKEN, { webHook: true }); - - return request({ - url: 'http://localhost:8443/NOT_REAL_TOKEN', - method: 'POST', - simple: false, - resolveWithFullResponse: true - }).then(response => { - assert.notEqual(response.statusCode, 200); - bot._WebHook._webServer.close(); - }); - }); - - it('should reject request if authorized but not a POST', function test() { - const bot = new Telegram(TOKEN, { webHook: true }); - return request({ - url: `http://localhost:8443/bot${TOKEN}`, - method: 'GET', - simple: false, - resolveWithFullResponse: true - }) - .then(response => { - assert.notEqual(response.statusCode, 200); - bot._WebHook._webServer.close(); - }); - }); - - it('should emit a `message` on HTTP WebHook', function test(done) { - const bot = new Telegram(TOKEN, { webHook: true }); - bot.on('message', () => { - bot._WebHook._webServer.close(); - done(); - }); - - const url = `http://localhost:8443/bot${TOKEN}`; - request({ - url, - method: 'POST', - json: true, - headers: { - 'content-type': 'application/json', - }, - body: { update_id: 0, message: { text: 'test' } } - }); - }); - - it('should emit a `message` on HTTPS WebHook', function test(done) { - const opts = { - webHook: { - port: 8443, - key: `${__dirname}/../examples/key.pem`, - cert: `${__dirname}/../examples/crt.pem` - } - }; - const bot = new Telegram(TOKEN, opts); - bot.on('message', () => { - bot._WebHook._webServer.close(); - done(); - }); - const url = `https://localhost:8443/bot${TOKEN}`; - request({ - url, - method: 'POST', - json: true, - headers: { - 'content-type': 'application/json', - }, - rejectUnhauthorized: false, - body: { update_id: 0, message: { text: 'test' } } - }); - }); - }); - - describe('#getMe', function getMeSuite() { - it('should return an User object', function test() { - const bot = new Telegram(TOKEN); - return bot.getMe().then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#getChat', function getChatSuite() { - it('should return a Chat object', function test() { - const bot = new Telegram(TOKEN); - return bot.getChat(USERID).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#getChatAdministrators', function getChatAdministratorsSuite() { - it('should return an Array', function test() { - const bot = new Telegram(TOKEN); - return bot.getChatAdministrators(GROUPID).then(resp => { - assert.ok(Array.isArray(resp)); - }); - }); - }); - - describe('#getChatMembersCount', function getChatMembersCountSuite() { - it('should return an Integer', function test() { - const bot = new Telegram(TOKEN); - return bot.getChatMembersCount(GROUPID).then(resp => { - assert.ok(Number.isInteger(resp)); - }); - }); - }); - - describe('#getChatMember', function getChatMemberSuite() { - it('should return a ChatMember', function test() { - const bot = new Telegram(TOKEN); - return bot.getChatMember(GROUPID, USERID).then(resp => { - assert.ok(is.object(resp.user)); - assert.ok(is.string(resp.status)); - }); - }); - }); - - describe('#getUpdates', function getUpdatesSuite() { - it('should return an Array', function test() { - const bot = new Telegram(TOKEN); - return bot.getUpdates().then(resp => { - assert.equal(Array.isArray(resp), true); - }); - }); - }); - - describe('#sendMessage', function sendMessageSuite() { - it('should send a message', function test() { - const bot = new Telegram(TOKEN); - return bot.sendMessage(USERID, 'test').then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#forwardMessage', function forwardMessageSuite() { - it('should forward a message', function test() { - const bot = new Telegram(TOKEN); - return bot.sendMessage(USERID, 'test').then(resp => { - const messageId = resp.message_id; - return bot.forwardMessage(USERID, USERID, messageId) - .then(forwarded => { - assert.ok(is.object(forwarded)); - }); - }); - }); - }); - - describe('#_formatSendData', function _formatSendData() { - it('should handle buffer path from fs.readStream', function test() { - const bot = new Telegram(TOKEN); - let photo; - try { - photo = fs.createReadStream(Buffer.from(`${__dirname}/bot.gif`)); - } catch(ex) { - // Older Node.js versions do not support passing a Buffer - // representation of the path to fs.createReadStream() - if (ex instanceof TypeError) return; - } - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendPhoto', function sendPhotoSuite() { - let photoId; - it('should send a photo from file', function test() { - const bot = new Telegram(TOKEN); - const photo = `${__dirname}/bot.gif`; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - photoId = resp.photo[0].file_id; - }); - }); - - it('should send a photo from id', function test() { - const bot = new Telegram(TOKEN); - // Send the same photo as before - const photo = photoId; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a photo from fs.readStream', function test() { - const bot = new Telegram(TOKEN); - const photo = fs.createReadStream(`${__dirname}/bot.gif`); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a photo from request Stream', function test() { - const bot = new Telegram(TOKEN); - const photo = request('https://telegram.org/img/t_logo.png'); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a photo from a Buffer', function test() { - const bot = new Telegram(TOKEN); - const photo = fs.readFileSync(`${__dirname}/bot.gif`); - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a photo along with reply_markup', function test() { - const bot = new Telegram(TOKEN); - const photo = fs.readFileSync(`${__dirname}/bot.gif`); - return bot.sendPhoto(USERID, photo, { - reply_markup: { - hide_keyboard: true - } - }).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendChatAction', function sendChatActionSuite() { - it('should send a chat action', function test() { - const bot = new Telegram(TOKEN); - const action = 'typing'; - return bot.sendChatAction(USERID, action).then(resp => { - assert.equal(resp, true); - }); - }); - }); - - describe('#editMessageText', function editMessageTextSuite() { - it('should edit a message sent by the bot', function test() { - const bot = new Telegram(TOKEN); - return bot.sendMessage(USERID, 'test').then(resp => { - assert.equal(resp.text, 'test'); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageText('edit test', opts).then(msg => { - assert.equal(msg.text, 'edit test'); - }); - }); - }); - }); - - describe('#editMessageCaption', function editMessageCaptionSuite() { - it('should edit a caption sent by the bot', function test() { - const bot = new Telegram(TOKEN); - const photo = `${__dirname}/bot.gif`; - const options = { caption: 'test caption' }; - return bot.sendPhoto(USERID, photo, options).then(resp => { - assert.equal(resp.caption, 'test caption'); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageCaption('new test caption', opts).then(msg => { - assert.equal(msg.caption, 'new test caption'); - }); - }); - }); - }); - - describe('#editMessageReplyMarkup', function editMessageReplyMarkupSuite() { - it('should edit previously-set reply markup', function test() { - const bot = new Telegram(TOKEN); - return bot.sendMessage(USERID, 'test').then(resp => { - const replyMarkup = JSON.stringify({ - inline_keyboard: [[{ - text: 'Test button', - callback_data: 'test' - }]] - }); - const opts = { - chat_id: USERID, - message_id: resp.message_id - }; - return bot.editMessageReplyMarkup(replyMarkup, opts).then(msg => { - // Keyboard markup is not returned, do a simple object check - assert.ok(is.object(msg)); - }); - }); - }); - }); - - describe('#sendAudio', function sendAudioSuite() { - it('should send an OGG audio', function test() { - const bot = new Telegram(TOKEN); - const audio = request('https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg'); - return bot.sendAudio(USERID, audio).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendDocument', function sendDocumentSuite() { - let documentId; - it('should send a document from file', function test() { - const bot = new Telegram(TOKEN); - const document = `${__dirname}/bot.gif`; - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - documentId = resp.document.file_id; - }); - }); - - it('should send a document from id', function test() { - const bot = new Telegram(TOKEN); - // Send the same photo as before - const document = documentId; - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a document from fs.readStream', function test() { - const bot = new Telegram(TOKEN); - const document = fs.createReadStream(`${__dirname}/bot.gif`); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a document from request Stream', function test() { - const bot = new Telegram(TOKEN); - const document = request('https://telegram.org/img/t_logo.png'); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a document from a Buffer', function test() { - const bot = new Telegram(TOKEN); - const document = fs.readFileSync(`${__dirname}/bot.gif`); - return bot.sendDocument(USERID, document).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendSticker', function sendStickerSuite() { - let stickerId; - it('should send a sticker from file', function test() { - const bot = new Telegram(TOKEN); - const sticker = `${__dirname}/sticker.webp`; - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - stickerId = resp.sticker.file_id; - }); - }); - - it('should send a sticker from id', function test() { - const bot = new Telegram(TOKEN); - // Send the same photo as before - return bot.sendSticker(USERID, stickerId).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a sticker from fs.readStream', function test() { - const bot = new Telegram(TOKEN); - const sticker = fs.createReadStream(`${__dirname}/sticker.webp`); - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a sticker from request Stream', function test() { - const bot = new Telegram(TOKEN); - const sticker = request('https://www.gstatic.com/webp/gallery3/1_webp_ll.webp'); - return bot.sendSticker(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a sticker from a Buffer', function test() { - const bot = new Telegram(TOKEN); - const sticker = fs.readFileSync(`${__dirname}/sticker.webp`); - return bot.sendDocument(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendVideo', function sendVideoSuite() { - let videoId; - it('should send a video from file', function test() { - const bot = new Telegram(TOKEN); - const video = `${__dirname}/video.mp4`; - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - videoId = resp.video.file_id; - }); - }); - - it('should send a video from id', function test() { - const bot = new Telegram(TOKEN); - // Send the same photo as before - return bot.sendVideo(USERID, videoId).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a video from fs.readStream', function test() { - const bot = new Telegram(TOKEN); - const video = fs.createReadStream(`${__dirname}/video.mp4`); - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a video from request Stream', function test() { - const bot = new Telegram(TOKEN); - const sticker = request('http://techslides.com/demos/sample-videos/small.mp4'); - return bot.sendVideo(USERID, sticker).then(resp => { - assert.ok(is.object(resp)); - }); - }); - - it('should send a video from a Buffer', function test() { - const bot = new Telegram(TOKEN); - const video = fs.readFileSync(`${__dirname}/video.mp4`); - return bot.sendVideo(USERID, video).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#sendVoice', function sendVoiceSuite() { - it('should send an OGG audio as voice', function test() { - const bot = new Telegram(TOKEN); - const voice = request('https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg'); - return bot.sendVoice(USERID, voice).then(resp => { - assert.ok(is.object(resp)); - }); - }); - }); - - describe('#getUserProfilePhotos', function getUserProfilePhotosSuite() { - it('should get user profile photos', function test() { - const bot = new Telegram(TOKEN); - return bot.getUserProfilePhotos(USERID).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.number(resp.total_count)); - assert.ok(is.array(resp.photos)); - }); - }); - }); - - describe('#sendLocation', function sendLocationSuite() { - it('should send a location', function test() { - const bot = new Telegram(TOKEN); - const lat = 47.5351072; - const long = -52.7508537; - return bot.sendLocation(USERID, lat, long).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.location)); - assert.ok(is.number(resp.location.latitude)); - assert.ok(is.number(resp.location.longitude)); - }); - }); - }); - - describe('#sendVenue', function sendVenueSuite() { - it('should send a venue', function test() { - const bot = new Telegram(TOKEN); - const lat = 47.5351072; - const long = -52.7508537; - const title = `The Village Shopping Centre`; - const address = `430 Topsail Rd,St. John's, NL A1E 4N1, Canada`; - return bot.sendVenue(USERID, lat, long, title, address).then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.object(resp.venue)); - assert.ok(is.object(resp.venue.location)); - assert.ok(is.number(resp.venue.location.latitude)); - assert.ok(is.number(resp.venue.location.longitude)); - assert.ok(is.string(resp.venue.title)); - assert.ok(is.string(resp.venue.address)); - }); - }); - }); - - describe('#getFile', function getFileSuite() { - let fileId; - - // To get a file we have to send any file first - it('should send a photo from file', function test() { - const bot = new Telegram(TOKEN); - const photo = `${__dirname}/bot.gif`; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - fileId = resp.photo[0].file_id; - }); - }); - - it('should get a file', function test() { - const bot = new Telegram(TOKEN); - - return bot - .getFile(fileId) - .then(resp => { - assert.ok(is.object(resp)); - assert.ok(is.string(resp.file_path)); - }); - }); - }); - - describe('#getFileLink', function getFileLinkSuite() { - let fileId; - - // To get a file we have to send any file first - it('should send a photo from file', function test() { - const bot = new Telegram(TOKEN); - const photo = `${__dirname}/bot.gif`; - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - fileId = resp.photo[0].file_id; - }); - }); - - it('should get a file link', function test() { - const bot = new Telegram(TOKEN); - - return bot - .getFileLink(fileId) - .then(fileURI => { - assert.ok(is.string(fileURI)); - assert.equal(fileURI.indexOf('https'), 0); - // TODO: validate URL with some library or regexp - }); - }); - }); - - describe('#downloadFile', function downloadFileSuite() { - const downloadPath = __dirname; - - it('should download a file', function test() { - const bot = new Telegram(TOKEN); - const photo = `${__dirname}/bot.gif`; - - // Send a file to get the ID - return bot.sendPhoto(USERID, photo).then(resp => { - assert.ok(is.object(resp)); - const fileId = resp.photo[0].file_id; - - return bot - .downloadFile(fileId, downloadPath) - .then(filePath => { - assert.ok(is.string(filePath)); - assert.ok(fs.existsSync(filePath)); - fs.unlinkSync(filePath); // Delete file after test - }); - }); - }); - }); - - it('should call `onText` callback on match', function test(done) { - const bot = new Telegram(TOKEN, { webHook: true }); - bot.onText(/\/echo (.+)/, (msg, match) => { - bot._WebHook._webServer.close(); - assert.equal(match[1], 'ECHO ALOHA'); - done(); - }); - const url = `http://localhost:8443/bot${TOKEN}`; - request({ - url, - method: 'POST', - json: true, - headers: { - 'content-type': 'application/json', - }, - body: { update_id: 0, message: { text: '/echo ECHO ALOHA' } } - }); - }); -}); // End Telegram - -describe('#TelegramBotPolling', function TelegramBotPollingSuite() { - - it('should call the callback on polling', function test(done) { - const opts = { interval: 100, timeout: 1 }; - const polling = new TelegramPolling(TOKEN, opts, (msg) => { - if (msg.update_id === 10) { - polling.stopPolling().then(() => { - done(); - }); - } - }); - // The second time _getUpdates is called it will return a message - // Really dirty but it works - polling._getUpdates = () => { - return new Promise.resolve([{ update_id: 10, message: {} }]); - }; - }); - - describe('#stopPolling', function stopPollingSuite() { - it('should stop polling after last poll request', function test(done) { - const opts = { interval: 200, timeout: 0.5 }; - const polling = new TelegramPolling(TOKEN, opts, (msg) => { - // error if message received as only one poll will complete and there should be no more because of stopPolling - done(msg); - }); - polling.stopPolling() - .then(() => { - setInterval(() => { - done(); - }, 1000); - }).catch(done); - // The second time _getUpdates is called it will return a message - // Really dirty but it works - polling._getUpdates = () => { - return new Promise.resolve([{ update_id: 11, message: {} }]); - }; - }); - }); - -}); diff --git a/test/mocha.opts b/test/mocha.opts index 7903b88..c7f44ab 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1 +1,3 @@ ---require babel-register \ No newline at end of file +--reporter spec +--require babel-register +--timeout 30000 diff --git a/test/telegram.js b/test/telegram.js new file mode 100644 index 0000000..4f0628a --- /dev/null +++ b/test/telegram.js @@ -0,0 +1,878 @@ +const Telegram = require('../lib/telegram'); +const Promise = require('bluebird'); +const request = require('request-promise'); +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const is = require('is'); +const utils = require('./utils'); + +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +const TOKEN = process.env.TEST_TELEGRAM_TOKEN; +if (!TOKEN) { + throw new Error('Bot token not provided'); +} + +// Telegram service if not User Id +const USERID = process.env.TEST_USER_ID || 777000; +const GROUPID = process.env.TEST_GROUP_ID || -1001075450562; +const GAME_SHORT_NAME = process.env.TEST_GAME_SHORT_NAME || 'medusalab_test'; +const timeout = 60 * 1000; +const staticPort = 8091; +const pollingPort = 8092; +const webHookPort = 8093; +const pollingPort2 = 8094; +const webHookPort2 = 8095; +const staticUrl = `http://127.0.0.1:${staticPort}`; +let FILE_ID; +let GAME_CHAT_ID; +let GAME_MSG_ID; + +before(function beforeAll() { + utils.startStaticServer(staticPort); + return utils.startMockServer(pollingPort) + .then(() => { + return utils.startMockServer(pollingPort2); + }); +}); + +describe('Telegram', function telegramSuite() { + let bot; + let testbot; + let botPolling; + let botWebHook; + + before(function beforeAll() { + this.timeout(timeout); + bot = new Telegram(TOKEN); + testbot = new Telegram(TOKEN, { + baseApiUrl: `http://127.0.0.1:${pollingPort}`, + polling: { + autoStart: false, + }, + webHook: { + autoOpen: false, + port: webHookPort, + }, + }); + botPolling = new Telegram(TOKEN, { + baseApiUrl: `http://127.0.0.1:${pollingPort2}`, + polling: true, + }); + botWebHook = new Telegram(TOKEN, { + webHook: { + port: webHookPort2, + }, + }); + + utils.handleRatelimit(bot, 'sendPhoto', this); + utils.handleRatelimit(bot, 'sendMessage', this); + utils.handleRatelimit(bot, 'sendGame', this); + return bot.sendPhoto(USERID, `${__dirname}/data/photo.gif`).then(resp => { + FILE_ID = resp.photo[0].file_id; + return bot.sendMessage(USERID, 'chat'); + }).then(resp => { + GAME_CHAT_ID = resp.chat.id; + return bot.sendGame(USERID, GAME_SHORT_NAME); + }).then(resp => { + GAME_MSG_ID = resp.message_id; + }); + }); + + it('automatically starts polling', function test() { + assert.equal(botPolling.isPolling(), true); + return utils.isPollingMockServer(pollingPort2); + }); + + it('automatically opens webhook', function test() { + assert.equal(botWebHook.hasOpenWebHook(), true); + return utils.hasOpenWebHook(webHookPort2); + }); + + it('does not automatically poll if "autoStart" is false', function test() { + assert.equal(testbot.isPolling(), false); + return utils.isPollingMockServer(pollingPort, true); + }); + + it('does not automatically open webhook if "autoOpen" is false', function test() { + assert.equal(testbot.hasOpenWebHook(), false); + return utils.hasOpenWebHook(webHookPort, true); + }); + + describe('Events', function eventsSuite() { + it('(polling) emits "message" on receiving message', function test(done) { + botPolling.once('message', () => { + return done(); + }); + }); + it('(webhook) emits "message" on receiving message', function test(done) { + botWebHook.once('message', () => { + return done(); + }); + utils.sendWebHookMessage(webHookPort2, TOKEN); + }); + }); + + describe('WebHook', function webHookSuite() { + it('returns 401 error if token is wrong', function test(done) { + utils.sendWebHookMessage(webHookPort2, 'wrong-token').catch(resp => { + assert.equal(resp.statusCode, 401); + return done(); + }); + }); + it('only accepts POST method', function test() { + const methods = ['GET', 'PUT', 'DELETE', 'OPTIONS']; + return Promise.each(methods, (method) => { + return utils.sendWebHookMessage(webHookPort2, TOKEN, { + method, + }).then(() => { + throw new Error(`expected error with webhook ${method} request`); + }).catch(resp => { + if (!resp.statusCode) throw resp; + if (resp.statusCode !== 418) throw new Error(`unexpected error: ${resp.body}`); + }); + }); // Promise.each + }); + }); + + describe('#initPolling', function initPollingSuite() { + it('initiates polling', function test() { + testbot.initPolling(); + return utils.isPollingMockServer(pollingPort); + }); + }); + + describe('#isPolling', function isPollingSuite() { + it('returns true if bot is polling', function test() { + assert.equal(testbot.isPolling(), true); + return utils.isPollingMockServer(pollingPort); + }); + it('returns false if bot is not polling', function test() { + return testbot.stopPolling().then(() => { + assert.equal(testbot.isPolling(), false); + utils.clearPollingCheck(pollingPort); + return utils.isPollingMockServer(pollingPort, true); + }); + }); + after(function after() { + return testbot.initPolling(); + }); + }); + + describe('#stopPolling', function stopPollingSuite() { + it('stops polling by bot', function test() { + return testbot.stopPolling().then(() => { + utils.clearPollingCheck(pollingPort); + return utils.isPollingMockServer(pollingPort, true); + }); + }); + }); + + describe('#openWebHook', function openWebHookSuite() { + it('opens webhook', function test() { + testbot.openWebHook(); + return utils.hasOpenWebHook(webHookPort); + }); + }); + + describe('#hasOpenWebHook', function hasOpenWebHookSuite() { + it('returns true if webhook is opened', function test() { + assert.equal(testbot.hasOpenWebHook(), true); + return utils.hasOpenWebHook(webHookPort); + }); + it('returns false if webhook is closed', function test() { + testbot.closeWebHook().then(() => { + assert.equal(testbot.hasOpenWebHook(), false); + return utils.hasOpenWebHook(webHookPort, true); + }); + }); + after(function after() { + return testbot.openWebHook(); + }); + }); + + describe('#closeWebHook', function closeWebHookSuite() { + it('closes webhook', function test() { + testbot.closeWebHook().then(() => { + return utils.hasOpenWebHook(webHookPort, true); + }); + }); + }); + + describe('#getMe', function getMeSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getMe', this); + }); + it('should return an User object', function test() { + return bot.getMe().then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.number(resp.id)); + assert.ok(is.string(resp.first_name)); + }); + }); + }); + + describe('#setWebHook', function setWebHookSuite() { + const ip = '216.58.210.174'; + before(function before() { + utils.handleRatelimit(bot, 'setWebHook', this); + }); + it('should set a webHook', function test() { + // Google IP ¯\_(ツ)_/¯ + return bot + .setWebHook(ip) + .then(resp => { + assert.equal(resp, true); + }); + }); + it('should set a webHook with certificate', function test() { + const cert = `${__dirname}/../examples/crt.pem`; + return bot + .setWebHook(ip, cert) + .then(resp => { + assert.equal(resp, true); + }); + }); + it('should delete the webHook', function test() { + return bot + .setWebHook('') + .then(resp => { + assert.equal(resp, true); + }); + }); + }); + + describe('#getUpdates', function getUpdatesSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getUpdates', this); + }); + it('should return an Array', function test() { + return bot.getUpdates().then(resp => { + assert.equal(Array.isArray(resp), true); + }); + }); + }); + + describe('#sendMessage', function sendMessageSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendMessage', this); + }); + it('should send a message', function test() { + return bot.sendMessage(USERID, 'test').then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.number(resp.message_id)); + }); + }); + }); + + describe.skip('#answerInlineQuery', function answerInlineQuerySuite() {}); + + describe('#forwardMessage', function forwardMessageSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendMessage', this); + utils.handleRatelimit(bot, 'forwardMessage', this); + }); + it('should forward a message', function test() { + return bot.sendMessage(USERID, 'test').then(resp => { + const messageId = resp.message_id; + return bot.forwardMessage(USERID, USERID, messageId) + .then(forwarded => { + assert.ok(is.object(forwarded)); + assert.ok(is.number(forwarded.message_id)); + }); + }); + }); + }); + + describe('#sendPhoto', function sendPhotoSuite() { + let photoId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendPhoto', this); + }); + it('should send a photo from file', function test() { + const photo = `${__dirname}/data/photo.gif`; + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + photoId = resp.photo[0].file_id; + }); + }); + it('should send a photo from id', function test() { + // Send the same photo as before + const photo = photoId; + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + }); + }); + it('should send a photo from fs.readStream', function test() { + const photo = fs.createReadStream(`${__dirname}/data/photo.gif`); + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + }); + }); + it('should send a photo from request Stream', function test() { + const photo = request(`${staticUrl}/photo.gif`); + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + }); + }); + it('should send a photo from a Buffer', function test() { + const photo = fs.readFileSync(`${__dirname}/data/photo.gif`); + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + }); + }); + }); + + describe('#sendAudio', function sendAudioSuite() { + let audioId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendAudio', this); + }); + it('should send an MP3 audio', function test() { + const audio = `${__dirname}/data/audio.mp3`; + return bot.sendAudio(USERID, audio).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.audio)); + audioId = resp.audio.file_id; + }); + }); + it('should send an audio from id', function test() { + // Send the same audio as before + const audio = audioId; + return bot.sendAudio(USERID, audio).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.audio)); + }); + }); + it('should send an audio from fs.readStream', function test() { + const audio = fs.createReadStream(`${__dirname}/data/audio.mp3`); + return bot.sendAudio(USERID, audio).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.audio)); + }); + }); + it('should send an audio from request Stream', function test() { + const audio = request(`${staticUrl}/audio.mp3`); + return bot.sendAudio(USERID, audio).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.audio)); + }); + }); + it('should send an audio from a Buffer', function test() { + const audio = fs.readFileSync(`${__dirname}/data/audio.mp3`); + return bot.sendAudio(USERID, audio).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.audio)); + }); + }); + }); + + describe('#sendDocument', function sendDocumentSuite() { + let documentId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendDocument', this); + }); + it('should send a document from file', function test() { + const document = `${__dirname}/data/photo.gif`; + return bot.sendDocument(USERID, document).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.document)); + documentId = resp.document.file_id; + }); + }); + it('should send a document from id', function test() { + // Send the same document as before + const document = documentId; + return bot.sendDocument(USERID, document).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.document)); + }); + }); + it('should send a document from fs.readStream', function test() { + const document = fs.createReadStream(`${__dirname}/data/photo.gif`); + return bot.sendDocument(USERID, document).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.document)); + }); + }); + it('should send a document from request Stream', function test() { + const document = request(`${staticUrl}/photo.gif`); + return bot.sendDocument(USERID, document).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.document)); + }); + }); + it('should send a document from a Buffer', function test() { + const document = fs.readFileSync(`${__dirname}/data/photo.gif`); + return bot.sendDocument(USERID, document).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.document)); + }); + }); + }); + + describe('#sendSticker', function sendStickerSuite() { + let stickerId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendSticker', this); + }); + it('should send a sticker from file', function test() { + const sticker = `${__dirname}/data/sticker.webp`; + return bot.sendSticker(USERID, sticker).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.sticker)); + stickerId = resp.sticker.file_id; + }); + }); + it('should send a sticker from id', function test() { + // Send the same photo as before + return bot.sendSticker(USERID, stickerId).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.sticker)); + }); + }); + it('should send a sticker from fs.readStream', function test() { + const sticker = fs.createReadStream(`${__dirname}/data/sticker.webp`); + return bot.sendSticker(USERID, sticker).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.sticker)); + }); + }); + it('should send a sticker from request Stream', function test() { + const sticker = request(`${staticUrl}/sticker.webp`); + return bot.sendSticker(USERID, sticker).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.sticker)); + }); + }); + it('should send a sticker from a Buffer', function test() { + const sticker = fs.readFileSync(`${__dirname}/data/sticker.webp`); + return bot.sendSticker(USERID, sticker).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.sticker)); + }); + }); + }); + + describe('#sendVideo', function sendVideoSuite() { + let videoId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendVideo', this); + }); + it('should send a video from file', function test() { + const video = `${__dirname}/data/video.mp4`; + return bot.sendVideo(USERID, video).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.video)); + videoId = resp.video.file_id; + }); + }); + it('should send a video from id', function test() { + // Send the same video as before + return bot.sendVideo(USERID, videoId).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.video)); + }); + }); + it('should send a video from fs.readStream', function test() { + const video = fs.createReadStream(`${__dirname}/data/video.mp4`); + return bot.sendVideo(USERID, video).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.video)); + }); + }); + it('should send a video from request Stream', function test() { + const video = request(`${staticUrl}/video.mp4`); + return bot.sendVideo(USERID, video).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.video)); + }); + }); + it('should send a video from a Buffer', function test() { + const video = fs.readFileSync(`${__dirname}/data/video.mp4`); + return bot.sendVideo(USERID, video).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.video)); + }); + }); + }); + + describe('#sendVoice', function sendVoiceSuite() { + let voiceId; + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendVoice', this); + }); + it('should send a voice from file', function test() { + const voice = `${__dirname}/data/voice.ogg`; + return bot.sendVoice(USERID, voice).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.voice)); + voiceId = resp.voice.file_id; + }); + }); + it('should send a voice from id', function test() { + // Send the same voice as before + return bot.sendVoice(USERID, voiceId).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.voice)); + }); + }); + it('should send a voice from fs.readStream', function test() { + const voice = fs.createReadStream(`${__dirname}/data/voice.ogg`); + return bot.sendVoice(USERID, voice).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.voice)); + }); + }); + it('should send a voice from request Stream', function test() { + const voice = request(`${staticUrl}/voice.ogg`); + return bot.sendVoice(USERID, voice).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.voice)); + }); + }); + it('should send a voice from a Buffer', function test() { + const voice = fs.readFileSync(`${__dirname}/data/voice.ogg`); + return bot.sendVoice(USERID, voice).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.voice)); + }); + }); + }); + + describe('#sendChatAction', function sendChatActionSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendChatAction', this); + }); + it('should send a chat action', function test() { + const action = 'typing'; + return bot.sendChatAction(USERID, action).then(resp => { + assert.equal(resp, true); + }); + }); + }); + + describe.skip('#kickChatMember', function kickChatMemberSuite() {}); + + describe.skip('#unbanChatMember', function unbanChatMemberSuite() {}); + + describe.skip('#answerCallbackQuery', function answerCallbackQuerySuite() {}); + + describe('#editMessageText', function editMessageTextSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendMessage', this); + utils.handleRatelimit(bot, 'editMessageText', this); + }); + it('should edit a message sent by the bot', function test() { + return bot.sendMessage(USERID, 'test').then(resp => { + assert.equal(resp.text, 'test'); + const opts = { + chat_id: USERID, + message_id: resp.message_id + }; + return bot.editMessageText('edit test', opts).then(msg => { + assert.equal(msg.text, 'edit test'); + }); + }); + }); + }); + + describe('#editMessageCaption', function editMessageCaptionSuite() { + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'sendPhoto', this); + utils.handleRatelimit(bot, 'editMessageCaption', this); + }); + it('should edit a caption sent by the bot', function test() { + const photo = `${__dirname}/data/photo.gif`; + const options = { caption: 'test caption' }; + return bot.sendPhoto(USERID, photo, options).then(resp => { + assert.equal(resp.caption, 'test caption'); + const opts = { + chat_id: USERID, + message_id: resp.message_id + }; + return bot.editMessageCaption('new test caption', opts).then(msg => { + assert.equal(msg.caption, 'new test caption'); + }); + }); + }); + }); + + describe('#editMessageReplyMarkup', function editMessageReplyMarkupSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendMessage', this); + utils.handleRatelimit(bot, 'editMessageReplyMarkup', this); + }); + it('should edit previously-set reply markup', function test() { + return bot.sendMessage(USERID, 'test').then(resp => { + const replyMarkup = JSON.stringify({ + inline_keyboard: [[{ + text: 'Test button', + callback_data: 'test' + }]] + }); + const opts = { + chat_id: USERID, + message_id: resp.message_id + }; + return bot.editMessageReplyMarkup(replyMarkup, opts).then(msg => { + // Keyboard markup is not returned, do a simple object check + assert.ok(is.object(msg)); + }); + }); + }); + }); + + describe('#getUserProfilePhotos', function getUserProfilePhotosSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getUserProfilePhotos', this); + }); + it('should get user profile photos', function test() { + return bot.getUserProfilePhotos(USERID).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.number(resp.total_count)); + assert.ok(is.array(resp.photos)); + }); + }); + }); + + describe('#sendLocation', function sendLocationSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendLocation', this); + }); + it('should send a location', function test() { + const lat = 47.5351072; + const long = -52.7508537; + return bot.sendLocation(USERID, lat, long).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.location)); + assert.ok(is.number(resp.location.latitude)); + assert.ok(is.number(resp.location.longitude)); + }); + }); + }); + + describe('#sendVenue', function sendVenueSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendVenue', this); + }); + it('should send a venue', function test() { + const lat = 47.5351072; + const long = -52.7508537; + const title = 'The Village Shopping Centre'; + const address = '430 Topsail Rd,St. John\'s, NL A1E 4N1, Canada'; + return bot.sendVenue(USERID, lat, long, title, address).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.venue)); + assert.ok(is.object(resp.venue.location)); + assert.ok(is.number(resp.venue.location.latitude)); + assert.ok(is.number(resp.venue.location.longitude)); + assert.ok(is.string(resp.venue.title)); + assert.ok(is.string(resp.venue.address)); + }); + }); + }); + + // NOTE: We are skipping TelegramBot#sendContact() as the + // corresponding rate-limits enforced by the Telegram servers + // are too strict! During our initial tests, we were required + // to retry after ~72000 secs (1200 mins / 20 hrs). + // We surely can NOT wait for that much time during testing + // (or in most practical cases for that matter!) + describe.skip('#sendContact', function sendContactSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendContact', this); + }); + it('should send a contact', function test() { + const phoneNumber = '+1(000)000-000'; + const firstName = 'John Doe'; + return bot.sendContact(USERID, phoneNumber, firstName).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.contact)); + assert.ok(is.string(resp.contact.phone_number)); + assert.ok(is.string(resp.contact.first_name)); + }); + }); + }); + + describe('#getFile', function getFileSuite() { + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'getFile', this); + }); + it('should get a file', function test() { + return bot.getFile(FILE_ID) + .then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.string(resp.file_path)); + }); + }); + }); + + describe('#getFileLink', function getFileLinkSuite() { + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'getFileLink', this); + }); + it('should get a file link', function test() { + return bot.getFileLink(FILE_ID) + .then(fileURI => { + assert.ok(is.string(fileURI)); + assert.ok(/https?:\/\/.*\/file\/bot.*\/.*/.test(fileURI)); + }); + }); + }); + + describe('#downloadFile', function downloadFileSuite() { + const downloadPath = os.tmpdir(); + this.timeout(timeout); + before(function before() { + utils.handleRatelimit(bot, 'downloadFile', this); + }); + it('should download a file', function test() { + return bot.downloadFile(FILE_ID, downloadPath) + .then(filePath => { + assert.ok(is.string(filePath)); + assert.equal(path.dirname(filePath), downloadPath); + assert.ok(fs.existsSync(filePath)); + fs.unlinkSync(filePath); // Delete file after test + }); + }); + }); + + describe('#onText', function onTextSuite() { + it('should call `onText` callback on match', function test(done) { + botWebHook.onText(/\/onText (.+)/, (msg, match) => { + assert.equal(match[1], 'ECHO ALOHA'); + return done(); + }); + utils.sendWebHookMessage(webHookPort2, TOKEN, { + message: { text: '/onText ECHO ALOHA' }, + }); + }); + }); + + describe.skip('#onReplyToMessage', function onReplyToMessageSuite() {}); + + describe('#getChat', function getChatSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getChat', this); + }); + it('should return a Chat object', function test() { + return bot.getChat(USERID).then(resp => { + assert.ok(is.object(resp)); + }); + }); + }); + + describe('#getChatAdministrators', function getChatAdministratorsSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getChatAdministrators', this); + }); + it('should return an Array', function test() { + return bot.getChatAdministrators(GROUPID).then(resp => { + assert.ok(Array.isArray(resp)); + }); + }); + }); + + describe('#getChatMembersCount', function getChatMembersCountSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getChatMembersCount', this); + }); + it('should return an Integer', function test() { + return bot.getChatMembersCount(GROUPID).then(resp => { + assert.ok(Number.isInteger(resp)); + }); + }); + }); + + describe('#getChatMember', function getChatMemberSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getChatMember', this); + }); + it('should return a ChatMember', function test() { + return bot.getChatMember(GROUPID, USERID).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.user)); + assert.ok(is.string(resp.status)); + }); + }); + }); + + describe.skip('#leaveChat', function leaveChatSuite() {}); + + describe('#sendGame', function sendGameSuite() { + before(function before() { + utils.handleRatelimit(bot, 'sendGame', this); + }); + it('should send a Game', function test() { + return bot.sendGame(USERID, GAME_SHORT_NAME).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.object(resp.game)); + }); + }); + }); + + describe('#setGameScore', function setGameScoreSuite() { + before(function before() { + utils.handleRatelimit(bot, 'setGameScore', this); + }); + it('should set GameScore', function test() { + const score = Math.floor(Math.random() * 1000); + const opts = { + chat_id: GAME_CHAT_ID, + message_id: GAME_MSG_ID, + force: true + }; + return bot.setGameScore(USERID, score, opts).then(resp => { + assert.ok(is.object(resp) || is.boolean(resp)); + }); + }); + }); + + describe('#getGameHighScores', function getGameHighScoresSuite() { + before(function before() { + utils.handleRatelimit(bot, 'getGameHighScores', this); + }); + it('should get GameHighScores', function test() { + const opts = { + chat_id: GAME_CHAT_ID, + message_id: GAME_MSG_ID, + }; + return bot.getGameHighScores(USERID, opts).then(resp => { + assert.ok(is.array(resp)); + }); + }); + }); + + describe('#_formatSendData', function _formatSendDataSuite() { + it('should handle buffer path from fs.readStream', function test() { + let photo; + try { + photo = fs.createReadStream(Buffer.from(`${__dirname}/data/photo.gif`)); + } catch (ex) { + // Older Node.js versions do not support passing a Buffer + // representation of the path to fs.createReadStream() + if (ex instanceof TypeError) return Promise.resolve(); + } + return bot.sendPhoto(USERID, photo).then(resp => { + assert.ok(is.object(resp)); + assert.ok(is.array(resp.photo)); + }); + }); + }); +}); // End Telegram diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..c4b59a9 --- /dev/null +++ b/test/utils.js @@ -0,0 +1,190 @@ +/* eslint-disable no-use-before-define */ +exports = module.exports = { + /** + * Clear polling check, so that 'isPollingMockServer()' returns false + * if the bot stopped polling the mock server. + * @param {Number} port + */ + clearPollingCheck, + /** + * Redefine a bot method to allow us to ignore 429 (rate-limit) errors + * @param {TelegramBot} bot + * @param {String} methodName + * @param {Suite} suite From mocha + * @return {TelegramBot} + */ + handleRatelimit, + /** + * Return true if a webhook has been opened at the specified port. + * Otherwise throw an error. + * @param {Number} port + * @param {Boolean} [reverse] Throw error when it should have returned true (and vice versa) + * @return {Promise} + */ + hasOpenWebHook, + /** + * Return true if the mock server is being polled by a bot. + * Otherwise throw an error. + * @param {Number} port + * @param {Boolean} [reverse] Throw error when it should have returned true (and vice versa) + * @return {Promise} + */ + isPollingMockServer, + /** + * Send a message to the webhook at the specified port. + * @param {Number} port + * @param {String} token + * @param {Object} [options] + * @param {String} [options.method=POST] Method to use + * @param {Object} [options.message] Message to send. Default to a generic text message + * @return {Promise} + */ + sendWebHookMessage, + /** + * Start a mock server at the specified port. + * @param {Number} port + * @return {Promise} + */ + startMockServer, + /** + * Start the static server, serving files in './data' + * @param {Number} port + */ + startStaticServer, +}; +/* eslint-enable no-use-before-define */ + + +const assert = require('assert'); +const http = require('http'); +const Promise = require('bluebird'); +const request = require('request-promise'); +const statics = require('node-static'); + +const servers = {}; + + +function startMockServer(port) { + assert.ok(port); + const server = http.Server((req, res) => { + servers[port].polling = true; + return res.end(JSON.stringify({ + ok: true, + result: [{ + update_id: 0, + message: { text: 'test' }, + }], + })); + }); + return new Promise((resolve, reject) => { + servers[port] = { server, polling: false }; + server.on('error', reject).listen(port, resolve); + }); +} + + +function startStaticServer(port) { + const fileServer = new statics.Server(`${__dirname}/data`); + http.Server((req, res) => { + req.addListener('end', () => { + fileServer.serve(req, res); + }).resume(); + }).listen(port); +} + + +function isPollingMockServer(port, reverse) { + assert.ok(port); + return new Promise((resolve, reject) => { + // process.nextTick() does not wait until a poll request + // is complete! + setTimeout(() => { + let polling = servers[port] && servers[port].polling; + if (reverse) polling = !polling; + if (polling) return resolve(true); + return reject(new Error('polling-check failed')); + }, 1000); + }); +} + + +function clearPollingCheck(port) { + assert.ok(port); + if (servers[port]) servers[port].polling = false; +} + + +function hasOpenWebHook(port, reverse) { + assert.ok(port); + const error = new Error('open-webhook-check failed'); + let connected = false; + return request.get(`http://127.0.0.1:${port}`) + .then(() => { + connected = true; + }).catch(e => { + if (e.statusCode < 500) connected = true; + }).finally(() => { + if (reverse) { + if (connected) throw error; + return; + } + if (!connected) throw error; + }); +} + + +function sendWebHookMessage(port, token, options = {}) { + assert.ok(port); + assert.ok(token); + const url = `http://127.0.0.1:${port}/bot${token}`; + return request({ + url, + method: options.method || 'POST', + body: { + update_id: 1, + message: options.message || { text: 'test' } + }, + json: true, + }); +} + + +function handleRatelimit(bot, methodName, suite) { + const backupMethodName = `__${methodName}`; + if (!bot[backupMethodName]) bot[backupMethodName] = bot[methodName]; + + const maxRetries = 3; + const addSecs = 5; + const method = bot[backupMethodName]; + assert.equal(typeof method, 'function'); + + bot[methodName] = (...args) => { + let retry = 0; + function exec() { + return method.call(bot, ...args) + .catch(error => { + if (!error.response || error.response.statusCode !== 429) { + throw error; + } + retry++; + if (retry > maxRetries) { + throw error; + } + if (typeof error.response.body === 'string') { + error.response.body = JSON.parse(error.response.body); + } + const retrySecs = error.response.body.parameters.retry_after; + const timeout = (1000 * retrySecs) + (1000 * addSecs); + console.error('tests: Handling rate-limit error. Retrying after %d secs', timeout / 1000); // eslint-disable-line no-console + suite.timeout(timeout * 2); + return new Promise(function timeoutPromise(resolve, reject) { + setTimeout(function execTimeout() { + return exec().then(resolve).catch(reject); + }, timeout); + }); + }); + } + return exec(); + }; + return bot; +} diff --git a/test/video.mp4 b/test/video.mp4 deleted file mode 100644 index 1fc4788..0000000 Binary files a/test/video.mp4 and /dev/null differ