diff --git a/CHANGELOG.md b/CHANGELOG.md index f32fb43..cdbfb50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Added: 1. Add methods: * *TelegramBot#removeReplyListener()* (by @githugger) +1. Add proper error handling (by @GochoMugo) 1. Add health-check endpoint (by @mironov) * `options.webHook.healthEndpoint` 1. Use *String#indexOf()*, instead of *RegExp#test()*, to diff --git a/doc/usage.md b/doc/usage.md index c2fb4f0..78043b0 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -3,6 +3,7 @@ 1. [Events](#events) 1. [WebHooks](#WebHooks) 1. [Sending files](#sending-files) +1. [Error handling](#error-handling) * * * @@ -29,6 +30,8 @@ 1. `edited_channel_post`: Received a new version of a channel post that is known to the bot and was edited 1. `edited_channel_post_text` 1. `edited_channel_post_caption` +1. `polling_error`: Error occurred during polling. See [polling errors](#polling-errors). +1. `webhook_error`: Error occurred handling a webhook request. See [webhook errors](#webhook-errors). **Tip:** Its much better to listen a specific event rather than on `message` in order to stay safe from the content. @@ -145,3 +148,60 @@ const bot = new TelegramBot(token, { filepath: false, }); ``` + + + +## Error handling + +Every `Error` object we pass back has the properties: + +* `code` (String): + * value is `EFATAL` if error was fatal e.g. network error + * value is `EPARSE` if response body could **not** be parsed + * value is `ETELEGRAM` if error was returned from Telegram servers +* `response` ([http.IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage)): + * available if `error.code` is **not** `EFATAL` +* `response.body` (String|Object): Error response from Telegram + * type is `String` if `error.code` is `EPARSE` + * type is `Object` if `error.code` is `ETELEGRAM` + +For example, sending message to a non-existent user: + +```js +bot.sendMessage(nonExistentUserId, 'text').catch(error => { + console.log(error.code); // => 'ETELEGRAM' + console.log(error.response.body); // => { ok: false, error_code: 400, description: 'Bad Request: chat not found' } +}); +``` + + +#### Polling errors + +An error may occur during polling. It is up to you to handle it +as you see fit. You may decide to crash your bot after a maximum number +of polling errors occurring. **It is all up to you.** + +By default, the polling error is just logged to stderr, if you do +**not** handle this event yourself. + +Listen on the `'polling_error'` event. For example, + +```js +bot.on('polling_error', (error) => { + console.log(error.code); // => 'EFATAL' +}); +``` + + +#### WebHook errors + +Just like with [polling errors](#polling-errors), you decide on how to +handle it. By default, the error is logged to stderr. + +Listen on the `'webhook_error'` event. For example, + +```js +bot.on('webhook_error', (error) => { + console.log(error.code); // => 'EPARSE' +}); +``` diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..a4bffd3 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,59 @@ +exports.BaseError = class BaseError extends Error { + /** + * @class BaseError + * @constructor + * @private + * @param {String} code Error code + * @param {String} message Error message + */ + constructor(code, message) { + super(`${code}: ${message}`); + this.code = code; + } +}; + + +exports.FatalError = class FatalError extends exports.BaseError { + /** + * Fatal Error. Error code is `"EFATAL"`. + * @class FatalError + * @constructor + * @param {String|Error} data Error object or message + */ + constructor(data) { + const error = (typeof data === 'string') ? null : data; + const message = error ? error.message : data; + super('EFATAL', message); + if (error) this.stack = error.stack; + } +}; + + +exports.ParseError = class ParseError extends exports.BaseError { + /** + * Error during parsing. Error code is `"EPARSE"`. + * @class ParseError + * @constructor + * @param {String} message Error message + * @param {http.IncomingMessage} response Server response + */ + constructor(message, response) { + super('EPARSE', message); + this.response = response; + } +}; + + +exports.TelegramError = class TelegramError extends exports.BaseError { + /** + * Error returned from Telegram. Error code is `"ETELEGRAM"`. + * @class TelegramError + * @constructor + * @param {String} message Error message + * @param {http.IncomingMessage} response Server response + */ + constructor(message, response) { + super('ETELEGRAM', message); + this.response = response; + } +}; diff --git a/src/telegram.js b/src/telegram.js index 28b1819..b44536b 100644 --- a/src/telegram.js +++ b/src/telegram.js @@ -1,6 +1,7 @@ // shims require('array.prototype.findindex').shim(); // for Node.js v0.x +const errors = require('./errors'); const TelegramBotWebHook = require('./telegramWebHook'); const TelegramBotPolling = require('./telegramPolling'); const debug = require('debug')('node-telegram-bot-api'); @@ -31,6 +32,10 @@ Promise.config({ class TelegramBot extends EventEmitter { + static get errors() { + return errors; + } + static get messageTypes() { return _messageTypes; } @@ -136,7 +141,7 @@ class TelegramBot extends EventEmitter { */ _request(_path, options = {}) { if (!this.token) { - throw new Error('Telegram Bot Token not provided!'); + return Promise.reject(new errors.FatalError('Telegram Bot Token not provided!')); } if (this.options.request) { @@ -158,30 +163,22 @@ class TelegramBot extends EventEmitter { debug('HTTP request: %j', options); return request(options) .then(resp => { - if (resp.statusCode !== 200) { - const error = new Error(`${resp.statusCode} ${resp.body}`); - error.response = resp; - throw error; - } - let data; - try { - data = JSON.parse(resp.body); + data = resp.body = JSON.parse(resp.body); } catch (err) { - const error = new Error(`Error parsing Telegram response: ${resp.body}`); - error.response = resp; - throw error; + throw new errors.ParseError(`Error parsing Telegram response: ${resp.body}`, resp); } if (data.ok) { return data.result; } - const error = new Error(`${data.error_code} ${data.description}`); - error.response = resp; - error.response.body = data; - throw error; + throw new errors.TelegramError(`${data.error_code} ${data.description}`, resp); + }).catch(error => { + // TODO: why can't we do `error instanceof errors.BaseError`? + if (error.response) throw error; + throw new errors.FatalError(error); }); } @@ -215,7 +212,7 @@ class TelegramBot extends EventEmitter { } else if (Buffer.isBuffer(data)) { const filetype = fileType(data); if (!filetype) { - throw new Error('Unsupported Buffer file type'); + throw new errors.FatalError('Unsupported Buffer file type'); } formData = {}; formData[type] = { @@ -256,11 +253,11 @@ class TelegramBot extends EventEmitter { */ startPolling(options = {}) { if (this.hasOpenWebHook()) { - return Promise.reject(new Error('Polling and WebHook are mutually exclusive')); + return Promise.reject(new errors.FatalError('Polling and WebHook are mutually exclusive')); } options.restart = typeof options.restart === 'undefined' ? true : options.restart; if (!this._polling) { - this._polling = new TelegramBotPolling(this._request.bind(this), this.options.polling, this.processUpdate.bind(this)); + this._polling = new TelegramBotPolling(this); } return this._polling.start(options); } @@ -305,10 +302,10 @@ class TelegramBot extends EventEmitter { */ openWebHook() { if (this.isPolling()) { - return Promise.reject(new Error('WebHook and Polling are mutually exclusive')); + return Promise.reject(new errors.FatalError('WebHook and Polling are mutually exclusive')); } if (!this._webHook) { - this._webHook = new TelegramBotWebHook(this.token, this.options.webHook, this.processUpdate.bind(this)); + this._webHook = new TelegramBotWebHook(this); } return this._webHook.open(); } @@ -385,14 +382,7 @@ class TelegramBot extends EventEmitter { } } - return this._request('setWebHook', opts) - .then(resp => { - if (!resp) { - throw new Error(resp); - } - - return resp; - }); + return this._request('setWebHook', opts); } /** diff --git a/src/telegramPolling.js b/src/telegramPolling.js index 6943ceb..dafd217 100644 --- a/src/telegramPolling.js +++ b/src/telegramPolling.js @@ -5,29 +5,14 @@ const ANOTHER_WEB_HOOK_USED = 409; class TelegramBotPolling { /** * 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 + * @param {TelegramBot} bot + * @see https://core.telegram.org/bots/api#getting-updates */ - constructor(request, options = {}, callback) { - /* eslint-disable no-param-reassign */ - if (typeof options === 'function') { - callback = options; - options = {}; - } else if (typeof options === 'boolean') { - options = {}; - } - /* eslint-enable no-param-reassign */ - - this.request = request; - this.options = options; - this.options.timeout = (typeof options.timeout === 'number') ? options.timeout : 10; - this.options.interval = (typeof options.interval === 'number') ? options.interval : 300; - this.callback = callback; + constructor(bot) { + this.bot = bot; + this.options = (typeof bot.options.polling === 'boolean') ? {} : bot.options.polling; + this.options.timeout = (typeof this.options.timeout === 'number') ? this.options.timeout : 10; + this.options.interval = (typeof this.options.interval === 'number') ? this.options.interval : 300; this._offset = 0; this._lastUpdate = 0; this._lastRequest = null; @@ -102,13 +87,18 @@ class TelegramBotPolling { updates.forEach(update => { this._offset = update.update_id; debug('updated offset: %s', this._offset); - this.callback(update); + this.bot.processUpdate(update); }); return null; }) .catch(err => { debug('polling error: %s', err.message); - throw err; + if (this.bot.listeners('polling_error').length) { + this.bot.emit('polling_error', err); + } else { + console.error(err); // eslint-disable-line no-console + } + return null; }) .finally(() => { if (this._abort) { @@ -128,7 +118,7 @@ class TelegramBotPolling { * @private */ _unsetWebHook() { - return this.request('setWebHook'); + return this.bot._request('setWebHook'); } /** @@ -144,7 +134,7 @@ class TelegramBotPolling { }; debug('polling with options: %j', opts); - return this.request('getUpdates', opts) + return this.bot._request('getUpdates', opts) .catch(err => { if (err.response && err.response.statusCode === ANOTHER_WEB_HOOK_USED) { return this._unsetWebHook(); diff --git a/src/telegramWebHook.js b/src/telegramWebHook.js index b8f29f9..2ca20a3 100644 --- a/src/telegramWebHook.js +++ b/src/telegramWebHook.js @@ -1,3 +1,4 @@ +const errors = require('./errors'); const debug = require('debug')('node-telegram-bot-api'); const https = require('https'); const http = require('http'); @@ -9,27 +10,16 @@ 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 {String} [options.host=0.0.0.0] Host to bind to - * @param {Number} [options.port=8443] Port to bind to - * @param {String} [options.healthEndpoint=/healthz] An endpoint for health checks that always responds with 200 OK - * @param {Function} callback Function for process a new update + * @param {TelegramBot} bot + * @see https://core.telegram.org/bots/api#getting-updates */ - constructor(token, options, callback) { - // define opts - if (typeof options === 'boolean') { - options = {}; // eslint-disable-line no-param-reassign - } - - this.token = token; - this.options = options; - this.options.host = options.host || '0.0.0.0'; - this.options.port = options.port || 8443; - this.options.https = options.https || {}; - this.options.healthEndpoint = options.healthEndpoint || '/healthz'; - this.callback = callback; + constructor(bot) { + this.bot = bot; + this.options = (typeof bot.options.webHook === 'boolean') ? {} : bot.options.webHook; + this.options.host = this.options.host || '0.0.0.0'; + this.options.port = this.options.port || 8443; + this.options.https = this.options.https || {}; + this.options.healthEndpoint = this.options.healthEndpoint || '/healthz'; this._healthRegex = new RegExp(this.options.healthEndpoint); this._webServer = null; this._open = false; @@ -100,31 +90,35 @@ class TelegramBotWebHook { return this._open; } - // used so that other funcs are not non-optimizable - _safeParse(json) { - try { - return JSON.parse(json); - } catch (err) { - debug(err); - return null; + /** + * Handle error thrown during processing of webhook request. + * @private + * @param {Error} error + */ + _error(error) { + if (!this.bot.listeners('webhook_error').length) { + return console.error(error); // eslint-disable-line no-console } + return this.bot.emit('webhook_error', error); } /** * Handle request body by passing it to 'callback' * @private */ - _parseBody(err, body) { - if (err) { - return debug(err); + _parseBody(error, body) { + if (error) { + return this._error(new errors.FatalError(error)); } - const data = this._safeParse(body); - if (data) { - return this.callback(data); + let data; + try { + data = JSON.parse(body.toString()); + } catch (parseError) { + return this._error(new errors.ParseError(parseError.message)); } - return null; + return this.bot.processUpdate(data); } /** @@ -137,7 +131,7 @@ class TelegramBotWebHook { debug('WebHook request URL: %s', req.url); debug('WebHook request headers: %j', req.headers); - if (req.url.indexOf(this.token) !== -1) { + if (req.url.indexOf(this.bot.token) !== -1) { if (req.method !== 'POST') { debug('WebHook request isn\'t a POST'); res.statusCode = 418; // I'm a teabot! diff --git a/test/telegram.js b/test/telegram.js index a509376..345b6bf 100644 --- a/test/telegram.js +++ b/test/telegram.js @@ -27,6 +27,7 @@ const pollingPort = portindex++; const webHookPort = portindex++; const pollingPort2 = portindex++; const webHookPort2 = portindex++; +const badTgServerPort = portindex++; const staticUrl = `http://127.0.0.1:${staticPort}`; const key = `${__dirname}/../examples/key.pem`; const cert = `${__dirname}/../examples/crt.pem`; @@ -39,6 +40,8 @@ before(function beforeAll() { return utils.startMockServer(pollingPort) .then(() => { return utils.startMockServer(pollingPort2); + }).then(() => { + return utils.startMockServer(badTgServerPort, { bad: true }); }); }); @@ -125,17 +128,33 @@ describe('TelegramBot', function telegramSuite() { return done(); }); }); + it('(polling) emits "polling_error" if error occurs during polling', function test(done) { + const myBot = new TelegramBot(12345, { polling: true }); + myBot.once('polling_error', (error) => { + assert.ok(error); + assert.equal(error.code, 'ETELEGRAM'); + return myBot.stopPolling().then(() => { done(); }).catch(done); + }); + }); it('(webhook) emits "message" on receiving message', function test(done) { botWebHook.once('message', () => { return done(); }); utils.sendWebHookMessage(webHookPort2, TOKEN); }); + it('(webhook) emits "webhook_error" if could not parse webhook request body', function test(done) { + botWebHook.once('webhook_error', (error) => { + assert.ok(error); + assert.equal(error.code, 'EPARSE'); + return done(); + }); + utils.sendWebHookMessage(webHookPort2, TOKEN, { update: 'unparseable!', json: false }); + }); }); describe('WebHook', function webHookSuite() { it('returns 200 OK for health endpoint', function test(done) { - utils.sendWebHookRequest(webHookPort2, '/healthz', { json: false }).then(resp => { + utils.sendWebHookRequest(webHookPort2, '/healthz').then(resp => { assert.equal(resp, 'OK'); return done(); }); @@ -186,6 +205,58 @@ describe('TelegramBot', function telegramSuite() { }); }); + describe('errors', function errorsSuite() { + const botParse = new TelegramBot('useless-token', { + baseApiUrl: `http://localhost:${badTgServerPort}`, + }); + it('FatalError is thrown if token is missing', function test() { + const myBot = new TelegramBot(null); + return myBot.sendMessage(USERID, 'text').catch(error => { + // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); + assert.equal(error.code, 'EFATAL'); + assert.ok(error.message.indexOf('not provided') > -1); + }); + }); + it('FatalError is thrown if file-type of Buffer could not be determined', function test() { + let buffer; + try { + buffer = Buffer.from('12345'); + } catch (ex) { + buffer = new Buffer('12345'); + } + return bot.sendPhoto(USERID, buffer).catch(error => { + // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); + assert.equal(error.code, 'EFATAL'); + assert.ok(error.message.indexOf('Unsupported') > -1); + }); + }); + it('FatalError is thrown on network error', function test() { + const myBot = new TelegramBot('useless-token', { + baseApiUrl: 'http://localhost:23', // are we sure this port is not bound to? + }); + return myBot.getMe().catch(error => { + // FIX: assert.ok(error instanceof TelegramBot.errors.FatalError); + assert.equal(error.code, 'EFATAL'); + }); + }); + it('ParseError is thrown if response body could not be parsed', function test() { + botParse.sendMessage(USERID, 'text').catch(error => { + // FIX: assert.ok(error instanceof TelegramBot.errors.ParseError); + assert.equal(error.code, 'EPARSE'); + assert.ok(typeof error.response === 'object'); + assert.ok(typeof error.response.body === 'string'); + }); + }); + it('TelegramError is thrown if error is from Telegram', function test() { + return bot.sendMessage('404', 'text').catch(error => { + // FIX: assert.ok(error instanceof TelegramBot.errors.TelegramError); + assert.equal(error.code, 'ETELEGRAM'); + assert.ok(typeof error.response === 'object'); + assert.ok(typeof error.response.body === 'object'); + }); + }); + }); + describe('#startPolling', function initPollingSuite() { it('initiates polling', function test() { return testbot.startPolling().then(() => { @@ -195,6 +266,8 @@ describe('TelegramBot', function telegramSuite() { it('returns error if using webhook', function test() { return botWebHook.startPolling().catch((err) => { // TODO: check for error in a better way + // FIX: assert.ok(err instanceof TelegramBot.errors.FatalError); + assert.equal(err.code, 'EFATAL'); assert.ok(err.message.indexOf('mutually exclusive') !== -1); }); }); @@ -235,6 +308,8 @@ describe('TelegramBot', function telegramSuite() { it('returns error if using polling', function test() { return botPolling.openWebHook().catch((err) => { // TODO: check for error in a better way + // FIX: assert.ok(err instanceof TelegramBot.errors.FatalError); + assert.equal(err.code, 'EFATAL'); assert.ok(err.message.indexOf('mutually exclusive') !== -1); }); }); @@ -1020,7 +1095,7 @@ describe('TelegramBot', function telegramSuite() { const photo = `${__dirname}/data/photo.gif`; return tgbot.sendPhoto(USERID, photo).catch(err => { // TODO: check for error in a better way - assert.ok(err.response.body.indexOf('Bad Request') !== -1); + assert.ok(err.response.body.description.indexOf('Bad Request') !== -1); }); }); it('should allow stream.path that can not be parsed', function test() { diff --git a/test/utils.js b/test/utils.js index 044bf09..23df9d8 100644 --- a/test/utils.js +++ b/test/utils.js @@ -36,6 +36,7 @@ exports = module.exports = { * @param {String} path * @param {Object} [options] * @param {String} [options.method=POST] Method to use + * @param {Object} [options.update] Update object to send. * @param {Object} [options.message] Message to send. Default to a generic text message * @param {Boolean} [options.https=false] Use https * @return {Promise} @@ -47,6 +48,7 @@ exports = module.exports = { * @param {String} token * @param {Object} [options] * @param {String} [options.method=POST] Method to use + * @param {Object} [options.update] Update object to send. * @param {Object} [options.message] Message to send. Default to a generic text message * @param {Boolean} [options.https=false] Use https * @return {Promise} @@ -55,6 +57,9 @@ exports = module.exports = { /** * Start a mock server at the specified port. * @param {Number} port + * @param {Object} [options] + * @param {Boolean} [options.bad=false] Bad Mock Server; responding with + * unparseable messages * @return {Promise} */ startMockServer, @@ -76,10 +81,13 @@ const statics = require('node-static'); const servers = {}; -function startMockServer(port) { +function startMockServer(port, options = {}) { assert.ok(port); const server = http.Server((req, res) => { servers[port].polling = true; + if (options.bad) { + return res.end('can not be parsed with JSON.parse()'); + } return res.end(JSON.stringify({ ok: true, result: [{ @@ -153,11 +161,11 @@ function sendWebHookRequest(port, path, options = {}) { return request({ url, method: options.method || 'POST', - body: { + body: options.update || { update_id: 1, message: options.message || { text: 'test' } }, - json: options.json || true, + json: (typeof options.json === 'undefined') ? true : options.json, }); }