@zero-bot.net/tg-bot-api 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/telegram.js CHANGED
@@ -1,23 +1,32 @@
1
- // shims
2
- require('array.prototype.findindex').shim(); // for Node.js v0.x
3
-
4
1
  const errors = require('./errors');
5
2
  const TelegramBotWebHook = require('./telegramWebHook');
6
3
  const TelegramBotPolling = require('./telegramPolling');
7
4
  const debug = require('debug')('@zero-bot.net/tg-bot-api');
8
5
  const EventEmitter = require('eventemitter3');
9
- const fileType = require('file-type');
6
+ const { detectFileType, lookupMime } = require('./fileTypes');
7
+ const { applyNetworkTuning } = require('./network');
10
8
  const requestBase = require('@zero-bot.net/request');
11
- const request = (options) => new Promise((resolve, reject) => {
12
- requestBase(options, (err, response) => {
13
- if (err) reject(err);
14
- else resolve(response);
9
+ const request = (options) => {
10
+ let underlying = null;
11
+ const promise = new Promise((resolve, reject) => {
12
+ underlying = requestBase(options, (err, response) => {
13
+ if (err) reject(err);
14
+ else resolve(response);
15
+ });
15
16
  });
16
- });
17
+ // Expose cancellation so polling (and any caller) can abort an in-flight
18
+ // request. Aborting surfaces through the callback as a rejection.
19
+ promise.cancel = (reason) => {
20
+ if (underlying && typeof underlying.abort === 'function') {
21
+ underlying.abort();
22
+ }
23
+ return reason;
24
+ };
25
+ return promise;
26
+ };
17
27
  const streamedRequest = requestBase;
18
28
  const qs = require('querystring');
19
29
  const stream = require('stream');
20
- const mime = require('mime');
21
30
  const path = require('path');
22
31
  const URL = require('url');
23
32
  const fs = require('fs');
@@ -108,6 +117,22 @@ const _messageTypes = [
108
117
  // Bot API 10.3
109
118
  'stopped_message_generation',
110
119
  'community_chat_joined',
120
+ // Bot API 8.3+
121
+ 'giveaway_created',
122
+ 'giveaway',
123
+ 'giveaway_winners',
124
+ 'giveaway_completed',
125
+ 'chat_boost_added',
126
+ 'story',
127
+ 'chat_background_set',
128
+ 'forum_topic_created',
129
+ 'forum_topic_closed',
130
+ 'forum_topic_reopened',
131
+ 'forum_topic_edited',
132
+ 'general_forum_topic_hidden',
133
+ 'general_forum_topic_unhidden',
134
+ 'write_access_allowed',
135
+ 'boost',
111
136
  ];
112
137
 
113
138
  const _deprecatedMessageTypes = [
@@ -208,6 +233,13 @@ class TelegramBot extends EventEmitter {
208
233
  * **if and only if** the Node.js version you're using terminates the
209
234
  * process on unhandled rejections. This option is only for
210
235
  * *forward-compatibility purposes*.
236
+ * @param {Boolean} [options.ipv4First=false] Apply latency-oriented network
237
+ * tuning at construction: prefer IPv4 and shorten Node's IPv6 fallback
238
+ * window. This mutates process-global DNS settings — see
239
+ * {@link TelegramBot.applyNetworkTuning}.
240
+ * @param {Boolean} [options.prewarm=false] Eagerly open the DNS/TCP/TLS
241
+ * connection with a lightweight `getMe()` call so the first real request is
242
+ * not a cold start.
211
243
  * @see https://core.telegram.org/bots/api
212
244
  */
213
245
  constructor(token, options = {}) {
@@ -219,12 +251,17 @@ class TelegramBot extends EventEmitter {
219
251
  this.options.baseApiUrl = options.baseApiUrl || 'https://api.telegram.org';
220
252
  this.options.filepath = (typeof options.filepath === 'undefined') ? true : options.filepath;
221
253
  this.options.badRejection = (typeof options.badRejection === 'undefined') ? false : options.badRejection;
254
+ this.options.request = Object.assign({}, options.request);
222
255
  this._textRegexpCallbacks = [];
223
256
  this._replyListenerId = 0;
224
257
  this._replyListeners = [];
225
258
  this._polling = null;
226
259
  this._webHook = null;
227
260
 
261
+ if (options.ipv4First) {
262
+ applyNetworkTuning({ ipv4First: true });
263
+ }
264
+
228
265
  if (options.polling) {
229
266
  const autoStart = options.polling.autoStart;
230
267
  if (typeof autoStart === 'undefined' || autoStart === true) {
@@ -238,6 +275,23 @@ class TelegramBot extends EventEmitter {
238
275
  this.openWebHook();
239
276
  }
240
277
  }
278
+
279
+ if (options.prewarm) {
280
+ // Fire-and-forget; a failed warm-up must not break construction.
281
+ this.preheat();
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Apply latency-oriented network tuning (prefer IPv4, shorten Node's IPv6
287
+ * fallback window). Mutates process-global settings — call once at startup.
288
+ *
289
+ * @param {Object} [options] See {@link module:network.applyNetworkTuning}
290
+ * @return {Object} The applied settings
291
+ * @see https://nodejs.org/api/dns.html#dnssetdefaultresultorderorder
292
+ */
293
+ static applyNetworkTuning(options) {
294
+ return applyNetworkTuning(options);
241
295
  }
242
296
 
243
297
  /**
@@ -342,7 +396,13 @@ class TelegramBot extends EventEmitter {
342
396
  'link_preview_options',
343
397
  ];
344
398
  for (const field of jsonFields) {
345
- if (obj.hasOwnProperty(field) && typeof obj[field] !== 'string') {
399
+ // Skip null/undefined: `stringify(null)` would produce the string "null" and
400
+ // corrupt sentinel values such as `photo: null` set by sendPhoto() when
401
+ // uploading a Buffer/Stream (Telegram would then treat it as a file_id).
402
+ if (obj.hasOwnProperty(field)
403
+ && obj[field] !== null
404
+ && obj[field] !== undefined
405
+ && typeof obj[field] !== 'string') {
346
406
  obj[field] = stringify(obj[field]);
347
407
  }
348
408
  }
@@ -382,13 +442,14 @@ class TelegramBot extends EventEmitter {
382
442
  options.resolveWithFullResponse = true;
383
443
  options.forever = true;
384
444
  debug('HTTP request: %j', options);
385
- return request(options)
445
+ const raw = request(options);
446
+ const promise = raw
386
447
  .then(resp => {
387
448
  let data;
388
449
  try {
389
450
  data = resp.body = JSON.parse(resp.body);
390
- } catch (err) {
391
- throw new errors.ParseError(`Error parsing response: ${resp.body}`, resp);
451
+ } catch (parseError) {
452
+ throw new errors.ParseError(`Error parsing response: ${parseError.message}`, resp);
392
453
  }
393
454
 
394
455
  if (data.ok) {
@@ -401,6 +462,13 @@ class TelegramBot extends EventEmitter {
401
462
  if (error.response) throw error;
402
463
  throw new errors.FatalError(error);
403
464
  });
465
+
466
+ // Keep cancellation available on the promise chain returned to callers.
467
+ if (typeof raw.cancel === 'function') {
468
+ promise.cancel = (reason) => raw.cancel(reason);
469
+ }
470
+
471
+ return promise;
404
472
  }
405
473
 
406
474
  /**
@@ -441,7 +509,7 @@ class TelegramBot extends EventEmitter {
441
509
  filename = 'data';
442
510
  }
443
511
  if (!contentType) {
444
- const filetype = fileType(data);
512
+ const filetype = detectFileType(data);
445
513
  if (filetype) {
446
514
  contentType = filetype.mime;
447
515
  const ext = filetype.ext;
@@ -467,7 +535,7 @@ class TelegramBot extends EventEmitter {
467
535
  }
468
536
 
469
537
  filename = filename || 'filename';
470
- contentType = contentType || mime.lookup(filename);
538
+ contentType = contentType || lookupMime(filename);
471
539
  if (process.env.NTBA_FIX_350) {
472
540
  contentType = contentType || 'application/octet-stream';
473
541
  } else {
@@ -900,6 +968,34 @@ class TelegramBot extends EventEmitter {
900
968
  debug('Process Update removed_chat_boost %j', removedChatBoost);
901
969
  this.emit('removed_chat_boost', removedChatBoost);
902
970
  }
971
+
972
+ // Update-level types not tied to message content
973
+ const purchasedPaidMedia = update.purchased_paid_media;
974
+ const subscription = update.subscription;
975
+ const managedBot = update.managed_bot;
976
+ const guestMessage = update.guest_message;
977
+ const stoppedMessageGeneration = update.stopped_message_generation;
978
+ const reaction = update.reaction;
979
+
980
+ if (purchasedPaidMedia) {
981
+ debug('Process Update purchased_paid_media %j', purchasedPaidMedia);
982
+ this.emit('purchased_paid_media', purchasedPaidMedia);
983
+ } else if (subscription) {
984
+ debug('Process Update subscription %j', subscription);
985
+ this.emit('subscription', subscription);
986
+ } else if (managedBot) {
987
+ debug('Process Update managed_bot %j', managedBot);
988
+ this.emit('managed_bot', managedBot);
989
+ } else if (guestMessage) {
990
+ debug('Process Update guest_message %j', guestMessage);
991
+ this.emit('guest_message', guestMessage);
992
+ } else if (stoppedMessageGeneration) {
993
+ debug('Process Update stopped_message_generation %j', stoppedMessageGeneration);
994
+ this.emit('stopped_message_generation', stoppedMessageGeneration);
995
+ } else if (reaction) {
996
+ debug('Process Update reaction %j', reaction);
997
+ this.emit('reaction', reaction);
998
+ }
903
999
  }
904
1000
 
905
1001
  /** Start Telegram Bot API methods */
@@ -1013,6 +1109,34 @@ class TelegramBot extends EventEmitter {
1013
1109
  return this._request('getMe', { form });
1014
1110
  }
1015
1111
 
1112
+ /**
1113
+ * Warm up the connection so the first real request is not a cold start.
1114
+ *
1115
+ * Issues a lightweight `getMe()` call, which establishes DNS, the TCP socket
1116
+ * and the TLS session and populates the keep-alive pool. Worth calling once
1117
+ * at startup for webhook / broadcast bots.
1118
+ *
1119
+ * By default a failed warm-up is swallowed (emitting `preheat_error`) so it
1120
+ * is always safe to `await` at startup.
1121
+ *
1122
+ * @param {Object} [options]
1123
+ * @param {Boolean} [options.suppressErrors=true] Resolve instead of rejecting
1124
+ * when the warm-up request fails.
1125
+ * @return {Promise<TelegramBot>} Resolves with the bot instance
1126
+ * @see https://core.telegram.org/bots/api#getme
1127
+ */
1128
+ preheat({ suppressErrors = true } = {}) {
1129
+ const warm = this.getMe();
1130
+ if (!suppressErrors) {
1131
+ return warm.then(() => this);
1132
+ }
1133
+ return warm.then(() => this).catch((error) => {
1134
+ debug('preheat failed: %s', error.message);
1135
+ this.emit('preheat_error', error);
1136
+ return this;
1137
+ });
1138
+ }
1139
+
1016
1140
  /**
1017
1141
  * This method log out your bot from the cloud Bot API server before launching the bot locally.
1018
1142
  * You must log out the bot before running it locally, otherwise there is no guarantee that the bot will receive updates.
@@ -1087,7 +1211,7 @@ class TelegramBot extends EventEmitter {
1087
1211
  forwardMessages(chatId, fromChatId, messageIds, form = {}) {
1088
1212
  form.chat_id = chatId;
1089
1213
  form.from_chat_id = fromChatId;
1090
- form.message_ids = messageIds;
1214
+ form.message_ids = stringify(messageIds);
1091
1215
  return this._request('forwardMessages', { form });
1092
1216
  }
1093
1217
 
@@ -3303,7 +3427,14 @@ class TelegramBot extends EventEmitter {
3303
3427
  * @see https://core.telegram.org/bots/api#sendgift
3304
3428
  */
3305
3429
  sendGift(userId, giftId, form = {}) {
3306
- form.user_id = userId;
3430
+ if (typeof userId === 'number' || typeof userId === 'string') {
3431
+ // Check if it looks like a chat ID (negative numbers or @channel)
3432
+ if (String(userId).charAt(0) === '-' || String(userId).charAt(0) === '@') {
3433
+ form.chat_id = userId;
3434
+ } else {
3435
+ form.user_id = userId;
3436
+ }
3437
+ }
3307
3438
  form.gift_id = giftId;
3308
3439
  return this._request('sendGift', { form });
3309
3440
  }
@@ -3971,14 +4102,15 @@ class TelegramBot extends EventEmitter {
3971
4102
  * Use this method to remove multiple reactions from a message.
3972
4103
  *
3973
4104
  * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`)
3974
- * @param {Number} messageId Unique identifier of the target message
3975
- * @param {Object} [options] Additional Telegram query options
4105
+ * @param {Number|Object} [options] Identifier of the user whose reactions are removed, or an options object (`user_id`/`actor_chat_id`)
3976
4106
  * @return {Promise} True on success
3977
- * @see https://core.telegram.org/bots/api#deletemessagereactions
4107
+ * @see https://core.telegram.org/bots/api#deleteallmessagereactions
3978
4108
  */
3979
- deleteAllMessageReactions(chatId, messageId, form = {}) {
4109
+ deleteAllMessageReactions(chatId, options = {}) {
4110
+ const form = (typeof options === 'number' || typeof options === 'string')
4111
+ ? { user_id: options }
4112
+ : options;
3980
4113
  form.chat_id = chatId;
3981
- form.message_id = messageId;
3982
4114
  return this._request('deleteAllMessageReactions', { form });
3983
4115
  }
3984
4116
 
@@ -4173,12 +4305,48 @@ class TelegramBot extends EventEmitter {
4173
4305
  * @return {Promise} On success, the edited Message object is returned
4174
4306
  * @see https://core.telegram.org/bots/api#editephemeralmessagemedia
4175
4307
  */
4176
- editEphemeralMessageMedia(chatId, ephemeralMessageId, media, form = {}, fileOptions = {}) {
4308
+ editEphemeralMessageMedia(chatId, ephemeralMessageId, media, form = {}) {
4177
4309
  if (!chatId) return Promise.reject(new Error('chatId is required'));
4178
4310
  if (!ephemeralMessageId) return Promise.reject(new Error('ephemeralMessageId is required'));
4311
+
4312
+ const regexAttach = /attach:\/\/.+/;
4313
+
4314
+ if (typeof media.media === 'string' && regexAttach.test(media.media)) {
4315
+ const opts = { qs: form };
4316
+ opts.formData = {};
4317
+
4318
+ const payload = Object.assign({}, media);
4319
+ delete payload.media;
4320
+
4321
+ try {
4322
+ const attachName = String(0);
4323
+ const [formData] = this._formatSendData(
4324
+ attachName,
4325
+ media.media.replace('attach://', ''),
4326
+ media.fileOptions
4327
+ );
4328
+
4329
+ if (formData) {
4330
+ opts.formData[attachName] = formData[attachName];
4331
+ payload.media = `attach://${attachName}`;
4332
+ } else {
4333
+ throw new errors.FatalError(`Failed to process the replacement action for your ${media.type}`);
4334
+ }
4335
+ } catch (ex) {
4336
+ return Promise.reject(ex);
4337
+ }
4338
+
4339
+ opts.qs.chat_id = chatId;
4340
+ opts.qs.ephemeral_message_id = ephemeralMessageId;
4341
+ opts.qs.media = stringify(payload);
4342
+
4343
+ return this._request('editEphemeralMessageMedia', opts);
4344
+ }
4345
+
4179
4346
  form.chat_id = chatId;
4180
4347
  form.ephemeral_message_id = ephemeralMessageId;
4181
4348
  form.media = stringify(media);
4349
+
4182
4350
  return this._request('editEphemeralMessageMedia', { form });
4183
4351
  }
4184
4352
 
@@ -23,6 +23,7 @@ class TelegramBotPolling {
23
23
  }
24
24
  this._lastUpdate = 0;
25
25
  this._lastRequest = null;
26
+ this._currentRequest = null;
26
27
  this._abort = false;
27
28
  this._pollingTimeout = null;
28
29
  }
@@ -60,11 +61,15 @@ class TelegramBotPolling {
60
61
  return Promise.resolve();
61
62
  }
62
63
  const lastRequest = this._lastRequest;
64
+ const currentRequest = this._currentRequest;
63
65
  this._lastRequest = null;
66
+ this._currentRequest = null;
64
67
  clearTimeout(this._pollingTimeout);
65
68
  if (options.cancel) {
66
69
  const reason = options.reason || 'Polling stop';
67
- lastRequest.cancel(reason);
70
+ if (currentRequest && typeof currentRequest.cancel === 'function') {
71
+ currentRequest.cancel(reason);
72
+ }
68
73
  return Promise.resolve();
69
74
  }
70
75
  this._abort = true;
@@ -98,8 +103,9 @@ class TelegramBotPolling {
98
103
  * @private
99
104
  */
100
105
  _polling() {
101
- this._lastRequest = this
102
- ._getUpdates()
106
+ const currentRequest = this._getUpdates();
107
+ this._currentRequest = currentRequest;
108
+ this._lastRequest = currentRequest
103
109
  .then(updates => {
104
110
  this._lastUpdate = Date.now();
105
111
  debug('polling data %j', updates);
@@ -3,7 +3,9 @@ const debug = require('debug')('@zero-bot.net/tg-bot-api');
3
3
  const https = require('https');
4
4
  const http = require('http');
5
5
  const fs = require('fs');
6
- const bl = require('bl');
6
+
7
+ /** Default maximum accepted webhook body size (10 MiB). */
8
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
7
9
 
8
10
  class TelegramBotWebHook {
9
11
  /**
@@ -18,11 +20,13 @@ class TelegramBotWebHook {
18
20
  this.options.port = this.options.port || 8443;
19
21
  this.options.https = this.options.https || {};
20
22
  this.options.healthEndpoint = this.options.healthEndpoint || '/healthz';
23
+ this.options.maxBodySize = this.options.maxBodySize || DEFAULT_MAX_BODY_SIZE;
21
24
  this._healthRegex = new RegExp(this.options.healthEndpoint);
22
25
  this._webServer = null;
23
26
  this._open = false;
24
27
  this._requestListener = this._requestListener.bind(this);
25
28
  this._parseBody = this._parseBody.bind(this);
29
+ this._collectBody = this._collectBody.bind(this);
26
30
 
27
31
  if (this.options.key && this.options.cert) {
28
32
  debug('HTTPS WebHook enabled (by key/cert)');
@@ -104,6 +108,40 @@ class TelegramBotWebHook {
104
108
  return this.bot.emit('webhook_error', error);
105
109
  }
106
110
 
111
+ /**
112
+ * Buffer an incoming request body, enforcing the configured size limit.
113
+ * @private
114
+ * @param {http.IncomingMessage} req
115
+ * @param {Function} callback `(error, body)`
116
+ */
117
+ _collectBody(req, callback) {
118
+ const chunks = [];
119
+ let size = 0;
120
+ let done = false;
121
+
122
+ const finish = (error, body) => {
123
+ if (done) return;
124
+ done = true;
125
+ callback(error, body);
126
+ };
127
+
128
+ req.on('data', (chunk) => {
129
+ if (done) return undefined;
130
+ size += chunk.length;
131
+ if (size > this.options.maxBodySize) {
132
+ // Stop reading; the request listener answers with 413.
133
+ req.pause();
134
+ return finish(new errors.FatalError('WebHook request body exceeds maxBodySize'));
135
+ }
136
+ chunks.push(chunk);
137
+ return undefined;
138
+ });
139
+
140
+ req.on('end', () => finish(null, Buffer.concat(chunks)));
141
+ req.on('error', (error) => finish(error));
142
+ return undefined;
143
+ }
144
+
107
145
  /**
108
146
  * Handle request body by passing it to 'callback'
109
147
  * @private
@@ -139,9 +177,20 @@ class TelegramBotWebHook {
139
177
  res.statusCode = 418; // I'm a teabot!
140
178
  res.end();
141
179
  } else {
142
- req
143
- .pipe(bl(this._parseBody))
144
- .on('finish', () => res.end('OK'));
180
+ this._collectBody(req, (error, body) => {
181
+ if (res.writableEnded) return undefined;
182
+ if (error) {
183
+ const tooLarge = /maxBodySize/.test(error.message);
184
+ debug('WebHook body rejected: %s', error.message);
185
+ res.statusCode = tooLarge ? 413 : 400;
186
+ res.setHeader('Connection', 'close');
187
+ res.end(tooLarge ? 'Payload Too Large' : 'Bad Request');
188
+ return undefined;
189
+ }
190
+ this._parseBody(null, body);
191
+ res.end('OK');
192
+ return undefined;
193
+ });
145
194
  }
146
195
  } else if (this._healthRegex.test(req.url)) {
147
196
  debug('WebHook health check passed');
@@ -1,68 +0,0 @@
1
- <!--
2
- This template includes three sections:
3
- 1. Bug reporting
4
- 2. Feature request
5
- 3. Question
6
-
7
- Please remove sections that do not apply to your issue
8
- -->
9
-
10
-
11
-
12
- <!--********************************************************************
13
- Reporting a Bug.
14
- *********************************************************************-->
15
-
16
- > Bug Report
17
-
18
- I have read:
19
-
20
- * [Usage information](https://github.com/ZeroBot-net/tg-bot-api/tree/master/doc/usage.md)
21
- * [Help information](https://github.com/ZeroBot-net/tg-bot-api/tree/master/doc/help.md)
22
-
23
- I am using the latest version of the library.
24
-
25
- ### Expected Behavior
26
-
27
- <!-- Explain what you are trying to achieve -->
28
-
29
- ### Actual Behavior
30
-
31
- <!-- Explain what happens, contrary to what you expected -->
32
-
33
- ### Steps to reproduce the Behavior
34
-
35
- <!-- Explain how we can reproduce the bug -->
36
-
37
-
38
-
39
- <!--********************************************************************
40
- Feature Request.
41
- *********************************************************************-->
42
-
43
- > Feature Request
44
-
45
- I have:
46
-
47
- * searched for such a feature request (https://github.com/ZeroBot-net/tg-bot-api/labels/enhancement) and found none
48
-
49
- ### Introduction
50
-
51
- <!-- Describe what value this feature would add, and in which use case,
52
- or scenario -->
53
-
54
- ### Example
55
-
56
- <!-- A code snippet of how this feature would work, were it already
57
- implemented -->
58
-
59
-
60
-
61
- <!--********************************************************************
62
- Question.
63
- *********************************************************************-->
64
-
65
- > Question
66
-
67
- <!-- Ask your question here. Please be precise, adding as much detail
68
- as necessary. Also, add a code snippet(s) if possible. -->
@@ -1,23 +0,0 @@
1
- <!--
2
- Mark whichever option below applies to this PR.
3
- For example, if your PR passes all tests, you would mark the option as so:
4
- - [x] All tests pass
5
- Note the 'x' in between the square brackets '[]'
6
- -->
7
- - [ ] All tests pass
8
- - [ ] I have run `npm run doc`
9
-
10
- ### Description
11
-
12
- <!-- Explain what you are trying to achieve with this PR -->
13
-
14
- ### References
15
-
16
- <!--
17
- Add references to other documents/pages that are relevant to this
18
- PR, such as related issues, documentation, etc.
19
-
20
- For example,
21
- * Issue #1: https://github.com/ZeroBot-net/tg-bot-api/issues/1
22
- * Telegram Bot API - Getting updates: https://core.telegram.org/bots/api#getting-updates
23
- -->