From 8766049e4ee405e05fc971ce83d7357f4261c328 Mon Sep 17 00:00:00 2001 From: Colin Hill Date: Sat, 28 Mar 2026 21:04:57 -0400 Subject: [PATCH 1/2] Significantly reduced the threat surface from 40 to 12 modules. The rest require outright dependency replacement --- .babelrc | 11 +++-- .eslintignore | 2 - .gitignore | 4 +- eslint.config.mjs | 76 +++++++++++++++++++++++++++++++++++ examples/game/game.js | 2 +- examples/webhook/express.js | 2 +- examples/webhook/now.js | 2 +- package.json | 49 +++++++++------------- src/telegram.js | 20 ++++----- src/telegramPolling.js | 6 +-- src/telegramWebHook.js | 2 +- test/telegram.js | 2 +- test/test.format-send-data.js | 9 +++-- test/utils.js | 6 +-- 14 files changed, 130 insertions(+), 63 deletions(-) delete mode 100644 .eslintignore create mode 100644 eslint.config.mjs diff --git a/.babelrc b/.babelrc index d03285da..2878ba9b 100644 --- a/.babelrc +++ b/.babelrc @@ -1,10 +1,9 @@ { - "plugins": [ - "transform-strict-mode", - "transform-object-rest-spread", - "transform-class-properties" - ], "presets": [ - "babel-preset-es2015" + ["@babel/preset-env", { + "targets": { + "node": "0.12" + } + }] ] } diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 417342d6..00000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -bin -*.md diff --git a/.gitignore b/.gitignore index bd74acab..2764967b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.nyc_output/ coverage/ npm-debug.log .package.json @@ -7,4 +8,5 @@ output.md output/ lib/ lib-doc/ -.DS_Store \ No newline at end of file +.DS_Store +.env diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..267f2788 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,76 @@ +import js from '@eslint/js'; +import babelParser from '@babel/eslint-parser'; +import mochaPlugin from 'eslint-plugin-mocha'; + +export default [ + { + ignores: ['node_modules/**', 'lib/**', 'bin/**', '*.md'] + }, + { + files: ['**/*.js'], + languageOptions: { + parser: babelParser, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: ['@babel/preset-env'] + } + }, + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + require: 'readonly', + module: 'readonly', + exports: 'readonly', + global: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly' + } + }, + rules: { + ...js.configs.recommended.rules, + 'new-cap': 0, + 'prefer-arrow-callback': 0, + 'no-param-reassign': [2, { props: false }], + 'max-len': [2, 200], + 'arrow-body-style': 0, + 'comma-dangle': 0, + 'indent': ['error', 2], + 'no-console': 0, + 'func-names': 0, + 'object-shorthand': 0, + 'no-use-before-define': 0, + 'no-underscore-dangle': 0 + } + }, + { + files: ['test/**/*.js'], + languageOptions: { + globals: { + describe: 'readonly', + it: 'readonly', + before: 'readonly', + after: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly' + } + }, + plugins: { + mocha: mochaPlugin + }, + rules: { + 'mocha/no-top-level-hooks': 0, + 'mocha/consistent-spacing-between-blocks': 0, + 'mocha/no-setup-in-describe': 0, + 'mocha/max-top-level-suites': 0, + 'mocha/no-pending-tests': 0 + } + } +]; diff --git a/examples/game/game.js b/examples/game/game.js index cd7a9d23..a40ecd10 100644 --- a/examples/game/game.js +++ b/examples/game/game.js @@ -1,7 +1,7 @@ /** * This example demonstrates using HTML5 games with Telegram. */ -/* eslint-disable no-console */ + const TOKEN = process.env.TELEGRAM_TOKEN || 'YOUR_TELEGRAM_BOT_TOKEN'; const gameName = process.env.TELEGRAM_GAMENAME || 'YOUR_TELEGRAM_GAMENAME'; diff --git a/examples/webhook/express.js b/examples/webhook/express.js index 4978e3ba..8a98a79c 100644 --- a/examples/webhook/express.js +++ b/examples/webhook/express.js @@ -2,7 +2,7 @@ * This example demonstrates setting up a webook, and receiving * updates in your express app */ -/* eslint-disable no-console */ + const TOKEN = process.env.TELEGRAM_TOKEN || 'YOUR_TELEGRAM_BOT_TOKEN'; const url = 'https://'; diff --git a/examples/webhook/now.js b/examples/webhook/now.js index f492e664..c67bb76a 100644 --- a/examples/webhook/now.js +++ b/examples/webhook/now.js @@ -17,7 +17,7 @@ const options = { // domain. // See: https://zeit.co/blog/now-alias // Or just use NOW_URL to get deployment url from env. -const url = 'YOUR_DOMAIN_ALIAS' || process.env.NOW_URL; +const url = process.env.NOW_URL || 'YOUR_DOMAIN_ALIAS'; const bot = new TelegramBot(TOKEN, options); diff --git a/package.json b/package.json index 5cd12637..c2024678 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "eslint": "eslint ./src ./test ./examples", "mocha": "mocha", "pretest": "npm run build", - "test": "npm run eslint && istanbul cover ./node_modules/mocha/bin/_mocha" + "test": "npm run eslint && nyc mocha" }, "author": "Yago PΓ©rez ", "license": "MIT", @@ -29,39 +29,30 @@ "node": ">=0.12" }, "dependencies": { - "@cypress/request": "^3.0.8", "@cypress/request-promise": "^5.0.0", - "array.prototype.findindex": "^2.0.2", - "bl": "^1.2.3", - "debug": "^3.2.7", - "eventemitter3": "^3.0.0", - "file-type": "^3.9.0", - "mime": "^1.6.0", - "pump": "^2.0.0" + "array.prototype.findindex": "^2.2.4", + "bl": "^6.1.6", + "debug": "^4.4.3", + "eventemitter3": "^5.0.4", + "file-type": "^12.4.2", + "mime": "^4.1.0", + "pump": "^3.0.4" }, "devDependencies": { - "babel-cli": "^6.26.0", - "babel-eslint": "^8.0.3", - "babel-plugin-transform-class-properties": "^6.24.1", - "babel-plugin-transform-es2015-destructuring": "^6.23.0", - "babel-plugin-transform-es2015-parameters": "^6.24.1", - "babel-plugin-transform-es2015-shorthand-properties": "^6.24.1", - "babel-plugin-transform-es2015-spread": "^6.22.0", - "babel-plugin-transform-object-rest-spread": "^6.26.0", - "babel-plugin-transform-strict-mode": "^6.24.1", - "babel-preset-es2015": "^6.24.1", - "babel-register": "^6.26.0", - "concat-stream": "^1.6.0", - "eslint": "^2.13.1", - "eslint-config-airbnb": "^6.2.0", - "eslint-plugin-mocha": "^4.11.0", - "is": "^3.2.1", - "is-ci": "^1.0.10", - "istanbul": "^1.1.0-alpha.1", + "@babel/cli": "^7.23.0", + "@babel/core": "^7.23.0", + "@babel/eslint-parser": "^7.23.0", + "@babel/preset-env": "^7.23.0", + "concat-stream": "^2.0.0", + "eslint": "^9.0.0", + "eslint-plugin-mocha": "^11.2.0", + "is": "^3.3.2", + "is-ci": "^4.1.0", "jsdoc-to-markdown": "^9.1.3", - "mocha": "^3.5.3", + "mocha": "^11.3.0", "mocha-lcov-reporter": "^1.3.0", - "node-static": "^0.7.10" + "node-static": "^0.7.11", + "nyc": "^15.1.0" }, "repository": { "type": "git", diff --git a/src/telegram.js b/src/telegram.js index 56378350..26111333 100644 --- a/src/telegram.js +++ b/src/telegram.js @@ -11,7 +11,7 @@ const request = require('@cypress/request-promise'); const streamedRequest = require('@cypress/request'); const qs = require('querystring'); const stream = require('stream'); -const mime = require('mime'); +const mime = require('mime').default; const path = require('path'); const URL = require('url'); const fs = require('fs'); @@ -276,7 +276,7 @@ class TelegramBot extends EventEmitter { * @see https://core.telegram.org/bots/api#sendmessage */ _fixReplyParameters(obj) { - if (obj.hasOwnProperty('reply_parameters') && typeof obj.reply_parameters !== 'string') { + if (Object.prototype.hasOwnProperty.call(obj, 'reply_parameters') && typeof obj.reply_parameters !== 'string') { obj.reply_parameters = stringify(obj.reply_parameters); } } @@ -319,7 +319,7 @@ class TelegramBot extends EventEmitter { let data; try { data = resp.body = JSON.parse(resp.body); - } catch (err) { + } catch { throw new errors.ParseError(`Error parsing response: ${resp.body}`, resp); } @@ -399,7 +399,7 @@ class TelegramBot extends EventEmitter { } filename = filename || 'filename'; - contentType = contentType || mime.lookup(filename); + contentType = contentType || mime.getType(filename); if (process.env.NTBA_FIX_350) { contentType = contentType || 'application/octet-stream'; } else { @@ -927,14 +927,14 @@ class TelegramBot extends EventEmitter { * We need to ensure backwards-compatibility while maintaining * consistency of the method signatures throughout the library */ if (typeof form !== 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ + /* eslint-disable no-param-reassign */ deprecate('The method signature getUpdates(timeout, limit, offset) has been deprecated since v0.25.0'); form = { timeout: arguments[0], limit: arguments[1], offset: arguments[2], }; - /* eslint-enable no-param-reassign, prefer-rest-params */ + /* eslint-enable no-param-reassign */ } // If allowed_updates is present and is an array, stringify it. @@ -2460,23 +2460,23 @@ class TelegramBot extends EventEmitter { * We need to ensure backwards-compatibility while maintaining * consistency of the method signatures throughout the library */ if (typeof form !== 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ + /* eslint-disable no-param-reassign */ deprecate('The method signature answerCallbackQuery(callbackQueryId, text, showAlert) has been deprecated since v0.27.1'); form = { callback_query_id: arguments[0], text: arguments[1], show_alert: arguments[2], }; - /* eslint-enable no-param-reassign, prefer-rest-params */ + /* eslint-enable no-param-reassign */ } /* The older method signature (in/before v0.29.0) was answerCallbackQuery([options]). * We need to ensure backwards-compatibility while maintaining * consistency of the method signatures throughout the library. */ if (typeof callbackQueryId === 'object') { - /* eslint-disable no-param-reassign, prefer-rest-params */ + /* eslint-disable no-param-reassign */ deprecate('The method signature answerCallbackQuery([options]) has been deprecated since v0.29.0'); form = callbackQueryId; - /* eslint-enable no-param-reassign, prefer-rest-params */ + /* eslint-enable no-param-reassign */ } else { form.callback_query_id = callbackQueryId; } diff --git a/src/telegramPolling.js b/src/telegramPolling.js index c63c5af6..1eee420f 100644 --- a/src/telegramPolling.js +++ b/src/telegramPolling.js @@ -87,7 +87,7 @@ class TelegramBotPolling { */ _error(error) { if (!this.bot.listeners('polling_error').length) { - return console.error(`${new Date().toISOString()} error: [polling_error] %j`, error); // eslint-disable-line no-console + return console.error(`${new Date().toISOString()} error: [polling_error] %j`, error); } return this.bot.emit('polling_error', error); } @@ -150,14 +150,14 @@ class TelegramBotPolling { * We simply can not rescue this situation, emit "error" * event, with the hope that the application exits. */ - /* eslint-disable no-console */ + const bugUrl = 'https://github.com/yagop/node-telegram-bot-api/issues/36#issuecomment-268532067'; const ts = new Date().toISOString(); console.error(`${ts} error: Internal handling of The Offset Infinite Loop failed`); console.error(`${ts} error: Due to error '${requestErr}'`); console.error(`${ts} error: You may receive already-processed updates on app restart`); console.error(`${ts} error: Please see ${bugUrl} for more information`); - /* eslint-enable no-console */ + return this.bot.emit('error', new errors.FatalError(err)); }); }) diff --git a/src/telegramWebHook.js b/src/telegramWebHook.js index 1ee767a5..8c812836 100644 --- a/src/telegramWebHook.js +++ b/src/telegramWebHook.js @@ -99,7 +99,7 @@ class TelegramBotWebHook { */ _error(error) { if (!this.bot.listeners('webhook_error').length) { - return console.error(`${new Date().toISOString()} error: [webhook_error] %j`, error); // eslint-disable-line no-console + return console.error(`${new Date().toISOString()} error: [webhook_error] %j`, error); } return this.bot.emit('webhook_error', error); } diff --git a/test/telegram.js b/test/telegram.js index 2ea3b169..8c90fb7f 100644 --- a/test/telegram.js +++ b/test/telegram.js @@ -263,7 +263,7 @@ describe('TelegramBot', function telegramSuite() { let buffer; try { buffer = Buffer.from('12345'); - } catch (ex) { + } catch { buffer = new Buffer('12345'); } return bot.sendPhoto(USERID, buffer).catch(error => { diff --git a/test/test.format-send-data.js b/test/test.format-send-data.js index 97828427..b74ba84a 100644 --- a/test/test.format-send-data.js +++ b/test/test.format-send-data.js @@ -1,6 +1,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); +const { PassThrough } = require('stream'); const TelegramBot = require('..'); const paths = { @@ -21,14 +22,14 @@ describe('#_formatSendData', function sendfileSuite() { describe('using fileOptions', function sendfileOptionsSuite() { const stream = fs.createReadStream(paths.audio); - const nonPathStream = fs.createReadStream(paths.audio); + // Create a PassThrough stream to simulate a stream without a .path property + // This avoids issues with deleting properties from real ReadStreams + const nonPathStream = new PassThrough(); const buffer = fs.readFileSync(paths.audio); const nonDetectableBuffer = fs.readFileSync(__filename); const filepath = paths.audio; const files = [stream, nonPathStream, buffer, nonDetectableBuffer, filepath]; - delete nonPathStream.path; - describe('filename', function filenameSuite() { it('(1) fileOptions.filename', function test() { const filename = 'custom-filename'; @@ -130,7 +131,7 @@ describe('#_formatSendData', function sendfileSuite() { }); it('should allow stream.path that can not be parsed', function test() { - const stream = fs.createReadStream(paths.audio); + const stream = new PassThrough(); stream.path = '/?id=123'; // for example, 'http://example.com/?id=666' assert.doesNotThrow(function assertDoesNotThrow() { bot._formatSendData('file', stream); diff --git a/test/utils.js b/test/utils.js index 78dec279..cdf7590f 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,4 +1,4 @@ -/* eslint-disable no-use-before-define */ +/* eslint-disable no-global-assign */ exports = module.exports = { /** * Clear polling check, so that 'isPollingMockServer()' returns false @@ -76,7 +76,7 @@ exports = module.exports = { */ startStaticServer, }; -/* eslint-enable no-use-before-define */ + const assert = require('assert'); @@ -210,7 +210,7 @@ function handleRatelimit(bot, methodName, suite) { } 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 + console.error('tests: Handling rate-limit error. Retrying after %d secs', timeout / 1000); suite.timeout(timeout * 2); return new Promise(function timeoutPromise(resolve, reject) { setTimeout(function execTimeout() { From 30add3f686fdff75826bf976c8e854bdf4deef66 Mon Sep 17 00:00:00 2001 From: Colin Hill Date: Sun, 29 Mar 2026 14:02:49 -0400 Subject: [PATCH 2/2] Factored out the remaining modules exhibiting vulnerabilities. --- examples/polling.js | 13 ++- package.json | 6 +- src/telegram.js | 208 ++++++++++++++++++++++++++++++++++---------- test/telegram.js | 87 ++++++++++-------- test/utils.js | 94 ++++++++++++++++---- 5 files changed, 305 insertions(+), 103 deletions(-) diff --git a/examples/polling.js b/examples/polling.js index 3d6aecb4..283cfad6 100644 --- a/examples/polling.js +++ b/examples/polling.js @@ -6,7 +6,7 @@ const TOKEN = process.env.TELEGRAM_TOKEN || 'YOUR_TELEGRAM_BOT_TOKEN'; const TelegramBot = require('..'); -const request = require('@cypress/request'); +const fetch = require('node-fetch'); const options = { polling: true }; @@ -25,10 +25,15 @@ bot.onText(/\/photo/, function onPhotoText(msg) { // Matches /audio bot.onText(/\/audio/, function onAudioText(msg) { - // From HTTP request + // From HTTP request - convert response to stream const url = 'https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg'; - const audio = request(url); - bot.sendAudio(msg.chat.id, audio); + fetch(url) + .then(response => { + bot.sendAudio(msg.chat.id, response.body); + }) + .catch(err => { + console.error('Error fetching audio:', err); + }); }); diff --git a/package.json b/package.json index c2024678..256a25d4 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,14 @@ "node": ">=0.12" }, "dependencies": { - "@cypress/request-promise": "^5.0.0", "array.prototype.findindex": "^2.2.4", "bl": "^6.1.6", "debug": "^4.4.3", "eventemitter3": "^5.0.4", "file-type": "^12.4.2", + "form-data": "^4.0.5", "mime": "^4.1.0", + "node-fetch": "^2.6.12", "pump": "^3.0.4" }, "devDependencies": { @@ -49,9 +50,8 @@ "is": "^3.3.2", "is-ci": "^4.1.0", "jsdoc-to-markdown": "^9.1.3", - "mocha": "^11.3.0", + "mocha": "^12.0.0-beta-10", "mocha-lcov-reporter": "^1.3.0", - "node-static": "^0.7.11", "nyc": "^15.1.0" }, "repository": { diff --git a/src/telegram.js b/src/telegram.js index 26111333..fdb5fd23 100644 --- a/src/telegram.js +++ b/src/telegram.js @@ -7,8 +7,7 @@ const TelegramBotPolling = require('./telegramPolling'); const debug = require('debug')('node-telegram-bot-api'); const EventEmitter = require('eventemitter3'); const fileType = require('file-type'); -const request = require('@cypress/request-promise'); -const streamedRequest = require('@cypress/request'); +const fetch = require('node-fetch'); const qs = require('querystring'); const stream = require('stream'); const mime = require('mime').default; @@ -58,6 +57,44 @@ const _messageTypes = [ 'message_reaction' ]; +/** + * Convert request-compatible form data to FormData for node-fetch + * @param {Object} form - The form data object + * @return {FormData} - FormData instance + * @private + */ +function createFormData(form) { + const FormData = require('form-data'); + const formData = new FormData(); + + Object.keys(form).forEach((key) => { + const field = form[key]; + const value = field.value; + const opts = field.options || {}; + const filename = opts.filename || 'file'; + const contentType = opts.contentType; + + if (Object.prototype.isPrototypeOf.call(stream.Readable.prototype, value) || value instanceof stream.Stream) { + // It's a stream + formData.append(key, value, { + filename, + contentType, + }); + } else if (Buffer.isBuffer(value)) { + // It's a buffer + formData.append(key, value, { + filename, + contentType, + }); + } else { + // It's a string/number/etc. + formData.append(key, value); + } + }); + + return formData; +} + const _deprecatedMessageTypes = [ 'new_chat_participant', 'left_chat_participant' ]; @@ -247,15 +284,15 @@ class TelegramBot extends EventEmitter { options.thumbnail = options.thumb; } if (options.thumbnail) { - if (opts.formData === null) { - opts.formData = {}; + if (opts.form === null) { + opts.form = {}; } const attachName = 'photo'; const [formData] = this._formatSendData(attachName, options.thumbnail.replace('attach://', '')); if (formData) { - opts.formData[attachName] = formData[attachName]; + opts.form[attachName] = formData[attachName]; opts.qs.thumbnail = `attach://${attachName}`; } } @@ -308,29 +345,104 @@ class TelegramBot extends EventEmitter { this._fixReplyParameters(options.qs); } - options.method = 'POST'; - options.url = this._buildURL(_path); - options.simple = false; - options.resolveWithFullResponse = true; - options.forever = true; - debug('HTTP request: %j', options); - return request(options) + let url = this._buildURL(_path); + let body = null; + const fetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + + if (options.qs) { + // Append query string parameters to URL + const queryString = qs.stringify(options.qs); + url = `${url}?${queryString}`; + } + + if (options.form) { + // Check if form has any file content (complex fields with .value that are streams/buffers) + const formKeys = Object.keys(options.form); + const hasFileContent = formKeys.some(key => { + const field = options.form[key]; + // Check if it's a complex field object with file content + return field && typeof field === 'object' && field.value && ( + field.value instanceof stream.Stream || + Buffer.isBuffer(field.value) + ); + }); + + if (hasFileContent) { + // Use multipart form data for files + body = createFormData(options.form); + // Get headers from form-data (includes Content-Type with boundary) + const formHeaders = body.getHeaders(); + fetchOptions.headers = formHeaders; + } else { + // No files - encode simple fields as URL-form-encoded + if (formKeys.length > 0) { + // Encode form fields as URL-encoded, handling both simple values and complex objects + const formString = formKeys + .map(key => { + const value = options.form[key]; + // If it's a complex object (file field), extract the value; otherwise use as-is + const fieldValue = value && typeof value === 'object' && !Buffer.isBuffer(value) && !(value instanceof stream.Stream) && value.value + ? value.value + : value; + return `${encodeURIComponent(key)}=${encodeURIComponent(fieldValue)}`; + }) + .join('&'); + body = formString; + } else { + // Empty form - just empty body + body = ''; + } + } + } else { + // Send empty body for non-form requests + body = ''; + } + + if (body) { + fetchOptions.body = body; + } + + debug('HTTP request: %s', url); + debug('HTTP headers: %O', fetchOptions.headers); + debug('HTTP body type: %s', body ? body.constructor.name : 'none'); + return fetch(url, fetchOptions) .then(resp => { + if (!resp.ok) { + return resp.text().then(text => { + // Try to parse error response as JSON + let errorBody; + try { + errorBody = JSON.parse(text); + } catch { + errorBody = { error: text }; + } + throw new errors.TelegramError(`${resp.status} ${resp.statusText}`, { body: errorBody, statusCode: resp.status }); + }); + } + return resp.text(); + }) + .then(text => { let data; try { - data = resp.body = JSON.parse(resp.body); + data = JSON.parse(text); } catch { - throw new errors.ParseError(`Error parsing response: ${resp.body}`, resp); + throw new errors.ParseError(`Error parsing response: ${text}`, { body: text }); } if (data.ok) { return data.result; } - throw new errors.TelegramError(`${data.error_code} ${data.description}`, resp); - }).catch(error => { - // TODO: why can't we do `error instanceof errors.BaseError`? + throw new errors.TelegramError(`${data.error_code} ${data.description}`, { body: data, statusCode: 200 }); + }) + .catch(error => { if (error.response) throw error; + if (error instanceof errors.BaseError) throw error; throw new errors.FatalError(error); }); } @@ -578,7 +690,13 @@ class TelegramBot extends EventEmitter { fileStream.emit('info', { uri: fileURI, }); - pump(streamedRequest(Object.assign({ uri: fileURI }, this.options.request)), fileStream); + fetch(fileURI, this.options.request || {}) + .then(response => { + pump(response.body, fileStream); + }) + .catch(error => { + fileStream.emit('error', error); + }); }) .catch((error) => { fileStream.emit('error', error); @@ -983,7 +1101,7 @@ class TelegramBot extends EventEmitter { if (cert) { try { const sendData = this._formatSendData('certificate', cert, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.certificate = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -1164,7 +1282,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('photo', photo, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.photo = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -1195,7 +1313,7 @@ class TelegramBot extends EventEmitter { try { const sendData = this._formatSendData('audio', audio, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.audio = sendData[1]; this._fixAddFileThumbnail(options, opts); } catch (ex) { @@ -1223,7 +1341,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('document', doc, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.document = sendData[1]; this._fixAddFileThumbnail(options, opts); } catch (ex) { @@ -1252,7 +1370,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('video', video, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.video = sendData[1]; this._fixAddFileThumbnail(options, opts); } catch (ex) { @@ -1279,7 +1397,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('animation', animation, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.animation = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -1307,7 +1425,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('voice', voice, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.voice = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -1335,7 +1453,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('video_note', videoNote, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.video_note = sendData[1]; this._fixAddFileThumbnail(options, opts); } catch (ex) { @@ -1363,11 +1481,11 @@ class TelegramBot extends EventEmitter { try { const inputPaidMedia = []; - opts.formData = {}; + opts.form = {}; const { formData, fileIds } = this._formatSendMultipleData('media', media); - opts.formData = formData; + opts.form = formData; inputPaidMedia.push(...media.map((item, index) => { if (fileIds[index]) { @@ -1408,7 +1526,7 @@ class TelegramBot extends EventEmitter { }; opts.qs.chat_id = chatId; - opts.formData = {}; + opts.form = {}; const inputMedia = []; let index = 0; for (const input of media) { @@ -1419,7 +1537,7 @@ class TelegramBot extends EventEmitter { const attachName = String(index); const [formData, fileId] = this._formatSendData(attachName, input.media, input.fileOptions); if (formData) { - opts.formData[attachName] = formData[attachName]; + opts.form[attachName] = formData[attachName]; payload.media = `attach://${attachName}`; } else { payload.media = fileId; @@ -1581,7 +1699,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('dice'); - opts.formData = sendData[0]; + opts.form = sendData[0]; } catch (ex) { return Promise.reject(ex); } @@ -2030,7 +2148,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('photo', photo, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.photo = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -2662,7 +2780,7 @@ class TelegramBot extends EventEmitter { try { const sendData = this._formatSendData('photo', photo); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.photo = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -2781,7 +2899,7 @@ class TelegramBot extends EventEmitter { qs: form, }; - opts.formData = {}; + opts.form = {}; const payload = Object.assign({}, media); delete payload.media; @@ -2795,7 +2913,7 @@ class TelegramBot extends EventEmitter { ); if (formData) { - opts.formData[attachName] = formData[attachName]; + opts.form[attachName] = formData[attachName]; payload.media = `attach://${attachName}`; } else { throw new errors.FatalError(`Failed to process the replacement action for your ${media.type}`); @@ -2914,7 +3032,7 @@ class TelegramBot extends EventEmitter { opts.qs.chat_id = chatId; try { const sendData = this._formatSendData('sticker', sticker, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.sticker = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -2969,7 +3087,7 @@ class TelegramBot extends EventEmitter { try { const sendData = this._formatSendData('sticker', sticker, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.sticker = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -3007,7 +3125,7 @@ class TelegramBot extends EventEmitter { opts.qs.mask_position = stringify(options.mask_position); try { const sendData = this._formatSendData('png_sticker', pngSticker, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.png_sticker = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -3053,7 +3171,7 @@ class TelegramBot extends EventEmitter { try { const sendData = this._formatSendData(stickerType, sticker, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs[stickerType] = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -3206,7 +3324,7 @@ class TelegramBot extends EventEmitter { opts.qs.mask_position = stringify(options.mask_position); try { const sendData = this._formatSendData('thumbnail', thumbnail, fileOptions); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.thumbnail = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -3702,7 +3820,7 @@ class TelegramBot extends EventEmitter { try { const sendData = this._formatSendData('photo', photo); - opts.formData = sendData[0]; + opts.form = sendData[0]; opts.qs.photo = sendData[1]; } catch (ex) { return Promise.reject(ex); @@ -3897,7 +4015,7 @@ class TelegramBot extends EventEmitter { try { const inputHistoryContent = content; - opts.formData = {}; + opts.form = {}; if (!content.type) { return Promise.reject(new Error('content.type is required')); @@ -3905,7 +4023,7 @@ class TelegramBot extends EventEmitter { const { formData, fileIds } = this._formatSendMultipleData(content.type, [content]); - opts.formData = formData; + opts.form = formData; if (fileIds[0]) { inputHistoryContent[content.type] = fileIds[0]; @@ -3965,7 +4083,7 @@ class TelegramBot extends EventEmitter { try { const inputHistoryContent = content; - opts.formData = {}; + opts.form = {}; if (!content.type) { return Promise.reject(new Error('content.type is required')); @@ -3973,7 +4091,7 @@ class TelegramBot extends EventEmitter { const { formData, fileIds } = this._formatSendMultipleData(content.type, [content]); - opts.formData = formData; + opts.form = formData; if (fileIds[0]) { inputHistoryContent[content.type] = fileIds[0]; diff --git a/test/telegram.js b/test/telegram.js index 8c90fb7f..49ac2029 100644 --- a/test/telegram.js +++ b/test/telegram.js @@ -1,5 +1,4 @@ const TelegramBot = require('..'); -const request = require('@cypress/request-promise'); const assert = require('assert'); const fs = require('fs'); const os = require('os'); @@ -9,6 +8,22 @@ const is = require('is'); const utils = require('./utils'); const isCI = require('is-ci'); const concat = require('concat-stream'); +const fetch = require('node-fetch'); + +/** + * Helper to create a stream from a URL (replaces request(url)) + * @param {string} url + * @return {stream.Readable} Readable stream + */ +function getStreamFromUrl(url) { + return fetch(url) + .then(resp => { + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return resp.body; + }); +} // Allows self-signed certificates to be used in our tests process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; @@ -24,8 +39,8 @@ if (!PROVIDER_TOKEN && !isCI) { // If is not running in Travis / Appveyor } // Telegram service if not User Id -const USERID = process.env.TEST_USER_ID || 777000; -const GROUPID = process.env.TEST_GROUP_ID || -1001075450562; +const USERID = parseInt(process.env.TEST_USER_ID, 10) || 777000; +const GROUPID = parseInt(process.env.TEST_GROUP_ID, 10) || -1001075450562; const GAME_SHORT_NAME = process.env.TEST_GAME_SHORT_NAME || 'medusalab_test'; const STICKER_SET_NAME = process.env.TEST_STICKER_SET_NAME || 'pusheen'; const CURRENT_TIMESTAMP = Date.now(); @@ -53,9 +68,10 @@ let STICKER_FILE_ID_FROM_SET; let STICKERS_FROM_BOT_SET; before(function beforeAll() { - utils.startStaticServer(staticPort); - return utils.startMockServer(pollingPort) + return utils.startStaticServer(staticPort) .then(() => { + return utils.startMockServer(pollingPort); + }).then(() => { return utils.startMockServer(pollingPort2); }).then(() => { return utils.startMockServer(badTgServerPort, { bad: true }); @@ -202,8 +218,8 @@ describe('TelegramBot', function telegramSuite() { }); }); it('returns 401 error if token is wrong', function test(done) { - utils.sendWebHookMessage(webHookPort2, 'wrong-token').catch(resp => { - assert.strictEqual(resp.statusCode, 401); + utils.sendWebHookMessage(webHookPort2, 'wrong-token').catch(error => { + assert.strictEqual(error.statusCode, 401); return done(); }); }); @@ -693,8 +709,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.array(resp.photo)); }); }); - it('should send a photo from request Stream', function test() { - const photo = request(`${staticUrl}/photo.png`); + it('should send a photo from request Stream', async function test() { + const photo = await getStreamFromUrl(`${staticUrl}/photo.png`); return bot.sendPhoto(USERID, photo).then(resp => { assert.ok(is.object(resp)); assert.ok(is.array(resp.photo)); @@ -738,8 +754,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.object(resp.audio)); }); }); - it('should send an audio from request Stream', function test() { - const audio = request(`${staticUrl}/audio.mp3`); + it('should send an audio from request Stream', async function test() { + const audio = await getStreamFromUrl(`${staticUrl}/audio.mp3`); return bot.sendAudio(USERID, audio).then(resp => { assert.ok(is.object(resp)); assert.ok(is.object(resp.audio)); @@ -793,8 +809,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.object(resp.document)); }); }); - it('should send a document from request Stream', function test() { - const document = request(`${staticUrl}/photo.gif`); + it('should send a document from request Stream', async function test() { + const document = await getStreamFromUrl(`${staticUrl}/photo.gif`); return bot.sendDocument(USERID, document).then(resp => { assert.ok(is.object(resp)); assert.ok(is.object(resp.document)); @@ -837,8 +853,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.object(resp.video)); }); }); - it('should send a video from request Stream', function test() { - const video = request(`${staticUrl}/video.mp4`); + it('should send a video from request Stream', async function test() { + const video = await getStreamFromUrl(`${staticUrl}/video.mp4`); return bot.sendVideo(USERID, video).then(resp => { assert.ok(is.object(resp)); assert.ok(is.object(resp.video)); @@ -893,8 +909,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.object(resp.voice)); }); }); - it('should send a voice from request Stream', function test() { - const voice = request(`${staticUrl}/voice.ogg`); + it('should send a voice from request Stream', async function test() { + const voice = await getStreamFromUrl(`${staticUrl}/voice.ogg`); return bot.sendVoice(USERID, voice).then(resp => { assert.ok(is.object(resp)); assert.ok(is.object(resp.voice)); @@ -1283,8 +1299,8 @@ describe('TelegramBot', function telegramSuite() { assert.strictEqual(resp, true); }); }); - it('should set a chat photo from request Stream', function test() { - const photo = request(`${staticUrl}/chat_photo.png`); + it('should set a chat photo from request Stream', async function test() { + const photo = await getStreamFromUrl(`${staticUrl}/chat_photo.png`); return bot.setChatPhoto(GROUPID, photo).then(resp => { assert.strictEqual(resp, true); }); @@ -1298,6 +1314,7 @@ describe('TelegramBot', function telegramSuite() { }); describe('#deleteChatPhoto', function deleteChatPhotoSuite() { + this.timeout(timeout); before(function before() { utils.handleRatelimit(bot, 'deleteChatPhoto', this); }); @@ -1349,6 +1366,7 @@ describe('TelegramBot', function telegramSuite() { }); describe('#unpinChatMessage', function unpinChatMessageSuite() { + this.timeout(timeout); before(function before() { utils.handleRatelimit(bot, 'unpinChatMessage', this); }); @@ -1622,7 +1640,7 @@ describe('TelegramBot', function telegramSuite() { return bot.getMyDefaultAdministratorRights({ for_channels: false }).then(resp => { - assert.ok(is.equal(resp, { + assert.deepStrictEqual(resp, { can_manage_chat: true, can_change_info: true, can_delete_messages: false, @@ -1630,13 +1648,14 @@ describe('TelegramBot', function telegramSuite() { can_restrict_members: false, can_pin_messages: true, can_manage_topics: false, + can_manage_tags: false, can_promote_members: false, can_manage_video_chats: false, can_post_stories: false, can_edit_stories: false, can_delete_stories: false, is_anonymous: false - })); + }); }); }); }); @@ -1792,8 +1811,8 @@ describe('TelegramBot', function telegramSuite() { assert.ok(is.object(resp.sticker)); }); }); - it('should send a sticker from request Stream', function test() { - const sticker = request(`${staticUrl}/sticker.webp`); + it('should send a sticker from request Stream', async function test() { + const sticker = await getStreamFromUrl(`${staticUrl}/sticker.webp`); return bot.sendSticker(USERID, sticker).then(resp => { assert.ok(is.object(resp)); assert.ok(is.object(resp.sticker)); @@ -1828,14 +1847,17 @@ describe('TelegramBot', function telegramSuite() { utils.handleRatelimit(bot, 'createNewStickerSet', this); }); - it('should create a new sticker set', function test(done) { + it('should create a new sticker set', function test() { const sticker = `${__dirname}/data/sticker.png`; const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - bot.createNewStickerSet(USERID, stickerPackName, 'Sticker Pack Title', sticker, '😍').then((resp) => { + return bot.createNewStickerSet(USERID, stickerPackName, 'Sticker Pack Title', sticker, '😍').then((resp) => { assert.ok(is.boolean(resp)); + // Store sticker set name for later tests + if (!this.stickerPackName) { + this.stickerPackName = stickerPackName; + } }); - setTimeout(() => done(), 2000); }); }); @@ -1898,17 +1920,16 @@ describe('TelegramBot', function telegramSuite() { const sticker = `${__dirname}/data/sticker.png`; const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - bot.addStickerToSet(USERID, stickerPackName, sticker, 'πŸ˜ŠπŸ˜πŸ€”', 'png_sticker').then((resp) => { + return bot.addStickerToSet(USERID, stickerPackName, sticker, 'πŸ˜ŠπŸ˜πŸ€”', 'png_sticker').then((resp) => { assert.ok(is.boolean(resp)); }); }); - it('should add a sticker to a set using the file Id', function test(done) { + it('should add a sticker to a set using the file Id', function test() { const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - bot.addStickerToSet(USERID, stickerPackName, STICKER_FILE_ID_FROM_SET, 'πŸ˜ŠπŸ€”', 'png_sticker').then((resp) => { + return bot.addStickerToSet(USERID, stickerPackName, STICKER_FILE_ID_FROM_SET, 'πŸ˜ŠπŸ€”', 'png_sticker').then((resp) => { assert.ok(is.boolean(resp)); }); - setTimeout(() => done(), 2000); }); }); @@ -1939,15 +1960,13 @@ describe('TelegramBot', function telegramSuite() { utils.handleRatelimit(bot, 'setStickerEmojiList', this); }); - it('should get the list for the given sticker of the bot sticker pack', function test(done) { + it('should get the list for the given sticker of the bot sticker pack', function test() { const stickerPackName = `s${CURRENT_TIMESTAMP}_by_${BOT_USERNAME}`; - bot.getStickerSet(stickerPackName).then(resp => { + return bot.getStickerSet(stickerPackName).then(resp => { STICKERS_FROM_BOT_SET = resp.stickers; assert.ok(is.array(STICKERS_FROM_BOT_SET)); }); - - setTimeout(() => done(), 2000); }); it('should set a emoji list for the given sticker', function test() { diff --git a/test/utils.js b/test/utils.js index cdf7590f..6978908a 100644 --- a/test/utils.js +++ b/test/utils.js @@ -80,9 +80,11 @@ exports = module.exports = { const assert = require('assert'); +const fs = require('fs'); const http = require('http'); -const request = require('@cypress/request-promise'); -const statics = require('node-static'); +const path = require('path'); +const URL = require('url').URL; +const fetch = require('node-fetch'); const servers = {}; @@ -110,12 +112,39 @@ function startMockServer(port, options = {}) { function startStaticServer(port) { - const fileServer = new statics.Server(`${__dirname}/data`); - http.Server((req, res) => { - req.addListener('end', () => { - fileServer.serve(req, res); - }).resume(); - }).listen(port); + const staticPath = path.join(__dirname, 'data'); + return new Promise((resolve, reject) => { + const server = http.Server((req, res) => { + req.addListener('end', () => { + const url = new URL(req.url, `http://${req.headers.host}`); + const filePath = path.join(staticPath, url.pathname); + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + return res.end('Not found'); + } + // Simple mime type detection based on extension + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + }; + const contentType = mimeTypes[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + }).resume(); + }); + server.on('error', reject); + server.listen(port, resolve); + }); } @@ -144,11 +173,11 @@ 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}`) + return fetch(`http://127.0.0.1:${port}`) .then(() => { connected = true; }).catch(e => { - if (e.statusCode < 500) connected = true; + if (e.response && e.response.status < 500) connected = true; }).finally(() => { if (reverse) { if (connected) throw error; @@ -164,14 +193,45 @@ function sendWebHookRequest(port, path, options = {}) { assert.ok(path); const protocol = options.https ? 'https' : 'http'; const url = `${protocol}://127.0.0.1:${port}${path}`; - return request({ - url, - method: options.method || 'POST', - body: options.update || { - update_id: 1, - message: options.message || { text: 'test' } + const body = options.update || { + update_id: 1, + message: options.message || { text: 'test' } + }; + const json = (typeof options.json === 'undefined') ? true : options.json; + const method = options.method || 'POST'; + + let bodyData; + if (json) { + bodyData = JSON.stringify(body); + } else { + // Fallback for URLSearchParams (Node 0.12+ compatible) + bodyData = Object.keys(body) + .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(body[key])) + .join('&'); + } + + return fetch(url, { + method, + headers: { + 'Content-Type': json ? 'application/json' : 'application/x-www-form-urlencoded', }, - json: (typeof options.json === 'undefined') ? true : options.json, + body: bodyData, + }).then(resp => { + // Check if response has content, otherwise return just the response + const contentType = resp.headers.get('content-type') || ''; + if (!resp.ok) { + // For error responses, create an error object with statusCode + const error = new Error(`HTTP ${resp.status}: ${resp.statusText}`); + error.statusCode = resp.status; + error.response = resp; + throw error; + } + // For successful responses, try to parse as JSON if content-type indicates it + if (contentType.includes('application/json')) { + return resp.json(); + } + // Otherwise return as text + return resp.text(); }); }