xzcgram 0.0.1 → 0.0.3

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.

Potentially problematic release.


This version of xzcgram might be problematic. Click here for more details.

package/src/context.js CHANGED
@@ -33,12 +33,83 @@ class Context {
33
33
  this.event = event;
34
34
  /** Raw GramJS message object */
35
35
  this.message = event.message;
36
- /** Chat/peer id where the message was sent */
36
+ /** Chat/peer id where the message was sent (numeric, informational) */
37
37
  this.chatId = this.message.chatId;
38
38
  /** Full text of the incoming message */
39
39
  this.text = this.message.message || "";
40
40
  /** Command arguments, e.g. "/start foo bar" -> ["foo", "bar"] */
41
41
  this.args = this.text.split(" ").slice(1);
42
+
43
+ // -- Extra metadata (read-only, straight from the raw message) --
44
+
45
+ /** Id of the user/chat that sent the message */
46
+ this.senderId = this.message.senderId || null;
47
+ /** Unix timestamp the message was sent at */
48
+ this.date = this.message.date || null;
49
+ /** Unix timestamp the message was last edited at, if any */
50
+ this.editDate = this.message.editDate || null;
51
+
52
+ /** true if this message came through an inline bot (@bot query) */
53
+ this.viaBotId = this.message.viaBotId || null;
54
+ /** true if this message came through a Telegram Business connected bot */
55
+ this.viaBusinessBotId = this.message.viaBusinessBotId || null;
56
+
57
+ /** true if the chat is a private 1-on-1 DM */
58
+ this.isPrivate = !!this.message.isPrivate;
59
+ /** true if the chat is a basic group */
60
+ this.isGroup = !!this.message.isGroup;
61
+ /** true if the chat is a channel or supergroup */
62
+ this.isChannel = !!this.message.isChannel;
63
+
64
+ /** Id of the message this one is replying to, if any */
65
+ this.replyToMsgId = this.message.replyTo ? this.message.replyTo.replyToMsgId : null;
66
+ /** true if this message is a reply to another message */
67
+ this.isReply = !!this.replyToMsgId;
68
+
69
+ /** get entities from replied message */
70
+ this.replyMessage = this.message.replyTo ? this.client.getMessages(this.chatId, { ids: [this.replyToMsgId] }) : [];
71
+ /** Forward header info, if this message was forwarded */
72
+ this.fwdFrom = this.message.fwdFrom || null;
73
+ /** true if this message was forwarded from elsewhere */
74
+ this.isForwarded = !!this.fwdFrom;
75
+
76
+ /** Message entities (bold, links, mentions, etc.) */
77
+ this.entities = this.message.entities || [];
78
+
79
+ /** Raw media object attached to the message, if any (photo/video/doc/etc.) */
80
+ this.media = this.message.media || null;
81
+ /** true if the message has any attached media */
82
+ this.hasMedia = !!this.message.media;
83
+ /** Short class name of the attached media, e.g. "MessageMediaPhoto" */
84
+ this.mediaType = this.message.media ? this.message.media.className : null;
85
+
86
+ /** true if the message was sent silently (no notification) */
87
+ this.silent = !!this.message.silent;
88
+ /** true if this message was sent by the account this client is logged into */
89
+ this.out = !!this.message.out;
90
+ /** true if the message is pinned */
91
+ this.pinned = !!this.message.pinned;
92
+ /** Grouped media id, if this message is part of an album */
93
+ this.groupedId = this.message.groupedId || null;
94
+ }
95
+
96
+
97
+ /**
98
+ * Resolves the correct peer to send to/act on.
99
+ * Uses message.getInputChat() first, since it carries the access_hash
100
+ * from the incoming update — this avoids "Could not find the input
101
+ * entity" errors that happen when using a raw numeric chatId for a
102
+ * user/chat the client hasn't cached yet (e.g. first DM from someone new).
103
+ * Falls back to the raw chatId if getInputChat() can't resolve anything.
104
+ */
105
+ async peer() {
106
+ try {
107
+ const inputChat = await this.message.getInputChat();
108
+ if (inputChat) return inputChat;
109
+ } catch (err) {
110
+ // fall through to raw chatId below
111
+ }
112
+ return this.chatId;
42
113
  }
43
114
 
44
115
  // ---------------------------------------------------------------------
@@ -46,13 +117,15 @@ class Context {
46
117
  // ---------------------------------------------------------------------
47
118
 
48
119
  /** Reply in the same chat the message came from. */
49
- reply(text, opts = {}) {
50
- return this.client.sendMessage(this.chatId, { message: text, ...opts });
120
+ async reply(text, opts = {}) {
121
+ const peer = await this.peer();
122
+ return this.client.sendMessage(peer, { message: text, ...opts });
51
123
  }
52
124
 
53
125
  /** Reply directly to the triggering message (quote-style reply). */
54
- replyQuote(text, opts = {}) {
55
- return this.client.sendMessage(this.chatId, {
126
+ async replyQuote(text, opts = {}) {
127
+ const peer = await this.peer();
128
+ return this.client.sendMessage(peer, {
56
129
  message: text,
57
130
  replyTo: this.message.id,
58
131
  ...opts,
@@ -60,8 +133,9 @@ class Context {
60
133
  }
61
134
 
62
135
  /** Edit a message previously sent in this chat (must be your own message). */
63
- editMessage(messageId, text, opts = {}) {
64
- return this.client.editMessage(this.chatId, {
136
+ async editMessage(messageId, text, opts = {}) {
137
+ const peer = await this.peer();
138
+ return this.client.editMessage(peer, {
65
139
  message: messageId,
66
140
  text,
67
141
  ...opts,
@@ -73,13 +147,15 @@ class Context {
73
147
  // ---------------------------------------------------------------------
74
148
 
75
149
  /** Send a photo. `file` can be a path, Buffer, URL, or existing file id. */
76
- replyWithPhoto(file, opts = {}) {
77
- return this.client.sendFile(this.chatId, { file, ...opts });
150
+ async replyWithPhoto(file, opts = {}) {
151
+ const peer = await this.peer();
152
+ return this.client.sendFile(peer, { file, ...opts });
78
153
  }
79
154
 
80
155
  /** Send a video. Pass `opts.supportsStreaming = true` for streamable playback. */
81
- replyWithVideo(file, opts = {}) {
82
- return this.client.sendFile(this.chatId, {
156
+ async replyWithVideo(file, opts = {}) {
157
+ const peer = await this.peer();
158
+ return this.client.sendFile(peer, {
83
159
  file,
84
160
  supportsStreaming: true,
85
161
  ...opts,
@@ -87,23 +163,27 @@ class Context {
87
163
  }
88
164
 
89
165
  /** Send a round "video note" (the circular video bubble). */
90
- replyWithVideoNote(file, opts = {}) {
91
- return this.client.sendFile(this.chatId, { file, videoNote: true, ...opts });
166
+ async replyWithVideoNote(file, opts = {}) {
167
+ const peer = await this.peer();
168
+ return this.client.sendFile(peer, { file, videoNote: true, ...opts });
92
169
  }
93
170
 
94
171
  /** Send an audio file (music, shown with player + duration/title). */
95
- replyWithAudio(file, opts = {}) {
96
- return this.client.sendFile(this.chatId, { file, ...opts });
172
+ async replyWithAudio(file, opts = {}) {
173
+ const peer = await this.peer();
174
+ return this.client.sendFile(peer, { file, ...opts });
97
175
  }
98
176
 
99
177
  /** Send a voice note (the waveform bubble). */
100
- replyWithVoice(file, opts = {}) {
101
- return this.client.sendFile(this.chatId, { file, voiceNote: true, ...opts });
178
+ async replyWithVoice(file, opts = {}) {
179
+ const peer = await this.peer();
180
+ return this.client.sendFile(peer, { file, voiceNote: true, ...opts });
102
181
  }
103
182
 
104
183
  /** Send any file as a generic document. */
105
- replyWithDocument(file, opts = {}) {
106
- return this.client.sendFile(this.chatId, {
184
+ async replyWithDocument(file, opts = {}) {
185
+ const peer = await this.peer();
186
+ return this.client.sendFile(peer, {
107
187
  file,
108
188
  forceDocument: true,
109
189
  ...opts,
@@ -111,13 +191,15 @@ class Context {
111
191
  }
112
192
 
113
193
  /** Send a sticker (.webp/.tgs file or existing file reference). */
114
- replyWithSticker(file, opts = {}) {
115
- return this.client.sendFile(this.chatId, { file, ...opts });
194
+ async replyWithSticker(file, opts = {}) {
195
+ const peer = await this.peer();
196
+ return this.client.sendFile(peer, { file, ...opts });
116
197
  }
117
198
 
118
199
  /** Send an animated GIF. */
119
- replyWithAnimation(file, opts = {}) {
120
- return this.client.sendFile(this.chatId, {
200
+ async replyWithAnimation(file, opts = {}) {
201
+ const peer = await this.peer();
202
+ return this.client.sendFile(peer, {
121
203
  file,
122
204
  attributes: [new Api.DocumentAttributeAnimated()],
123
205
  ...opts,
@@ -125,8 +207,9 @@ class Context {
125
207
  }
126
208
 
127
209
  /** Send multiple files as an album/media group. `files` is an array. */
128
- replyWithMediaGroup(files, opts = {}) {
129
- return this.client.sendFile(this.chatId, { file: files, ...opts });
210
+ async replyWithMediaGroup(files, opts = {}) {
211
+ const peer = await this.peer();
212
+ return this.client.sendFile(peer, { file: files, ...opts });
130
213
  }
131
214
 
132
215
  // ---------------------------------------------------------------------
@@ -139,8 +222,9 @@ class Context {
139
222
  * @param {Array<Array<{text: string, data?: string, url?: string}>>} rows
140
223
  * @param {object} [opts]
141
224
  */
142
- replyWithButtons(text, rows, opts = {}) {
143
- return this.client.sendMessage(this.chatId, {
225
+ async replyWithButtons(text, rows, opts = {}) {
226
+ const peer = await this.peer();
227
+ return this.client.sendMessage(peer, {
144
228
  message: text,
145
229
  buttons: buildButtons(rows, true),
146
230
  ...opts,
@@ -153,8 +237,9 @@ class Context {
153
237
  * @param {Array<Array<{text: string}>>} rows
154
238
  * @param {object} [opts]
155
239
  */
156
- replyWithKeyboard(text, rows, opts = {}) {
157
- return this.client.sendMessage(this.chatId, {
240
+ async replyWithKeyboard(text, rows, opts = {}) {
241
+ const peer = await this.peer();
242
+ return this.client.sendMessage(peer, {
158
243
  message: text,
159
244
  buttons: buildButtons(rows, false),
160
245
  ...opts,
@@ -167,19 +252,33 @@ class Context {
167
252
  * @param {string[]} answers
168
253
  * @param {object} [opts] e.g. { multipleChoice: true, quiz: false }
169
254
  */
170
- replyWithPoll(question, answers, opts = {}) {
255
+ async replyWithPoll(question, answers, opts = {}) {
256
+ const {
257
+ publicVoters = true,
258
+ multipleChoice = true,
259
+ quiz = false
260
+ } = opts;
261
+ const peer = await this.peer();
171
262
  return this.client.invoke(
172
263
  new Api.messages.SendMedia({
173
- peer: this.chatId,
264
+ peer,
174
265
  media: new Api.InputMediaPoll({
175
266
  poll: new Api.Poll({
176
267
  id: BigInt(Date.now()),
177
- question,
268
+ question: new Api.TextWithEntities({
269
+ text: question,
270
+ entities: [],
271
+ }),
178
272
  answers: answers.map(
179
273
  (text, i) =>
180
- new Api.PollAnswer({ text, option: Buffer.from([i]) })
274
+ new Api.PollAnswer({
275
+ text: new Api.TextWithEntities({ text, entities: [] }),
276
+ option: Buffer.from([i]),
277
+ })
181
278
  ),
182
- multipleChoice: !!opts.multipleChoice,
279
+ multipleChoice: !!multipleChoice,
280
+ publicVoters,
281
+ ...opts
183
282
  }),
184
283
  }),
185
284
  message: "",
@@ -189,25 +288,27 @@ class Context {
189
288
  }
190
289
 
191
290
  /** Send a geographic location. */
192
- replyWithLocation(latitude, longitude, opts = {}) {
291
+ async replyWithLocation(latitude, longitude, opts = {}) {
292
+ const peer = await this.peer();
193
293
  return this.client.invoke(
194
294
  new Api.messages.SendMedia({
195
- peer: this.chatId,
295
+ peer,
196
296
  media: new Api.InputMediaGeoPoint({
197
297
  geoPoint: new Api.InputGeoPoint({ lat: latitude, long: longitude }),
198
298
  }),
199
299
  message: "",
200
- randomId: BigInt(Date.now()),
300
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
201
301
  ...opts,
202
302
  })
203
303
  );
204
304
  }
205
-
305
+
206
306
  /** Send a contact card. */
207
- replyWithContact(phoneNumber, firstName, lastName = "", opts = {}) {
307
+ async replyWithContact(phoneNumber, firstName, lastName = "", opts = {}) {
308
+ const peer = await this.peer();
208
309
  return this.client.invoke(
209
310
  new Api.messages.SendMedia({
210
- peer: this.chatId,
311
+ peer,
211
312
  media: new Api.InputMediaContact({
212
313
  phoneNumber,
213
314
  firstName,
@@ -215,56 +316,58 @@ class Context {
215
316
  vcard: "",
216
317
  }),
217
318
  message: "",
218
- randomId: BigInt(Date.now()),
319
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
219
320
  ...opts,
220
321
  })
221
322
  );
222
323
  }
223
324
 
224
325
  /** Send an animated dice/emoji reaction (🎲 🎯 🏀 ⚽ 🎰 🎳). */
225
- replyWithDice(emoji = "🎰", opts = {}) {
326
+ async replyWithDice(emoji = "🎰", opts = {}) {
327
+ const peer = await this.peer();
226
328
  return this.client.invoke(
227
329
  new Api.messages.SendMedia({
228
- peer: this.chatId,
330
+ peer,
229
331
  media: new Api.InputMediaDice({ emoticon: emoji }),
230
332
  message: "",
231
- randomId: BigInt(Date.now()),
333
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
232
334
  ...opts,
233
335
  })
234
336
  );
235
337
  }
236
338
 
237
- // ---------------------------------------------------------------------
238
- // Chat / message management
239
- // ---------------------------------------------------------------------
240
-
241
- /** Delete the triggering message (revokes for everyone by default). */
242
- deleteMessage() {
243
- return this.client.deleteMessages(this.chatId, [this.message.id], {
339
+ /** Delete a message (defaults to the triggering message; revokes for everyone). */
340
+ async deleteMessage(id = this.message.id) {
341
+ const peer = await this.peer();
342
+ return this.client.deleteMessages(peer, [id], {
244
343
  revoke: true,
245
344
  });
246
345
  }
247
346
 
248
- /** Forward the triggering message to another chat. */
249
- forwardMessage(toChatId) {
347
+ /** Forward a message (defaults to the triggering message) to another chat. */
348
+ async forwardMessage(toChatId, id = this.message.id) {
349
+ const peer = await this.peer();
250
350
  return this.client.forwardMessages(toChatId, {
251
- messages: [this.message.id],
252
- fromPeer: this.chatId,
351
+ messages: [id],
352
+ fromPeer: peer,
253
353
  });
254
354
  }
255
355
 
256
- /** Pin the triggering message in the current chat. */
257
- pinMessage(opts = {}) {
258
- return this.client.pinMessage(this.chatId, this.message.id, opts);
356
+ /** Pin a message (defaults to the triggering message) in the current chat. */
357
+ async pinMessage(id = this.message.id, opts = {}) {
358
+ const peer = await this.peer();
359
+ return this.client.pinMessage(peer, id, opts);
259
360
  }
260
361
 
261
- /** Unpin the triggering message in the current chat. */
262
- unpinMessage() {
263
- return this.client.unpinMessage(this.chatId, this.message.id);
362
+ /** Unpin a message (defaults to the triggering message) in the current chat. */
363
+ async unpinMessage(id = this.message.id) {
364
+ const peer = await this.peer();
365
+ return this.client.unpinMessage(peer, id);
264
366
  }
265
367
 
266
368
  /** Show the "typing..." / "sending photo..." indicator. */
267
- sendChatAction(action = "typing") {
369
+ async sendChatAction(action = "typing") {
370
+ const peer = await this.peer();
268
371
  const actions = {
269
372
  typing: new Api.SendMessageTypingAction(),
270
373
  photo: new Api.SendMessageUploadPhotoAction({ progress: 0 }),
@@ -275,7 +378,7 @@ class Context {
275
378
  };
276
379
  return this.client.invoke(
277
380
  new Api.messages.SetTyping({
278
- peer: this.chatId,
381
+ peer,
279
382
  action: actions[action] || actions.typing,
280
383
  })
281
384
  );
@@ -290,6 +393,663 @@ class Context {
290
393
  getChat() {
291
394
  return this.message.getChat();
292
395
  }
396
+
397
+ /**
398
+ * Send a message "via @bot" — i.e. runs an inline query against a bot
399
+ * that supports inline mode, then sends one of its results. This is
400
+ * the only way to get the "via @bot" label on a message; you can't set
401
+ * `viaBotId` manually on a normal sendMessage/sendFile call.
402
+ *
403
+ * @param {string} bot - the bot's @username (with or without the @)
404
+ * @param {string} [query=""] - the inline query text to send to the bot
405
+ * @param {object} [opts]
406
+ * @param {number} [opts.resultIndex=0] - which result to pick from the list
407
+ * @param {boolean} [opts.hideVia=false] - hide the "via @bot" label
408
+ * @param {number} [opts.retries=2] - retry count if the bot times out
409
+ * (BOT_RESPONSE_TIMEOUT) or is briefly unreachable — this is a
410
+ * bot-side issue, not something the query itself can prevent
411
+ *
412
+ * @example
413
+ * // equivalent of typing "@pic cats" and sending the first result
414
+ * await ctx.replyViaBot("pic", "cats");
415
+ */
416
+ async replyViaBot(bot, query = "", opts = {}) {
417
+ const peer = await this.peer();
418
+ const retries = opts.retries ?? 2;
419
+
420
+ let results;
421
+ let lastErr;
422
+ for (let attempt = 0; attempt <= retries; attempt++) {
423
+ try {
424
+ results = await this.client.inlineQuery(bot, query, peer);
425
+ lastErr = null;
426
+ break;
427
+ } catch (err) {
428
+ lastErr = err;
429
+ const retriable =
430
+ err?.errorMessage === "BOT_RESPONSE_TIMEOUT" ||
431
+ err?.message?.includes("BOT_RESPONSE_TIMEOUT");
432
+ if (!retriable || attempt === retries) break;
433
+ // Small backoff before asking the bot again.
434
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
435
+ }
436
+ }
437
+
438
+ if (lastErr) {
439
+ throw new Error(
440
+ `replyViaBot: @${bot} didn't respond in time after ${retries + 1} ` +
441
+ `attempt(s) (${lastErr.errorMessage || lastErr.message}). ` +
442
+ `This means the bot itself is slow/offline, not a bug in the query.`
443
+ );
444
+ }
445
+
446
+ if (!results || results.length === 0) {
447
+ throw new Error(
448
+ `replyViaBot: @${bot} returned no inline results for "${query}"`
449
+ );
450
+ }
451
+
452
+ const index = opts.resultIndex || 0;
453
+ const chosen = results[index];
454
+ if (!chosen) {
455
+ throw new Error(
456
+ `replyViaBot: no result at index ${index} (got ${results.length} results)`
457
+ );
458
+ }
459
+
460
+ return chosen.click({
461
+ entity: peer,
462
+ hideVia: !!opts.hideVia,
463
+ });
464
+ }
465
+
466
+ // ---------------------------------------------------------------------
467
+ // More media / interaction helpers
468
+ // ---------------------------------------------------------------------
469
+
470
+ /**
471
+ * Send a venue — a location pinned with a name and address (e.g. a
472
+ * restaurant or landmark), like the "Location > Venue" picker in-app.
473
+ */
474
+ async replyWithVenue(latitude, longitude, title, address, opts = {}) {
475
+ const peer = await this.peer();
476
+ return this.client.invoke(
477
+ new Api.messages.SendMedia({
478
+ peer,
479
+ media: new Api.InputMediaVenue({
480
+ geoPoint: new Api.InputGeoPoint({ lat: latitude, long: longitude }),
481
+ title,
482
+ address,
483
+ provider: opts.provider || "",
484
+ venueId: opts.venueId || "",
485
+ venueType: opts.venueType || "",
486
+ }),
487
+ message: "",
488
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
489
+ })
490
+ );
491
+ }
492
+
493
+ /**
494
+ * Send a live location that updates in real time for `period` seconds
495
+ * (Telegram allows 60–86400). Use client.editMessage(...) with a new
496
+ * InputMediaGeoLive afterwards to push position updates.
497
+ */
498
+ async replyWithLiveLocation(latitude, longitude, period = 900, opts = {}) {
499
+ const peer = await this.peer();
500
+ return this.client.invoke(
501
+ new Api.messages.SendMedia({
502
+ peer,
503
+ media: new Api.InputMediaGeoLive({
504
+ geoPoint: new Api.InputGeoPoint({ lat: latitude, long: longitude }),
505
+ period,
506
+ heading: opts.heading,
507
+ proximityNotificationRadius: opts.proximityNotificationRadius,
508
+ }),
509
+ message: "",
510
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
511
+ })
512
+ );
513
+ }
514
+
515
+ /**
516
+ * React to the triggering message with an emoji (❤️ 👍 🔥 🎉 etc).
517
+ * Pass an empty string or `null` to remove your reaction instead.
518
+ */
519
+ async react(id = this.message.id, emoji = "👍", opts = {}) {
520
+ const peer = await this.peer();
521
+ return this.client.invoke(
522
+ new Api.messages.SendReaction({
523
+ peer,
524
+ msgId: id,
525
+ reaction: emoji ? [new Api.ReactionEmoji({ emoticon: emoji })] : [],
526
+ big: !!opts.big,
527
+ addToRecent: opts.addToRecent !== false,
528
+ })
529
+ );
530
+ }
531
+
532
+ /**
533
+ * Re-send the triggering message's content (text or media) to another
534
+ * chat, WITHOUT the "Forwarded from" header — unlike forwardMessage().
535
+ * Closest equivalent to Bot API's "copy message".
536
+ */
537
+ async copyMessage(toChatId, opts = {}) {
538
+ if (this.message.media) {
539
+ return this.client.sendFile(toChatId, {
540
+ file: this.message.media,
541
+ caption: this.text,
542
+ ...opts,
543
+ });
544
+ }
545
+ return this.client.sendMessage(toChatId, { message: this.text, ...opts });
546
+ }
547
+
548
+ /** Mark the current chat as read, up to (and including) this message. */
549
+ async markAsRead(userId) {
550
+ const peer = userId || await this.peer();
551
+ return this.client.markAsRead(peer, this.message);
552
+ }
553
+
554
+ /**
555
+ * Download the media attached to the triggering message (photo, video,
556
+ * document, voice note, etc). Resolves to a Buffer by default, or writes
557
+ * straight to disk if you pass `opts.outputFile: "path/to/save"`.
558
+ */
559
+ async downloadMedia(opts = {}) {
560
+ if (!this.message.media) {
561
+ throw new Error("downloadMedia: the triggering message has no media");
562
+ }
563
+ return this.client.downloadMedia(this.message, opts);
564
+ }
565
+
566
+ /** Edit the media of a message you previously sent (e.g. swap out a photo). */
567
+ async editMessageMedia(messageId, file, opts = {}) {
568
+ const peer = await this.peer();
569
+ return this.client.editMessage(peer, {
570
+ message: messageId,
571
+ file,
572
+ ...opts,
573
+ });
574
+ }
575
+
576
+ // ---------------------------------------------------------------------
577
+ // Voice / text / poll utilities
578
+ // ---------------------------------------------------------------------
579
+
580
+ /**
581
+ * Transcribe the voice note attached to the triggering message using
582
+ * Telegram's built-in transcription. Resolves to
583
+ * `{ text, transcriptionId, pending, trialRemainsNum, trialRemainsUntilDate }`.
584
+ * If `pending` is true, the final text arrives later via an
585
+ * `UpdateTranscribedAudio` update rather than this call.
586
+ */
587
+ async transcribeVoice(id = this.message.id) {
588
+ const peer = await this.peer();
589
+ return this.client.invoke(
590
+ new Api.messages.TranscribeAudio({ peer, msgId: id })
591
+ );
592
+ }
593
+
594
+ /** Translate the triggering message's text into another language (e.g. "en", "id"). */
595
+ async translateText(text, toLang) {
596
+ return this.client.invoke(
597
+ new Api.messages.TranslateText({
598
+ text: [new Api.TextWithEntities({ text, entities: [] })],
599
+ toLang,
600
+ })
601
+ );
602
+ }
603
+
604
+ /**
605
+ * Vote on the poll attached to the triggering message.
606
+ * @param {number|number[]} options - option index (or indexes, for
607
+ * multiple-choice polls), 0-based in the order the poll was created.
608
+ */
609
+ async votePoll(id = this.message.id, options) {
610
+ const peer = await this.peer();
611
+ const optionBytes = (Array.isArray(options) ? options : [options]).map(
612
+ (i) => Buffer.from([i])
613
+ );
614
+ return this.client.invoke(
615
+ new Api.messages.SendVote({
616
+ peer,
617
+ msgId: id,
618
+ options: optionBytes,
619
+ })
620
+ );
621
+ }
622
+
623
+ /** Retract your vote on the triggering message's poll. */
624
+ async retractVote(id = this.message.id) {
625
+ const peer = await this.peer();
626
+ return this.client.invoke(
627
+ new Api.messages.SendVote({ peer, msgId: id, options: [] })
628
+ );
629
+ }
630
+
631
+ // ---------------------------------------------------------------------
632
+ // Contacts / history
633
+ // ---------------------------------------------------------------------
634
+
635
+ /** Block the sender of the triggering message. */
636
+ async blockUser(userId) {
637
+ const senderPeer = userId || await this.message.getInputSender();
638
+ return this.client.invoke(new Api.contacts.Block({ id: senderPeer }));
639
+ }
640
+
641
+ /** Unblock the sender of the triggering message. */
642
+ async unblockUser(userId) {
643
+ const senderPeer = userId || await this.message.getInputSender();
644
+ return this.client.invoke(new Api.contacts.Unblock({ id: senderPeer }));
645
+ }
646
+
647
+ /** Fetch the most recent messages in the current chat. */
648
+ async getHistory(userId, limit = 20, opts = {}) {
649
+ const peer = userId || await this.peer();
650
+ return this.client.getMessages(peer, { limit, ...opts });
651
+ }
652
+
653
+ /** Search for messages containing `query` in the current chat. */
654
+ async searchMessages(query, opts = {}) {
655
+ const peer = await this.peer();
656
+ return this.client.getMessages(peer, { search: query, ...opts });
657
+ }
658
+
659
+ /** Unpin every pinned message in the current chat. */
660
+ async unpinAllMessages(userId) {
661
+ const peer = userId || await this.peer();
662
+ return this.client.unpinMessage(peer);
663
+ }
664
+
665
+ /** List participants/members of the current chat (groups/channels only). */
666
+ async getParticipants(opts = {}) {
667
+ const peer = await this.peer();
668
+ return this.client.getParticipants(peer, opts);
669
+ }
670
+
671
+ /**
672
+ * Get metadata for a user/chat/channel by @username.
673
+ * Resolves both the basic entity (id, name, username, etc.) and the
674
+ * "full" info (bio for users; description/member count for chats/channels).
675
+ *
676
+ * @param {string} username - with or without the leading @
677
+ * @returns {Promise<{ entity: object, full: object|null }>}
678
+ *
679
+ * @example
680
+ * const { entity, full } = await ctx.getEntityByUsername("durov");
681
+ * console.log(entity.firstName, full.fullUser.about);
682
+ */
683
+ async getEntityByUsername(username) {
684
+ const clean = username.replace(/^@/, "");
685
+ const entity = await this.client.getEntity(clean);
686
+
687
+ let full = null;
688
+ try {
689
+ if (entity.className === "User") {
690
+ full = await this.client.invoke(new Api.users.GetFullUser({ id: entity }));
691
+ } else if (entity.className === "Channel") {
692
+ full = await this.client.invoke(
693
+ new Api.channels.GetFullChannel({ channel: entity })
694
+ );
695
+ } else if (entity.className === "Chat") {
696
+ full = await this.client.invoke(
697
+ new Api.messages.GetFullChat({ chatId: entity.id })
698
+ );
699
+ }
700
+ } catch (err) {
701
+ // full info isn't always available (e.g. restricted/deleted accounts);
702
+ // the basic `entity` above is still returned either way
703
+ }
704
+
705
+ return { entity, full };
706
+ }
707
+
708
+ // ---------------------------------------------------------------------
709
+ // Channel / group management (supergroups & channels — see notes below)
710
+ // ---------------------------------------------------------------------
711
+
712
+ /**
713
+ * Ban a user from the current supergroup/channel (they can't rejoin
714
+ * unless unbanned). For basic (non-super) groups, Telegram doesn't
715
+ * support per-user restrictions — use kickUser() instead.
716
+ * @param {string|number} userId
717
+ * @param {object} [opts] e.g. { untilDate: unixTimestamp } for a timed ban
718
+ */
719
+ async banUser(userId, opts = {}) {
720
+ const peer = await this.peer();
721
+ const participant = await this.client.getInputEntity(userId);
722
+ return this.client.invoke(
723
+ new Api.channels.EditBanned({
724
+ channel: peer,
725
+ participant,
726
+ bannedRights: new Api.ChatBannedRights({
727
+ untilDate: opts.untilDate || 0,
728
+ viewMessages: true,
729
+ sendMessages: true,
730
+ sendMedia: true,
731
+ sendStickers: true,
732
+ sendGifs: true,
733
+ sendGames: true,
734
+ sendInline: true,
735
+ embedLinks: true,
736
+ sendPolls: true,
737
+ changeInfo: true,
738
+ inviteUsers: true,
739
+ pinMessages: true,
740
+ }),
741
+ })
742
+ );
743
+ }
744
+
745
+ /** Clear all restrictions on a user in the current supergroup/channel. */
746
+ async unbanUser(userId) {
747
+ const peer = await this.peer();
748
+ const participant = await this.client.getInputEntity(userId);
749
+ return this.client.invoke(
750
+ new Api.channels.EditBanned({
751
+ channel: peer,
752
+ participant,
753
+ bannedRights: new Api.ChatBannedRights({ untilDate: 0 }),
754
+ })
755
+ );
756
+ }
757
+
758
+ /**
759
+ * Kick a user (remove them, but they CAN rejoin) from a supergroup/channel.
760
+ * For basic groups, this uses messages.DeleteChatUser instead.
761
+ */
762
+ async kickUser(userId) {
763
+ if (this.isChannel) {
764
+ await this.banUser(userId);
765
+ return this.unbanUser(userId);
766
+ }
767
+ const participant = await this.client.getInputEntity(userId);
768
+ return this.client.invoke(
769
+ new Api.messages.DeleteChatUser({ chatId: this.chatId, userId: participant })
770
+ );
771
+ }
772
+
773
+ /**
774
+ * Promote a user to admin in the current supergroup/channel.
775
+ * `rights` overrides the (fairly permissive) defaults below.
776
+ */
777
+ async promoteUser(userId, rights = {}, rank = "") {
778
+ const peer = await this.peer();
779
+ const participant = await this.client.getInputEntity(userId);
780
+ return this.client.invoke(
781
+ new Api.channels.EditAdmin({
782
+ channel: peer,
783
+ userId: participant,
784
+ adminRights: new Api.ChatAdminRights({
785
+ changeInfo: true,
786
+ postMessages: true,
787
+ editMessages: true,
788
+ deleteMessages: true,
789
+ banUsers: true,
790
+ inviteUsers: true,
791
+ pinMessages: true,
792
+ addAdmins: false,
793
+ anonymous: false,
794
+ manageCall: true,
795
+ other: true,
796
+ ...rights,
797
+ }),
798
+ rank,
799
+ })
800
+ );
801
+ }
802
+
803
+ /** Strip a user of all admin rights in the current supergroup/channel. */
804
+ async demoteUser(userId) {
805
+ return this.promoteUser(
806
+ userId,
807
+ {
808
+ changeInfo: false,
809
+ postMessages: false,
810
+ editMessages: false,
811
+ deleteMessages: false,
812
+ banUsers: false,
813
+ inviteUsers: false,
814
+ pinMessages: false,
815
+ addAdmins: false,
816
+ anonymous: false,
817
+ manageCall: false,
818
+ other: false,
819
+ },
820
+ ""
821
+ );
822
+ }
823
+
824
+ /** Invite one or more users to the current supergroup/channel. */
825
+ async inviteToChannel(channelId, userIds) {
826
+ const peer = channelId || await this.peer();
827
+ const users = await Promise.all(
828
+ (Array.isArray(userIds) ? userIds : [userIds]).map((u) =>
829
+ this.client.getInputEntity(u)
830
+ )
831
+ );
832
+ return this.client.invoke(
833
+ new Api.channels.InviteToChannel({ channel: peer, users })
834
+ );
835
+ }
836
+
837
+ /** Rename the current chat/supergroup/channel. */
838
+ async setChatTitle(id, title) {
839
+ const peer = id || await this.peer();
840
+ if (this.isChannel) {
841
+ return this.client.invoke(new Api.channels.EditTitle({ channel: peer, title }));
842
+ }
843
+ return this.client.invoke(
844
+ new Api.messages.EditChatTitle({ chatId: this.chatId, title })
845
+ );
846
+ }
847
+
848
+ /** Change the current chat/supergroup/channel's profile photo. */
849
+ async setChatPhoto(id, file) {
850
+ const peer = id || await this.peer();
851
+ const uploaded = await this.client.uploadFile({ file, workers: 1 });
852
+ const photo = new Api.InputChatUploadedPhoto({ file: uploaded });
853
+ if (this.isChannel) {
854
+ return this.client.invoke(new Api.channels.EditPhoto({ channel: peer, photo }));
855
+ }
856
+ return this.client.invoke(
857
+ new Api.messages.EditChatPhoto({ chatId: this.chatId, photo })
858
+ );
859
+ }
860
+
861
+ // ---------------------------------------------------------------------
862
+ // Stickers & GIFs
863
+ // ---------------------------------------------------------------------
864
+
865
+ /** Look up stickers matching an emoji (e.g. "😂"). */
866
+ async searchStickers(emoji) {
867
+ return this.client.invoke(
868
+ new Api.messages.GetStickers({ emoticon: emoji, hash: BigInt(0) })
869
+ );
870
+ }
871
+
872
+ /** Get full info + document list for a sticker set by its short name. */
873
+ async getStickerSet(shortName) {
874
+ return this.client.invoke(
875
+ new Api.messages.GetStickerSet({
876
+ stickerset: new Api.InputStickerSetShortName({ shortName }),
877
+ hash: 0,
878
+ })
879
+ );
880
+ }
881
+
882
+ /** Search Telegram's global GIF results for a query. */
883
+ async searchGifs(query, offset = "") {
884
+ return this.client.invoke(new Api.messages.SearchGifs({ q: query, offset }));
885
+ }
886
+
887
+ /**
888
+ * Save (or unsave) a GIF document to the user's "Saved GIFs".
889
+ * @param {object} document - an Api.Document, e.g. from searchGifs() results
890
+ * @param {boolean} [unsave=false]
891
+ */
892
+ async saveGif(document, unsave = false) {
893
+ return this.client.invoke(new Api.messages.SaveGif({ id: document, unsave }));
894
+ }
895
+
896
+ // ---------------------------------------------------------------------
897
+ // Privacy & account settings
898
+ // ---------------------------------------------------------------------
899
+
900
+ /**
901
+ * Get your current privacy rules for a given key.
902
+ * @param {"phoneNumber"|"lastSeen"|"chatInvite"|"phoneCall"|"profilePhoto"|"forwards"} [key]
903
+ */
904
+ async getPrivacySettings(key = "phoneNumber") {
905
+ const keyMap = {
906
+ phoneNumber: () => new Api.InputPrivacyKeyPhoneNumber(),
907
+ lastSeen: () => new Api.InputPrivacyKeyStatusTimestamp(),
908
+ chatInvite: () => new Api.InputPrivacyKeyChatInvite(),
909
+ phoneCall: () => new Api.InputPrivacyKeyPhoneCall(),
910
+ profilePhoto: () => new Api.InputPrivacyKeyProfilePhoto(),
911
+ forwards: () => new Api.InputPrivacyKeyForwards(),
912
+ };
913
+ const build = keyMap[key] || keyMap.phoneNumber;
914
+ return this.client.invoke(new Api.account.GetPrivacy({ key: build() }));
915
+ }
916
+
917
+ /** Update your account's first/last name and bio ("about"). */
918
+ async updateProfile(opts = {}) {
919
+ return this.client.invoke(
920
+ new Api.account.UpdateProfile({
921
+ firstName: opts.firstName,
922
+ lastName: opts.lastName,
923
+ about: opts.about,
924
+ })
925
+ );
926
+ }
927
+
928
+ /** Change your account's @username. */
929
+ async updateUsername(username) {
930
+ return this.client.invoke(new Api.account.UpdateUsername({ username }));
931
+ }
932
+
933
+ /** Manually set your account's online/offline status. */
934
+ async setOnlineStatus(online = true) {
935
+ return this.client.invoke(new Api.account.UpdateStatus({ offline: !online }));
936
+ }
937
+
938
+ // ---------------------------------------------------------------------
939
+ // Advanced file uploads
940
+ // ---------------------------------------------------------------------
941
+
942
+ /** Send a photo/video with the "spoiler" blur overlay. */
943
+ async replyWithSpoiler(file, opts = {}) {
944
+ const peer = await this.peer();
945
+ return this.client.sendFile(peer, { file, spoiler: true, ...opts });
946
+ }
947
+
948
+ /** Send a file with a custom thumbnail image. */
949
+ async replyWithThumb(file, thumb, opts = {}) {
950
+ const peer = await this.peer();
951
+ return this.client.sendFile(peer, { file, thumb, ...opts });
952
+ }
953
+
954
+ /**
955
+ * Upload a file with progress tracking, without sending it yet. Returns
956
+ * an uploaded-file handle you can pass as `file` to any replyWith*
957
+ * method — useful for showing upload progress on large files.
958
+ * @param {*} file
959
+ * @param {(uploaded: number, total: number) => void} onProgress
960
+ */
961
+ async uploadWithProgress(file, onProgress) {
962
+ return this.client.uploadFile({
963
+ file,
964
+ workers: 1,
965
+ progressCallback: onProgress,
966
+ });
967
+ }
968
+ // ---------------------------------------------------------------------
969
+ // Channel / Group Actions
970
+ // ---------------------------------------------------------------------
971
+
972
+ /**
973
+ * Join channel/group by username, id, or invite link
974
+ * @param {string | number} channel - "@username", id, or entity
975
+ * @example ctx.joinChannel("XazepysK")
976
+ */
977
+ async joinChannel(id) {
978
+ const entity = await this.client.getInputEntity(id);
979
+ return this.client.invoke(new Api.channels.JoinChannel({ channel: entity }));
980
+ }
981
+
982
+ /**
983
+ * Join private group/channel via invite hash
984
+ * @param {string} hash - hash from t.me/+HASH
985
+ * @example ctx.joinByInvite("AAAAAE2x...")
986
+ */
987
+ async joinByInvite(hash) {
988
+ return this.client.invoke(new Api.messages.ImportChatInvite({ hash }));
989
+ }
990
+
991
+ /**
992
+ * Leave channel/group
993
+ * @param {string | number} channel - "@username", id, or entity. Default: chatId
994
+ */
995
+ async leaveChannel(id = null) {
996
+ const peer = id? await this.client.getInputEntity(id) : await this.peer();
997
+ return this.client.invoke(new Api.channels.LeaveChannel({ channel: peer }));
998
+ }
999
+
1000
+ /**
1001
+ * Get buffer + metadata from media with messageId
1002
+ * @param {number} [messageId] - messageId
1003
+ * @param {object} [opts={}] - opts downloadMedia
1004
+ * @returns {Promise<{buffer: Buffer, metadata: object, filename: string}>}
1005
+ */
1006
+ async saveMediaMessage(messageId = null, opts = {}) {
1007
+ const peer = await this.peer();
1008
+ const targetMsg = messageId ? (await this.client.getMessages(peer, { ids: [messageId] }))[0] : this.message;
1009
+
1010
+ if (!targetMsg) throw new Error(`${messageId} not found`);
1011
+ if (!targetMsg.media) throw new Error("message doesnt have media");
1012
+ const buffer = await this.client.downloadMedia(targetMsg, opts);
1013
+ const media = targetMsg.media;
1014
+ let metadata = {
1015
+ type: targetMsg.media.className,
1016
+ size: null,
1017
+ mimeType: null,
1018
+ fileName: null,
1019
+ width: null,
1020
+ height: null,
1021
+ duration: null,
1022
+ date: targetMsg.date,
1023
+ chatId: targetMsg.chatId,
1024
+ messageId: targetMsg.id,
1025
+ senderId: targetMsg.senderId,
1026
+ };
1027
+ if (media.className === "MessageMediaPhoto") {
1028
+ const biggest = media.photo.sizes[media.photo.sizes.length - 1];
1029
+ metadata.size = biggest?.size || null;
1030
+ metadata.width = biggest?.w || null;
1031
+ metadata.height = biggest?.h || null;
1032
+ metadata.fileName = `photo_${targetMsg.id}.jpg`;
1033
+ }
1034
+
1035
+ if (media.className === "MessageMediaDocument") {
1036
+ const doc = media.document;
1037
+ metadata.size = doc.size;
1038
+ metadata.mimeType = doc.mimeType;
1039
+ metadata.date = doc.date;
1040
+ const attr = doc.attributes.find(a => a.className === "DocumentAttributeFilename");
1041
+ metadata.fileName = attr?.fileName || `file_${targetMsg.id}`;
1042
+ const videoAttr = doc.attributes.find(a => a.className === "DocumentAttributeVideo");
1043
+ if (videoAttr) {
1044
+ metadata.width = videoAttr.w;
1045
+ metadata.height = videoAttr.h;
1046
+ metadata.duration = videoAttr.duration;
1047
+ }
1048
+ const audioAttr = doc.attributes.find(a => a.className === "DocumentAttributeAudio");
1049
+ if (audioAttr) metadata.duration = audioAttr.duration;
1050
+ }
1051
+ return { buffer, metadata, filename: metadata.fileName };
1052
+ }
293
1053
  }
294
1054
 
295
1055
  module.exports = { Context, buildButtons };