xzcgram 0.0.2 → 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
@@ -39,8 +39,61 @@ class Context {
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;
42
94
  }
43
95
 
96
+
44
97
  /**
45
98
  * Resolves the correct peer to send to/act on.
46
99
  * Uses message.getInputChat() first, since it carries the access_hash
@@ -49,7 +102,7 @@ class Context {
49
102
  * user/chat the client hasn't cached yet (e.g. first DM from someone new).
50
103
  * Falls back to the raw chatId if getInputChat() can't resolve anything.
51
104
  */
52
- async _peer() {
105
+ async peer() {
53
106
  try {
54
107
  const inputChat = await this.message.getInputChat();
55
108
  if (inputChat) return inputChat;
@@ -65,13 +118,13 @@ class Context {
65
118
 
66
119
  /** Reply in the same chat the message came from. */
67
120
  async reply(text, opts = {}) {
68
- const peer = await this._peer();
121
+ const peer = await this.peer();
69
122
  return this.client.sendMessage(peer, { message: text, ...opts });
70
123
  }
71
124
 
72
125
  /** Reply directly to the triggering message (quote-style reply). */
73
126
  async replyQuote(text, opts = {}) {
74
- const peer = await this._peer();
127
+ const peer = await this.peer();
75
128
  return this.client.sendMessage(peer, {
76
129
  message: text,
77
130
  replyTo: this.message.id,
@@ -81,7 +134,7 @@ class Context {
81
134
 
82
135
  /** Edit a message previously sent in this chat (must be your own message). */
83
136
  async editMessage(messageId, text, opts = {}) {
84
- const peer = await this._peer();
137
+ const peer = await this.peer();
85
138
  return this.client.editMessage(peer, {
86
139
  message: messageId,
87
140
  text,
@@ -95,13 +148,13 @@ class Context {
95
148
 
96
149
  /** Send a photo. `file` can be a path, Buffer, URL, or existing file id. */
97
150
  async replyWithPhoto(file, opts = {}) {
98
- const peer = await this._peer();
151
+ const peer = await this.peer();
99
152
  return this.client.sendFile(peer, { file, ...opts });
100
153
  }
101
154
 
102
155
  /** Send a video. Pass `opts.supportsStreaming = true` for streamable playback. */
103
156
  async replyWithVideo(file, opts = {}) {
104
- const peer = await this._peer();
157
+ const peer = await this.peer();
105
158
  return this.client.sendFile(peer, {
106
159
  file,
107
160
  supportsStreaming: true,
@@ -111,25 +164,25 @@ class Context {
111
164
 
112
165
  /** Send a round "video note" (the circular video bubble). */
113
166
  async replyWithVideoNote(file, opts = {}) {
114
- const peer = await this._peer();
167
+ const peer = await this.peer();
115
168
  return this.client.sendFile(peer, { file, videoNote: true, ...opts });
116
169
  }
117
170
 
118
171
  /** Send an audio file (music, shown with player + duration/title). */
119
172
  async replyWithAudio(file, opts = {}) {
120
- const peer = await this._peer();
173
+ const peer = await this.peer();
121
174
  return this.client.sendFile(peer, { file, ...opts });
122
175
  }
123
176
 
124
177
  /** Send a voice note (the waveform bubble). */
125
178
  async replyWithVoice(file, opts = {}) {
126
- const peer = await this._peer();
179
+ const peer = await this.peer();
127
180
  return this.client.sendFile(peer, { file, voiceNote: true, ...opts });
128
181
  }
129
182
 
130
183
  /** Send any file as a generic document. */
131
184
  async replyWithDocument(file, opts = {}) {
132
- const peer = await this._peer();
185
+ const peer = await this.peer();
133
186
  return this.client.sendFile(peer, {
134
187
  file,
135
188
  forceDocument: true,
@@ -139,13 +192,13 @@ class Context {
139
192
 
140
193
  /** Send a sticker (.webp/.tgs file or existing file reference). */
141
194
  async replyWithSticker(file, opts = {}) {
142
- const peer = await this._peer();
195
+ const peer = await this.peer();
143
196
  return this.client.sendFile(peer, { file, ...opts });
144
197
  }
145
198
 
146
199
  /** Send an animated GIF. */
147
200
  async replyWithAnimation(file, opts = {}) {
148
- const peer = await this._peer();
201
+ const peer = await this.peer();
149
202
  return this.client.sendFile(peer, {
150
203
  file,
151
204
  attributes: [new Api.DocumentAttributeAnimated()],
@@ -155,7 +208,7 @@ class Context {
155
208
 
156
209
  /** Send multiple files as an album/media group. `files` is an array. */
157
210
  async replyWithMediaGroup(files, opts = {}) {
158
- const peer = await this._peer();
211
+ const peer = await this.peer();
159
212
  return this.client.sendFile(peer, { file: files, ...opts });
160
213
  }
161
214
 
@@ -170,7 +223,7 @@ class Context {
170
223
  * @param {object} [opts]
171
224
  */
172
225
  async replyWithButtons(text, rows, opts = {}) {
173
- const peer = await this._peer();
226
+ const peer = await this.peer();
174
227
  return this.client.sendMessage(peer, {
175
228
  message: text,
176
229
  buttons: buildButtons(rows, true),
@@ -185,7 +238,7 @@ class Context {
185
238
  * @param {object} [opts]
186
239
  */
187
240
  async replyWithKeyboard(text, rows, opts = {}) {
188
- const peer = await this._peer();
241
+ const peer = await this.peer();
189
242
  return this.client.sendMessage(peer, {
190
243
  message: text,
191
244
  buttons: buildButtons(rows, false),
@@ -200,7 +253,12 @@ class Context {
200
253
  * @param {object} [opts] e.g. { multipleChoice: true, quiz: false }
201
254
  */
202
255
  async replyWithPoll(question, answers, opts = {}) {
203
- const peer = await this._peer();
256
+ const {
257
+ publicVoters = true,
258
+ multipleChoice = true,
259
+ quiz = false
260
+ } = opts;
261
+ const peer = await this.peer();
204
262
  return this.client.invoke(
205
263
  new Api.messages.SendMedia({
206
264
  peer,
@@ -218,7 +276,9 @@ class Context {
218
276
  option: Buffer.from([i]),
219
277
  })
220
278
  ),
221
- multipleChoice: !!opts.multipleChoice,
279
+ multipleChoice: !!multipleChoice,
280
+ publicVoters,
281
+ ...opts
222
282
  }),
223
283
  }),
224
284
  message: "",
@@ -229,7 +289,7 @@ class Context {
229
289
 
230
290
  /** Send a geographic location. */
231
291
  async replyWithLocation(latitude, longitude, opts = {}) {
232
- const peer = await this._peer();
292
+ const peer = await this.peer();
233
293
  return this.client.invoke(
234
294
  new Api.messages.SendMedia({
235
295
  peer,
@@ -237,15 +297,15 @@ class Context {
237
297
  geoPoint: new Api.InputGeoPoint({ lat: latitude, long: longitude }),
238
298
  }),
239
299
  message: "",
240
- randomId: BigInt(Date.now()),
300
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
241
301
  ...opts,
242
302
  })
243
303
  );
244
304
  }
245
-
305
+
246
306
  /** Send a contact card. */
247
307
  async replyWithContact(phoneNumber, firstName, lastName = "", opts = {}) {
248
- const peer = await this._peer();
308
+ const peer = await this.peer();
249
309
  return this.client.invoke(
250
310
  new Api.messages.SendMedia({
251
311
  peer,
@@ -256,62 +316,58 @@ class Context {
256
316
  vcard: "",
257
317
  }),
258
318
  message: "",
259
- randomId: BigInt(Date.now()),
319
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
260
320
  ...opts,
261
321
  })
262
322
  );
263
323
  }
264
324
 
265
325
  /** Send an animated dice/emoji reaction (🎲 🎯 🏀 ⚽ 🎰 🎳). */
266
- async replyWithDice(emoji = "🎲", opts = {}) {
267
- const peer = await this._peer();
326
+ async replyWithDice(emoji = "🎰", opts = {}) {
327
+ const peer = await this.peer();
268
328
  return this.client.invoke(
269
329
  new Api.messages.SendMedia({
270
330
  peer,
271
331
  media: new Api.InputMediaDice({ emoticon: emoji }),
272
332
  message: "",
273
- randomId: BigInt(Date.now()),
333
+ randomId: BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)),
274
334
  ...opts,
275
335
  })
276
336
  );
277
337
  }
278
338
 
279
- // ---------------------------------------------------------------------
280
- // Chat / message management
281
- // ---------------------------------------------------------------------
282
-
283
- /** Delete the triggering message (revokes for everyone by default). */
284
- async deleteMessage() {
285
- const peer = await this._peer();
286
- return this.client.deleteMessages(peer, [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], {
287
343
  revoke: true,
288
344
  });
289
345
  }
290
346
 
291
- /** Forward the triggering message to another chat. */
292
- async forwardMessage(toChatId) {
293
- const peer = await this._peer();
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();
294
350
  return this.client.forwardMessages(toChatId, {
295
- messages: [this.message.id],
351
+ messages: [id],
296
352
  fromPeer: peer,
297
353
  });
298
354
  }
299
355
 
300
- /** Pin the triggering message in the current chat. */
301
- async pinMessage(opts = {}) {
302
- const peer = await this._peer();
303
- return this.client.pinMessage(peer, 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);
304
360
  }
305
361
 
306
- /** Unpin the triggering message in the current chat. */
307
- async unpinMessage() {
308
- const peer = await this._peer();
309
- return this.client.unpinMessage(peer, 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);
310
366
  }
311
367
 
312
368
  /** Show the "typing..." / "sending photo..." indicator. */
313
369
  async sendChatAction(action = "typing") {
314
- const peer = await this._peer();
370
+ const peer = await this.peer();
315
371
  const actions = {
316
372
  typing: new Api.SendMessageTypingAction(),
317
373
  photo: new Api.SendMessageUploadPhotoAction({ progress: 0 }),
@@ -337,6 +393,663 @@ class Context {
337
393
  getChat() {
338
394
  return this.message.getChat();
339
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
+ }
340
1053
  }
341
1054
 
342
1055
  module.exports = { Context, buildButtons };