@cogitator-ai/channels 0.7.6 → 0.8.1

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.
@@ -1,36 +1,154 @@
1
1
  import { createServer } from 'node:http';
2
- const NOT_MODIFIED = 'message is not modified';
2
+ import { adaptMarkdown } from '../formatters/markdown.js';
3
+ /** Telegram's limit for a classic message and for a media caption. */
4
+ const TEXT_LIMIT = 4096;
5
+ const CAPTION_LIMIT = 1024;
6
+ /** Telegram's limit for a rich message's text. */
7
+ const RICH_LIMIT = 32768;
8
+ const CALLBACK_DATA_BYTES = 64;
9
+ const COMMAND_RE = /^[a-z0-9_]{1,32}$/;
3
10
  function errorMessage(err) {
4
11
  return err instanceof Error ? err.message : String(err);
5
12
  }
13
+ function errorCode(err) {
14
+ const code = err?.error_code;
15
+ return typeof code === 'number' ? code : undefined;
16
+ }
6
17
  function isEntityParseError(err) {
7
18
  const msg = errorMessage(err);
8
19
  return msg.includes("can't parse entities") || msg.includes("Bad Request: can't parse");
9
20
  }
10
21
  function isNotModifiedError(err) {
11
- return errorMessage(err).includes(NOT_MODIFIED);
22
+ return errorMessage(err).includes('message is not modified');
23
+ }
24
+ /** A request Telegram refused as malformed or unknown, which a simpler form may get through. */
25
+ function isRefused(err) {
26
+ const code = errorCode(err);
27
+ return code === 400 || code === 404 || isEntityParseError(err);
28
+ }
29
+ /** Telegram answers 404 to a method this Bot API server does not know. */
30
+ function isUnknownMethod(err) {
31
+ return errorCode(err) === 404 || /method not found/i.test(errorMessage(err));
32
+ }
33
+ /** A chat id as Telegram takes it: a number, or a public `@username`. */
34
+ export function telegramChatId(id) {
35
+ return /^-?\d+$/.test(id) ? Number(id) : id;
36
+ }
37
+ function isPrivateChat(id) {
38
+ return /^\d+$/.test(id);
39
+ }
40
+ /** The inline keyboard for rows of buttons. */
41
+ export function telegramKeyboard(rows) {
42
+ return {
43
+ inline_keyboard: rows.map((row) => row.map((button) => {
44
+ const base = { text: button.text, ...(button.style && { style: button.style }) };
45
+ if (button.disabled)
46
+ return { ...base, disabled: {} };
47
+ if (button.url !== undefined)
48
+ return { ...base, url: button.url };
49
+ if (button.copyText !== undefined)
50
+ return { ...base, copy_text: { text: button.copyText } };
51
+ if (button.data !== undefined) {
52
+ if (new TextEncoder().encode(button.data).length > CALLBACK_DATA_BYTES) {
53
+ throw new Error(`Button "${button.text}" carries more than ${CALLBACK_DATA_BYTES} bytes of data`);
54
+ }
55
+ return { ...base, callback_data: button.data };
56
+ }
57
+ throw new Error(`Button "${button.text}" needs data, url, copyText or disabled`);
58
+ })),
59
+ };
60
+ }
61
+ function linkPreviewOptions(preview) {
62
+ if (preview === undefined)
63
+ return undefined;
64
+ if (preview === false)
65
+ return { is_disabled: true };
66
+ return {
67
+ ...(preview.url && { url: preview.url }),
68
+ ...(preview.aboveText && { show_above_text: true }),
69
+ ...(preview.size === 'small' && { prefer_small_media: true }),
70
+ ...(preview.size === 'large' && { prefer_large_media: true }),
71
+ };
12
72
  }
73
+ /** Splits text at line breaks or spaces so every piece fits Telegram's limit. */
74
+ function chunk(text, limit) {
75
+ const pieces = [];
76
+ let rest = text;
77
+ while (rest.length > limit) {
78
+ const window = rest.slice(0, limit);
79
+ const newline = window.lastIndexOf('\n');
80
+ const space = window.lastIndexOf(' ');
81
+ const at = newline >= limit / 2 ? newline + 1 : space > 0 ? space + 1 : limit;
82
+ pieces.push(rest.slice(0, at).trimEnd());
83
+ rest = rest.slice(at);
84
+ }
85
+ if (rest.trim() || pieces.length === 0)
86
+ pieces.push(rest);
87
+ return pieces;
88
+ }
89
+ function sentId(result) {
90
+ const id = result?.message_id;
91
+ return id === undefined ? '' : String(id);
92
+ }
93
+ /**
94
+ * Telegram through grammY, on the current Bot API: rich messages that render standard Markdown,
95
+ * streamed drafts with a stop button, inline buttons with colors and their presses, topics,
96
+ * captions and albums, message effects, link previews, ephemeral messages and command menus.
97
+ * Every call goes through grammY's raw API, so a Bot API feature newer than the installed grammY
98
+ * still works, and `call` reaches any method this class does not wrap.
99
+ */
13
100
  export class TelegramChannel {
14
101
  config;
15
102
  type = 'telegram';
16
103
  handler = null;
104
+ actionHandler = null;
105
+ stopHandler = null;
17
106
  bot = null;
18
107
  grammy = null;
19
108
  server = null;
109
+ /** Set when the Bot API server does not know rich messages, so they are not tried again. */
110
+ richUnavailable = false;
20
111
  constructor(config) {
21
112
  this.config = config;
22
113
  }
114
+ /**
115
+ * With rich messages on, Markdown arrives as written (GitHub Flavored) and is adapted to
116
+ * Telegram's classic Markdown here when a rich message cannot be sent.
117
+ */
118
+ get nativeMarkdown() {
119
+ return this.config.richMessages !== false;
120
+ }
121
+ get maxMessageChars() {
122
+ return this.nativeMarkdown ? RICH_LIMIT : TEXT_LIMIT;
123
+ }
124
+ /** Text for a classic message: Markdown written for rich messages is adapted first. */
125
+ classic(text, format) {
126
+ return format === 'markdown' && this.nativeMarkdown ? adaptMarkdown(text, 'telegram') : text;
127
+ }
23
128
  onMessage(handler) {
24
129
  this.handler = handler;
25
130
  }
131
+ onAction(handler) {
132
+ this.actionHandler = handler;
133
+ }
134
+ onStop(handler) {
135
+ this.stopHandler = handler;
136
+ }
137
+ /** Calls any Bot API method by name, for features this class does not wrap. */
138
+ async call(method, args = {}) {
139
+ const bot = this.requireBot();
140
+ const fn = bot.api.raw[method];
141
+ if (!fn)
142
+ throw new Error(`Telegram method ${method} is not available`);
143
+ return (await fn(args));
144
+ }
26
145
  requireBot() {
27
146
  if (!this.bot)
28
147
  throw new Error('Telegram channel is not started');
29
148
  return this.bot;
30
149
  }
31
150
  async downloadFile(fileId) {
32
- const bot = this.requireBot();
33
- const file = await bot.api.getFile(fileId);
151
+ const file = await this.call('getFile', { file_id: fileId });
34
152
  if (!file.file_path)
35
153
  throw new Error('Telegram returned no file_path');
36
154
  const url = `https://api.telegram.org/file/bot${this.config.token}/${file.file_path}`;
@@ -41,6 +159,7 @@ export class TelegramChannel {
41
159
  }
42
160
  buildBaseMessage(ctx) {
43
161
  const replyTo = ctx.message.reply_to_message?.message_id;
162
+ const topic = ctx.message.message_thread_id;
44
163
  return {
45
164
  id: String(ctx.message.message_id),
46
165
  channelType: 'telegram',
@@ -49,6 +168,9 @@ export class TelegramChannel {
49
168
  userName: ctx.from.first_name + (ctx.from.last_name ? ` ${ctx.from.last_name}` : ''),
50
169
  groupId: ctx.chat.type !== 'private' ? String(ctx.chat.id) : undefined,
51
170
  ...(replyTo !== undefined ? { replyTo: String(replyTo) } : {}),
171
+ ...(topic !== undefined && (ctx.message.is_topic_message || ctx.chat.type === 'private')
172
+ ? { topicId: String(topic) }
173
+ : {}),
52
174
  };
53
175
  }
54
176
  async emitWithFile(ctx, file, attachment) {
@@ -83,6 +205,43 @@ export class TelegramChannel {
83
205
  bot.catch((err) => {
84
206
  console.error('[telegram] Bot error:', err);
85
207
  });
208
+ bot.use(async (ctx, next) => {
209
+ const stopped = ctx.update.stopped_message_generation;
210
+ if (!stopped) {
211
+ await next();
212
+ return;
213
+ }
214
+ this.stopHandler?.({
215
+ channelType: 'telegram',
216
+ channelId: String(stopped.chat.id),
217
+ draftId: stopped.draft_id,
218
+ ...(stopped.message_thread_id !== undefined
219
+ ? { topicId: String(stopped.message_thread_id) }
220
+ : {}),
221
+ });
222
+ });
223
+ bot.on('callback_query:data', async (ctx) => {
224
+ const query = ctx.callbackQuery;
225
+ if (!query?.data)
226
+ return;
227
+ if (!this.actionHandler) {
228
+ await this.answerAction(query.id).catch(() => { });
229
+ return;
230
+ }
231
+ const message = query.message;
232
+ const topic = message?.message_thread_id;
233
+ await this.actionHandler({
234
+ id: query.id,
235
+ channelType: 'telegram',
236
+ channelId: String(message?.chat?.id ?? query.from.id),
237
+ userId: String(query.from.id),
238
+ userName: query.from.first_name + (query.from.last_name ? ` ${query.from.last_name}` : ''),
239
+ ...(message ? { messageId: String(message.message_id) } : {}),
240
+ ...(topic !== undefined ? { topicId: String(topic) } : {}),
241
+ data: query.data,
242
+ raw: ctx,
243
+ });
244
+ });
86
245
  bot.on('message:text', async (ctx) => {
87
246
  if (!this.handler)
88
247
  return;
@@ -131,9 +290,29 @@ export class TelegramChannel {
131
290
  filename: video.file_name,
132
291
  });
133
292
  });
293
+ bot.on('message:video_note', async (ctx) => {
294
+ const note = ctx.message.video_note;
295
+ if (!note)
296
+ return;
297
+ await this.emitWithFile(ctx, note, {
298
+ type: 'video',
299
+ mimeType: 'video/mp4',
300
+ filename: 'video_note.mp4',
301
+ });
302
+ });
303
+ bot.on('message:animation', async (ctx) => {
304
+ const animation = ctx.message.animation;
305
+ if (!animation)
306
+ return;
307
+ await this.emitWithFile(ctx, animation, {
308
+ type: 'video',
309
+ mimeType: animation.mime_type ?? 'video/mp4',
310
+ filename: animation.file_name,
311
+ });
312
+ });
134
313
  bot.on('message:document', async (ctx) => {
135
314
  const doc = ctx.message.document;
136
- if (!doc)
315
+ if (!doc || ctx.message.animation)
137
316
  return;
138
317
  const isImage = doc.mime_type?.startsWith('image/') ?? false;
139
318
  await this.emitWithFile(ctx, doc, {
@@ -194,7 +373,8 @@ export class TelegramChannel {
194
373
  });
195
374
  });
196
375
  this.server = server;
197
- await bot.api.setWebhook(webhook.url, {
376
+ await bot.api.raw.setWebhook({
377
+ url: webhook.url,
198
378
  drop_pending_updates: true,
199
379
  ...(this.config.allowedUpdates ? { allowed_updates: this.config.allowedUpdates } : {}),
200
380
  ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
@@ -219,12 +399,11 @@ export class TelegramChannel {
219
399
  await bot.stop();
220
400
  }
221
401
  }
222
- async sendText(channelId, text, options) {
223
- const bot = this.requireBot();
224
- const chatId = Number(channelId);
225
- const useMarkdown = options?.format === 'markdown';
226
- const baseOpts = {
227
- ...(options?.replyTo
402
+ /** The parameters every send shares: topic, reply, notification, protection, effect, buttons. */
403
+ envelope(options) {
404
+ return {
405
+ ...(options.topicId ? { message_thread_id: Number(options.topicId) } : {}),
406
+ ...(options.replyTo
228
407
  ? {
229
408
  reply_parameters: {
230
409
  message_id: Number(options.replyTo),
@@ -232,97 +411,405 @@ export class TelegramChannel {
232
411
  },
233
412
  }
234
413
  : {}),
235
- ...(options?.silent ? { disable_notification: true } : {}),
414
+ ...(options.silent ? { disable_notification: true } : {}),
415
+ ...(options.protect ? { protect_content: true } : {}),
416
+ ...(options.effect ? { message_effect_id: options.effect } : {}),
417
+ ...(options.visibleTo
418
+ ? { ephemeral_message_parameters: { receiver_user_id: Number(options.visibleTo) } }
419
+ : {}),
420
+ ...(options.buttons?.length ? { reply_markup: telegramKeyboard(options.buttons) } : {}),
236
421
  };
237
- if (useMarkdown) {
422
+ }
423
+ get useRich() {
424
+ return this.config.richMessages !== false && !this.richUnavailable;
425
+ }
426
+ /** Notes a refusal of a rich call, so an unknown method is not tried again. */
427
+ richRefused(err) {
428
+ if (isUnknownMethod(err))
429
+ this.richUnavailable = true;
430
+ }
431
+ async sendText(channelId, text, options = {}) {
432
+ const chatId = telegramChatId(channelId);
433
+ if (options.format === 'markdown' && this.useRich) {
238
434
  try {
239
- const sent = await bot.api.sendMessage(chatId, text, {
240
- ...baseOpts,
241
- parse_mode: 'Markdown',
435
+ const sent = await this.call('sendRichMessage', {
436
+ chat_id: chatId,
437
+ rich_message: { markdown: text },
438
+ ...this.envelope(options),
242
439
  });
243
- return String(sent.message_id);
440
+ return sentId(sent);
244
441
  }
245
442
  catch (err) {
246
- if (!isEntityParseError(err))
443
+ if (!isRefused(err))
247
444
  throw err;
445
+ this.richRefused(err);
248
446
  }
249
447
  }
250
- const sent = await bot.api.sendMessage(chatId, text, baseOpts);
251
- return String(sent.message_id);
448
+ return this.sendClassic(chatId, this.classic(text, options.format), options);
252
449
  }
253
- async editText(channelId, messageId, text) {
254
- const bot = this.requireBot();
255
- const chatId = Number(channelId);
256
- const msgId = Number(messageId);
450
+ /**
451
+ * A text message in Telegram's classic formatting, in as many messages as the limit needs:
452
+ * the reply goes on the first, the buttons on the last, and a parse failure is sent as plain
453
+ * text.
454
+ */
455
+ async sendClassic(chatId, text, options) {
456
+ const parseMode = options.format === 'html' ? 'HTML' : options.format === 'markdown' ? 'Markdown' : undefined;
457
+ const pieces = chunk(text, TEXT_LIMIT);
458
+ const preview = linkPreviewOptions(options.linkPreview);
459
+ let first = '';
460
+ for (let i = 0; i < pieces.length; i++) {
461
+ const piece = pieces[i] ?? '';
462
+ const envelope = this.envelope({
463
+ ...options,
464
+ ...(i > 0 ? { replyTo: undefined } : {}),
465
+ ...(i < pieces.length - 1 ? { buttons: undefined } : {}),
466
+ });
467
+ const args = {
468
+ chat_id: chatId,
469
+ text: piece,
470
+ ...envelope,
471
+ ...(preview ? { link_preview_options: preview } : {}),
472
+ };
473
+ let sent;
474
+ if (parseMode) {
475
+ try {
476
+ sent = await this.call('sendMessage', { ...args, parse_mode: parseMode });
477
+ }
478
+ catch (err) {
479
+ if (!isEntityParseError(err))
480
+ throw err;
481
+ sent = await this.call('sendMessage', args);
482
+ }
483
+ }
484
+ else {
485
+ sent = await this.call('sendMessage', args);
486
+ }
487
+ if (i === 0)
488
+ first = sentId(sent);
489
+ }
490
+ return first;
491
+ }
492
+ async editText(channelId, messageId, text, options = {}) {
493
+ const chatId = telegramChatId(channelId);
494
+ const format = options.format ?? 'markdown';
495
+ const target = {
496
+ chat_id: chatId,
497
+ message_id: Number(messageId),
498
+ ...(options.buttons ? { reply_markup: telegramKeyboard(options.buttons) } : {}),
499
+ };
500
+ if (format === 'markdown' && this.useRich) {
501
+ try {
502
+ await this.call('editMessageText', { ...target, rich_message: { markdown: text } });
503
+ return;
504
+ }
505
+ catch (err) {
506
+ if (isNotModifiedError(err))
507
+ return;
508
+ if (!isRefused(err))
509
+ throw err;
510
+ this.richRefused(err);
511
+ }
512
+ }
513
+ const classicText = this.classic(text, format);
514
+ const clipped = classicText.length > TEXT_LIMIT ? classicText.slice(0, TEXT_LIMIT) : classicText;
515
+ const parseMode = format === 'html' ? 'HTML' : format === 'markdown' ? 'Markdown' : undefined;
257
516
  try {
258
- await bot.api.editMessageText(chatId, msgId, text, { parse_mode: 'Markdown' });
517
+ await this.call('editMessageText', {
518
+ ...target,
519
+ text: clipped,
520
+ ...(parseMode ? { parse_mode: parseMode } : {}),
521
+ });
259
522
  return;
260
523
  }
261
524
  catch (err) {
262
525
  if (isNotModifiedError(err))
263
526
  return;
264
- if (!isEntityParseError(err))
527
+ if (!parseMode || !isEntityParseError(err))
265
528
  throw err;
266
529
  }
267
530
  try {
268
- await bot.api.editMessageText(chatId, msgId, text);
531
+ await this.call('editMessageText', { ...target, text: clipped });
269
532
  }
270
533
  catch (err) {
271
534
  if (!isNotModifiedError(err))
272
535
  throw err;
273
536
  }
274
537
  }
275
- async sendFile(channelId, file) {
276
- const bot = this.requireBot();
277
- const chatId = Number(channelId);
278
- let source;
538
+ source(file) {
279
539
  if (file.buffer) {
280
540
  if (!this.grammy)
281
541
  throw new Error('Telegram channel is not started');
282
- source = new this.grammy.InputFile(file.buffer, file.filename);
283
- }
284
- else if (file.url) {
285
- source = file.url;
286
- }
287
- else {
288
- throw new Error('Attachment must have either a buffer or a url');
542
+ return new this.grammy.InputFile(file.buffer, file.filename);
289
543
  }
544
+ if (file.url)
545
+ return file.url;
546
+ throw new Error('Attachment must have either a buffer or a url');
547
+ }
548
+ /** The Bot API method and field a file is sent with. */
549
+ static mediaKind(file) {
550
+ const mime = file.mimeType.toLowerCase();
290
551
  switch (file.type) {
291
552
  case 'image':
292
- await bot.api.sendPhoto(chatId, source);
293
- return;
294
- case 'audio':
295
- await bot.api.sendAudio(chatId, source);
296
- return;
553
+ return mime === 'image/gif'
554
+ ? { method: 'sendAnimation', field: 'animation' }
555
+ : { method: 'sendPhoto', field: 'photo' };
297
556
  case 'video':
298
- await bot.api.sendVideo(chatId, source);
299
- return;
557
+ return { method: 'sendVideo', field: 'video' };
558
+ case 'audio':
559
+ return mime.includes('ogg') || mime.includes('opus')
560
+ ? { method: 'sendVoice', field: 'voice' }
561
+ : { method: 'sendAudio', field: 'audio' };
300
562
  default:
301
- await bot.api.sendDocument(chatId, source);
563
+ return { method: 'sendDocument', field: 'document' };
564
+ }
565
+ }
566
+ /** A caption in classic formatting, or none when it is too long to be one. */
567
+ caption(text, format) {
568
+ if (!text || text.length > CAPTION_LIMIT)
569
+ return {};
570
+ if (format === 'html')
571
+ return { caption: text, parse_mode: 'HTML' };
572
+ if (format === 'markdown') {
573
+ return { caption: this.classic(text, format), parse_mode: 'Markdown' };
574
+ }
575
+ return { caption: text };
576
+ }
577
+ /** Sends one file and returns the id of its message. */
578
+ async sendFile(channelId, file, options = {}) {
579
+ const chatId = telegramChatId(channelId);
580
+ const { method, field } = TelegramChannel.mediaKind(file);
581
+ const longCaption = (file.caption?.length ?? 0) > CAPTION_LIMIT;
582
+ const args = {
583
+ chat_id: chatId,
584
+ [field]: this.source(file),
585
+ ...this.caption(file.caption, options.format),
586
+ ...this.envelope(longCaption ? { ...options, buttons: undefined } : options),
587
+ };
588
+ let sent;
589
+ try {
590
+ sent = await this.call(method, args);
302
591
  }
592
+ catch (err) {
593
+ if (!('parse_mode' in args) || !isEntityParseError(err))
594
+ throw err;
595
+ const { parse_mode: _mode, ...plain } = args;
596
+ sent = await this.call(method, { ...plain, caption: file.caption });
597
+ }
598
+ if (longCaption && file.caption) {
599
+ await this.sendText(channelId, file.caption, { ...options, replyTo: undefined });
600
+ }
601
+ return sentId(sent);
303
602
  }
304
- async sendTyping(channelId) {
603
+ /**
604
+ * Sends files as albums of up to ten. Photos and videos share an album, while audio and
605
+ * documents each go in albums of their own kind, as Telegram requires; GIFs, which albums do
606
+ * not take, go on their own as animations. Each file keeps its own caption, and a caption too
607
+ * long for an album follows it as a message. Albums carry no buttons. Returns the ids of every
608
+ * message sent.
609
+ */
610
+ async sendFiles(channelId, files, options = {}) {
611
+ if (files.length === 1 && files[0]) {
612
+ return [await this.sendFile(channelId, files[0], options)];
613
+ }
614
+ const kindOf = (file) => {
615
+ if (TelegramChannel.mediaKind(file).method === 'sendAnimation')
616
+ return 'animation';
617
+ return file.type === 'image' || file.type === 'video' ? 'visual' : file.type;
618
+ };
619
+ const groups = [];
620
+ for (const file of files) {
621
+ const last = groups[groups.length - 1];
622
+ const joins = last &&
623
+ last.length < 10 &&
624
+ last[0] &&
625
+ kindOf(file) !== 'animation' &&
626
+ kindOf(last[0]) === kindOf(file);
627
+ if (joins)
628
+ last.push(file);
629
+ else
630
+ groups.push([file]);
631
+ }
632
+ const chatId = telegramChatId(channelId);
633
+ let replyTo = options.replyTo;
634
+ const ids = [];
635
+ for (const group of groups) {
636
+ if (group.length === 1 && group[0]) {
637
+ ids.push(await this.sendFile(channelId, group[0], { ...options, replyTo }));
638
+ }
639
+ else {
640
+ const album = await this.call('sendMediaGroup', {
641
+ chat_id: chatId,
642
+ media: group.map((file) => ({
643
+ type: file.type === 'image'
644
+ ? 'photo'
645
+ : file.type === 'video'
646
+ ? 'video'
647
+ : file.type === 'audio'
648
+ ? 'audio'
649
+ : 'document',
650
+ media: this.source(file),
651
+ ...this.caption(file.caption, options.format),
652
+ })),
653
+ ...this.envelope({ ...options, replyTo, buttons: undefined, visibleTo: undefined }),
654
+ });
655
+ if (Array.isArray(album))
656
+ ids.push(...album.map(sentId));
657
+ for (const file of group) {
658
+ if (file.caption && file.caption.length > CAPTION_LIMIT) {
659
+ ids.push(await this.sendText(channelId, file.caption, {
660
+ ...options,
661
+ replyTo: undefined,
662
+ buttons: undefined,
663
+ }));
664
+ }
665
+ }
666
+ }
667
+ replyTo = undefined;
668
+ }
669
+ return ids;
670
+ }
671
+ async sendTyping(channelId, options = {}) {
305
672
  if (!this.bot)
306
673
  return;
307
- await this.bot.api.sendChatAction(Number(channelId), 'typing');
308
- }
309
- async sendDraft(channelId, draftId, text, options) {
310
- const bot = this.requireBot();
311
- const useMarkdown = options?.format === 'markdown';
312
- await bot.api.sendMessageDraft(Number(channelId), draftId, text, {
313
- ...(useMarkdown ? { parse_mode: 'Markdown' } : {}),
674
+ await this.call('sendChatAction', {
675
+ chat_id: telegramChatId(channelId),
676
+ action: 'typing',
677
+ ...(options.topicId ? { message_thread_id: Number(options.topicId) } : {}),
314
678
  });
315
679
  }
680
+ /**
681
+ * Streams a partial answer as a draft, a preview Telegram shows for 30 seconds; the final
682
+ * answer is sent as a message. Drafts exist in private chats only, so a group throws and the
683
+ * caller falls back to sending and editing messages. With `canStop`, the user gets a stop
684
+ * button and the draft stays on screen after a press until the final message replaces it.
685
+ */
686
+ async sendDraft(channelId, draftId, text, options = {}) {
687
+ if (!isPrivateChat(channelId)) {
688
+ throw new Error('Telegram drafts can only be sent to private chats');
689
+ }
690
+ const base = {
691
+ chat_id: Number(channelId),
692
+ draft_id: draftId,
693
+ ...(options.topicId ? { message_thread_id: Number(options.topicId) } : {}),
694
+ ...(options.canStop ? { can_stop: true, keep_on_stop: true } : {}),
695
+ };
696
+ if (options.format === 'markdown' && this.useRich && text.trim()) {
697
+ try {
698
+ await this.call('sendRichMessageDraft', { ...base, rich_message: { markdown: text } });
699
+ return;
700
+ }
701
+ catch (err) {
702
+ if (!isRefused(err))
703
+ throw err;
704
+ this.richRefused(err);
705
+ }
706
+ }
707
+ const classic = this.classic(text, options.format);
708
+ const clipped = classic.length > TEXT_LIMIT ? classic.slice(0, TEXT_LIMIT) : classic;
709
+ try {
710
+ await this.call('sendMessageDraft', {
711
+ ...base,
712
+ text: clipped,
713
+ ...(options.format === 'markdown' ? { parse_mode: 'Markdown' } : {}),
714
+ });
715
+ }
716
+ catch (err) {
717
+ if (options.format !== 'markdown' || !isEntityParseError(err))
718
+ throw err;
719
+ await this.call('sendMessageDraft', { ...base, text: clipped });
720
+ }
721
+ }
316
722
  async deleteMessage(channelId, messageId) {
317
- const bot = this.requireBot();
318
- await bot.api.deleteMessage(Number(channelId), Number(messageId));
723
+ await this.call('deleteMessage', {
724
+ chat_id: telegramChatId(channelId),
725
+ message_id: Number(messageId),
726
+ });
319
727
  }
320
728
  async setReaction(channelId, messageId, emoji) {
321
729
  if (!this.bot)
322
730
  return;
323
- await this.bot.api.setMessageReaction(Number(channelId), Number(messageId), [
324
- { type: 'emoji', emoji },
325
- ]);
731
+ await this.call('setMessageReaction', {
732
+ chat_id: telegramChatId(channelId),
733
+ message_id: Number(messageId),
734
+ reaction: [{ type: 'emoji', emoji }],
735
+ });
736
+ }
737
+ async answerAction(actionId, options = {}) {
738
+ await this.call('answerCallbackQuery', {
739
+ callback_query_id: actionId,
740
+ ...(options.text ? { text: options.text } : {}),
741
+ ...(options.alert ? { show_alert: true } : {}),
742
+ });
743
+ }
744
+ async editButtons(channelId, messageId, buttons) {
745
+ try {
746
+ await this.call('editMessageReplyMarkup', {
747
+ chat_id: telegramChatId(channelId),
748
+ message_id: Number(messageId),
749
+ reply_markup: buttons ? telegramKeyboard(buttons) : { inline_keyboard: [] },
750
+ });
751
+ }
752
+ catch (err) {
753
+ if (!isNotModifiedError(err))
754
+ throw err;
755
+ }
756
+ }
757
+ async setCommands(commands, scope = {}) {
758
+ await this.setCommandMenu(commands, {
759
+ scope: scope.chatId ? { type: 'chat', chatId: scope.chatId } : { type: 'default' },
760
+ });
761
+ }
762
+ /** Sets the command menu for a scope and, optionally, a language. */
763
+ async setCommandMenu(commands, options = {}) {
764
+ for (const { command, description } of commands) {
765
+ if (!COMMAND_RE.test(command)) {
766
+ throw new Error(`Telegram command "${command}" must be 1-32 lowercase letters, digits or underscores`);
767
+ }
768
+ if (description.length < 1 || description.length > 256) {
769
+ throw new Error(`The description of /${command} must be 1-256 characters`);
770
+ }
771
+ }
772
+ await this.call('setMyCommands', {
773
+ commands,
774
+ ...this.scopeArgs(options),
775
+ });
776
+ }
777
+ /** Removes the command menu of a scope. */
778
+ async deleteCommandMenu(options = {}) {
779
+ await this.call('deleteMyCommands', this.scopeArgs(options));
780
+ }
781
+ scopeArgs(options) {
782
+ const scope = options.scope;
783
+ const wire = !scope
784
+ ? undefined
785
+ : scope.type === 'chat' || scope.type === 'chat_administrators'
786
+ ? { type: scope.type, chat_id: telegramChatId(scope.chatId) }
787
+ : scope.type === 'chat_member'
788
+ ? {
789
+ type: scope.type,
790
+ chat_id: telegramChatId(scope.chatId),
791
+ user_id: Number(scope.userId),
792
+ }
793
+ : { type: scope.type };
794
+ return {
795
+ ...(wire ? { scope: wire } : {}),
796
+ ...(options.languageCode ? { language_code: options.languageCode } : {}),
797
+ };
798
+ }
799
+ /** Sets the bot's name, description and short description; fields left out are not changed. */
800
+ async setProfile(profile) {
801
+ const language = profile.languageCode ? { language_code: profile.languageCode } : {};
802
+ if (profile.name !== undefined)
803
+ await this.call('setMyName', { name: profile.name, ...language });
804
+ if (profile.description !== undefined) {
805
+ await this.call('setMyDescription', { description: profile.description, ...language });
806
+ }
807
+ if (profile.shortDescription !== undefined) {
808
+ await this.call('setMyShortDescription', {
809
+ short_description: profile.shortDescription,
810
+ ...language,
811
+ });
812
+ }
326
813
  }
327
814
  }
328
815
  export function telegramChannel(config) {