diff --git a/README.md b/README.md index 3b3262c..b3e62f2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ TelegramBot * [TelegramBot](#TelegramBot) * [new TelegramBot(token, [options])](#new_TelegramBot_new) + * [.initPolling()](#TelegramBot+initPolling) * [.stopPolling()](#TelegramBot+stopPolling) ⇒ Promise * [.getMe()](#TelegramBot+getMe) ⇒ Promise * [.setWebHook(url, [cert])](#TelegramBot+setWebHook) @@ -122,14 +123,21 @@ 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.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.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.request] | Object | | Options which will be added for all requests to telegram api. See https://github.com/request/request#requestoptions-callback for more information. | + + +### telegramBot.initPolling() +Start polling + +**Kind**: instance method of [TelegramBot](#TelegramBot) ### telegramBot.stopPolling() ⇒ Promise diff --git a/package.json b/package.json index c314626..6f23db0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "bot" ], "scripts": { - "prepublish": "babel -d ./lib src", + "build": "babel -d ./lib src", + "prepublish": "npm run build", "test": "istanbul cover ./node_modules/mocha/bin/_mocha -- -R spec --timeout 10000", "prepublish:test": "npm run prepublish && npm run test", "gen-doc": "jsdoc2md --src src/telegram.js -t README.hbs > README.md", diff --git a/src/telegram.js b/src/telegram.js index 1f65f0c..419b3d1 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,22 +41,22 @@ 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|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.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 {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. * @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._textRegexpCallbacks = []; + this._onReplyToMessages = []; if (options.polling) { this.initPolling(); @@ -62,27 +67,13 @@ class TelegramBot extends EventEmitter { } } - 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 + * Process an update; emitting the proper events and executing regexp + * callbacks + * @param {Object} update + * @private */ - stopPolling() { - if (this._polling) { - return this._polling.stopPolling(); - } - return Promise.resolve(); - } - - processUpdate(update) { + _processUpdate(update) { debug('Process Update %j', update); const message = update.message; const editedMessage = update.edited_message; @@ -97,27 +88,28 @@ class TelegramBot extends EventEmitter { this.emit('message', message); const processMessageType = messageType => { if (message[messageType]) { - debug('Emtting %s: %j', messageType, message); + debug('Emitting %s: %j', messageType, message); this.emit(messageType, message); } }; TelegramBot.messageTypes.forEach(processMessageType); if (message.text) { debug('Text message'); - this.textRegexpCallbacks.some(reg => { + 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 (!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 => { + this._onReplyToMessages.forEach(reply => { // Message from the same chat if (reply.chatId === message.chat.id) { // Responding to that message @@ -139,7 +131,7 @@ class TelegramBot extends EventEmitter { } } else if (channelPost) { debug('Process Update channel_post %j', channelPost); - this.emit('channel_post', channelPost); + this.emit('channel_post', channelPost); } else if (editedChannelPost) { debug('Process Update edited_channel_post %j', editedChannelPost); this.emit('edited_channel_post', editedChannelPost); @@ -148,7 +140,7 @@ class TelegramBot extends EventEmitter { } 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); @@ -161,24 +153,42 @@ class TelegramBot extends EventEmitter { } } - // 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)}`); - } + /** + * 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 + */ + _buildURL(_path) { + return URL.format({ + protocol: 'https', + host: 'api.telegram.org', + pathname: `/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 +204,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 +213,108 @@ 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(); } /** @@ -330,49 +418,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 @@ -776,7 +821,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 +832,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..fd679f5 100644 --- a/src/telegramPolling.js +++ b/src/telegramPolling.js @@ -1,48 +1,69 @@ -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._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; + 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 +72,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); + 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..5efa3c1 100644 --- a/src/telegramWebHook.js +++ b/src/telegramWebHook.js @@ -4,18 +4,28 @@ const http = require('http'); const fs = require('fs'); const bl = require('bl'); + 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 +54,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 +71,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();