@cogitator-ai/channels 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -15
- package/dist/approvals.d.ts +18 -0
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +4 -0
- package/dist/approvals.js.map +1 -1
- package/dist/channels/telegram.d.ts +124 -6
- package/dist/channels/telegram.d.ts.map +1 -1
- package/dist/channels/telegram.js +547 -60
- package/dist/channels/telegram.js.map +1 -1
- package/dist/gateway.d.ts +23 -0
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +215 -24
- package/dist/gateway.js.map +1 -1
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +1 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/stream-buffer.d.ts +12 -2
- package/dist/stream-buffer.d.ts.map +1 -1
- package/dist/stream-buffer.js +16 -5
- package/dist/stream-buffer.js.map +1 -1
- package/package.json +7 -7
|
@@ -1,36 +1,154 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
435
|
+
const sent = await this.call('sendRichMessage', {
|
|
436
|
+
chat_id: chatId,
|
|
437
|
+
rich_message: { markdown: text },
|
|
438
|
+
...this.envelope(options),
|
|
242
439
|
});
|
|
243
|
-
return
|
|
440
|
+
return sentId(sent);
|
|
244
441
|
}
|
|
245
442
|
catch (err) {
|
|
246
|
-
if (!
|
|
443
|
+
if (!isRefused(err))
|
|
247
444
|
throw err;
|
|
445
|
+
this.richRefused(err);
|
|
248
446
|
}
|
|
249
447
|
}
|
|
250
|
-
|
|
251
|
-
return String(sent.message_id);
|
|
448
|
+
return this.sendClassic(chatId, this.classic(text, options.format), options);
|
|
252
449
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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.
|
|
324
|
-
|
|
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) {
|