@zero-bot.net/tg-bot-api 1.3.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.d.ts CHANGED
@@ -2688,6 +2688,16 @@ export interface TelegramBotOptions {
2688
2688
  filepath?: boolean;
2689
2689
  /** Set to true for forward-compatibility on unhandled rejections. */
2690
2690
  badRejection?: boolean;
2691
+ /**
2692
+ * Apply latency-oriented network tuning at construction (prefer IPv4 and
2693
+ * shorten Node's IPv6 fallback window). Mutates process-global DNS settings.
2694
+ */
2695
+ ipv4First?: boolean;
2696
+ /**
2697
+ * Warm up the connection (DNS/TCP/TLS) with a `getMe()` call at startup so
2698
+ * the first real request is not a cold start.
2699
+ */
2700
+ prewarm?: boolean;
2691
2701
  }
2692
2702
 
2693
2703
  /** Polling options. */
@@ -3170,6 +3180,14 @@ export default class TelegramBot extends EventEmitter {
3170
3180
 
3171
3181
  /** The types of message updates the library handles. */
3172
3182
  static messageTypes: string[];
3183
+ /**
3184
+ * Apply latency-oriented network tuning (prefer IPv4, shorten Node's IPv6
3185
+ * fallback window). Mutates process-global settings — call once at startup.
3186
+ */
3187
+ static applyNetworkTuning(options?: {
3188
+ ipv4First?: boolean;
3189
+ autoSelectFamilyAttemptTimeout?: number;
3190
+ }): { ipv4First: boolean; autoSelectFamilyAttemptTimeout: number };
3173
3191
 
3174
3192
  // --- Event Overloads --------------------------------------------------------
3175
3193
 
@@ -3362,6 +3380,11 @@ export default class TelegramBot extends EventEmitter {
3362
3380
 
3363
3381
  /** A simple method for testing your bot's authentication token. */
3364
3382
  getMe(form?: FormQueryOptions): Promise<User>;
3383
+ /**
3384
+ * Warm up the connection (DNS/TCP/TLS) with a lightweight `getMe()` call so
3385
+ * the first real request is not a cold start. Resolves with the bot instance.
3386
+ */
3387
+ preheat(options?: { suppressErrors?: boolean }): Promise<this>;
3365
3388
 
3366
3389
  /** Log out from the cloud Bot API server. */
3367
3390
  logOut(form?: FormQueryOptions): Promise<boolean>;
@@ -4999,10 +5022,17 @@ export default class TelegramBot extends EventEmitter {
4999
5022
  answerGuestQuery(guestQueryId: string, text: string, form?: FormQueryOptions): Promise<boolean>;
5000
5023
 
5001
5024
  /**
5002
- * Remove multiple reactions from a message.
5003
- * @see https://core.telegram.org/bots/api#deletemessagereactions
5025
+ * Remove up to 10000 recent reactions in a group or supergroup added by a
5026
+ * given user or chat.
5027
+ *
5028
+ * Pass the user id positionally, or an options object with `user_id` and/or
5029
+ * `actor_chat_id`.
5030
+ * @see https://core.telegram.org/bots/api#deleteallmessagereactions
5004
5031
  */
5005
- deleteAllMessageReactions(chatId: number | string, messageId: number, form?: FormQueryOptions): Promise<boolean>;
5032
+ deleteAllMessageReactions(
5033
+ chatId: number | string,
5034
+ userIdOrOptions?: number | string | FormQueryOptions,
5035
+ ): Promise<boolean>;
5006
5036
 
5007
5037
  /**
5008
5038
  * Remove a reaction from a message.
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');
@@ -224,6 +233,13 @@ class TelegramBot extends EventEmitter {
224
233
  * **if and only if** the Node.js version you're using terminates the
225
234
  * process on unhandled rejections. This option is only for
226
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.
227
243
  * @see https://core.telegram.org/bots/api
228
244
  */
229
245
  constructor(token, options = {}) {
@@ -235,12 +251,17 @@ class TelegramBot extends EventEmitter {
235
251
  this.options.baseApiUrl = options.baseApiUrl || 'https://api.telegram.org';
236
252
  this.options.filepath = (typeof options.filepath === 'undefined') ? true : options.filepath;
237
253
  this.options.badRejection = (typeof options.badRejection === 'undefined') ? false : options.badRejection;
254
+ this.options.request = Object.assign({}, options.request);
238
255
  this._textRegexpCallbacks = [];
239
256
  this._replyListenerId = 0;
240
257
  this._replyListeners = [];
241
258
  this._polling = null;
242
259
  this._webHook = null;
243
260
 
261
+ if (options.ipv4First) {
262
+ applyNetworkTuning({ ipv4First: true });
263
+ }
264
+
244
265
  if (options.polling) {
245
266
  const autoStart = options.polling.autoStart;
246
267
  if (typeof autoStart === 'undefined' || autoStart === true) {
@@ -254,6 +275,23 @@ class TelegramBot extends EventEmitter {
254
275
  this.openWebHook();
255
276
  }
256
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);
257
295
  }
258
296
 
259
297
  /**
@@ -358,7 +396,13 @@ class TelegramBot extends EventEmitter {
358
396
  'link_preview_options',
359
397
  ];
360
398
  for (const field of jsonFields) {
361
- 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') {
362
406
  obj[field] = stringify(obj[field]);
363
407
  }
364
408
  }
@@ -398,13 +442,14 @@ class TelegramBot extends EventEmitter {
398
442
  options.resolveWithFullResponse = true;
399
443
  options.forever = true;
400
444
  debug('HTTP request: %j', options);
401
- return request(options)
445
+ const raw = request(options);
446
+ const promise = raw
402
447
  .then(resp => {
403
448
  let data;
404
449
  try {
405
450
  data = resp.body = JSON.parse(resp.body);
406
- } catch (err) {
407
- throw new errors.ParseError(`Error parsing response: ${resp.body}`, resp);
451
+ } catch (parseError) {
452
+ throw new errors.ParseError(`Error parsing response: ${parseError.message}`, resp);
408
453
  }
409
454
 
410
455
  if (data.ok) {
@@ -417,6 +462,13 @@ class TelegramBot extends EventEmitter {
417
462
  if (error.response) throw error;
418
463
  throw new errors.FatalError(error);
419
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;
420
472
  }
421
473
 
422
474
  /**
@@ -457,7 +509,7 @@ class TelegramBot extends EventEmitter {
457
509
  filename = 'data';
458
510
  }
459
511
  if (!contentType) {
460
- const filetype = fileType(data);
512
+ const filetype = detectFileType(data);
461
513
  if (filetype) {
462
514
  contentType = filetype.mime;
463
515
  const ext = filetype.ext;
@@ -483,7 +535,7 @@ class TelegramBot extends EventEmitter {
483
535
  }
484
536
 
485
537
  filename = filename || 'filename';
486
- contentType = contentType || mime.lookup(filename);
538
+ contentType = contentType || lookupMime(filename);
487
539
  if (process.env.NTBA_FIX_350) {
488
540
  contentType = contentType || 'application/octet-stream';
489
541
  } else {
@@ -1057,6 +1109,34 @@ class TelegramBot extends EventEmitter {
1057
1109
  return this._request('getMe', { form });
1058
1110
  }
1059
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
+
1060
1140
  /**
1061
1141
  * This method log out your bot from the cloud Bot API server before launching the bot locally.
1062
1142
  * You must log out the bot before running it locally, otherwise there is no guarantee that the bot will receive updates.
@@ -1131,7 +1211,7 @@ class TelegramBot extends EventEmitter {
1131
1211
  forwardMessages(chatId, fromChatId, messageIds, form = {}) {
1132
1212
  form.chat_id = chatId;
1133
1213
  form.from_chat_id = fromChatId;
1134
- form.message_ids = messageIds;
1214
+ form.message_ids = stringify(messageIds);
1135
1215
  return this._request('forwardMessages', { form });
1136
1216
  }
1137
1217
 
@@ -4022,14 +4102,15 @@ class TelegramBot extends EventEmitter {
4022
4102
  * Use this method to remove multiple reactions from a message.
4023
4103
  *
4024
4104
  * @param {Number|String} chatId Unique identifier for the target chat or username of the target channel (in the format `@channelusername`)
4025
- * @param {Number} messageId Unique identifier of the target message
4026
- * @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`)
4027
4106
  * @return {Promise} True on success
4028
- * @see https://core.telegram.org/bots/api#deletemessagereactions
4107
+ * @see https://core.telegram.org/bots/api#deleteallmessagereactions
4029
4108
  */
4030
- deleteAllMessageReactions(chatId, messageId, form = {}) {
4109
+ deleteAllMessageReactions(chatId, options = {}) {
4110
+ const form = (typeof options === 'number' || typeof options === 'string')
4111
+ ? { user_id: options }
4112
+ : options;
4031
4113
  form.chat_id = chatId;
4032
- form.message_id = messageId;
4033
4114
  return this._request('deleteAllMessageReactions', { form });
4034
4115
  }
4035
4116
 
@@ -4224,12 +4305,48 @@ class TelegramBot extends EventEmitter {
4224
4305
  * @return {Promise} On success, the edited Message object is returned
4225
4306
  * @see https://core.telegram.org/bots/api#editephemeralmessagemedia
4226
4307
  */
4227
- editEphemeralMessageMedia(chatId, ephemeralMessageId, media, form = {}, fileOptions = {}) {
4308
+ editEphemeralMessageMedia(chatId, ephemeralMessageId, media, form = {}) {
4228
4309
  if (!chatId) return Promise.reject(new Error('chatId is required'));
4229
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
+
4230
4346
  form.chat_id = chatId;
4231
4347
  form.ephemeral_message_id = ephemeralMessageId;
4232
4348
  form.media = stringify(media);
4349
+
4233
4350
  return this._request('editEphemeralMessageMedia', { form });
4234
4351
  }
4235
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
- -->