@imessaging/telegram-mtproto 0.8.19 → 0.8.21
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/package.json +2 -2
- package/src/telegram-mtproto-transport.ts +202 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imessaging/telegram-mtproto",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.21",
|
|
4
4
|
"description": "Telegram MTProto user-account transport for imessaging",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"messaging",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@imessaging/core": "^0.8.
|
|
34
|
+
"@imessaging/core": "^0.8.17",
|
|
35
35
|
"big-integer": "^1.6.52"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
PhoneLookupOptions,
|
|
7
7
|
PhoneLookupTransport,
|
|
8
8
|
PhoneMatch,
|
|
9
|
+
DraftReaderTransport,
|
|
9
10
|
DraftTransport,
|
|
10
11
|
MessageDraft,
|
|
11
12
|
OutgoingObserverTransport,
|
|
@@ -31,15 +32,25 @@ import type {
|
|
|
31
32
|
ResolvedPeer,
|
|
32
33
|
SelfAccount,
|
|
33
34
|
SelfAwareTransport,
|
|
35
|
+
OutboundDocument,
|
|
34
36
|
SendResult,
|
|
37
|
+
SentPart,
|
|
38
|
+
StoredDraft,
|
|
35
39
|
TelegramPeerStore,
|
|
36
40
|
TelegramRecipient,
|
|
37
41
|
TransportStatus,
|
|
38
42
|
TypingTransport,
|
|
39
43
|
} from "@imessaging/core";
|
|
40
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
formatText,
|
|
46
|
+
formattedOf,
|
|
47
|
+
isTelegramRecipient,
|
|
48
|
+
MemoryPeerStore,
|
|
49
|
+
PartialSendError,
|
|
50
|
+
} from "@imessaging/core";
|
|
41
51
|
import { toTelegramEntities } from "./entities";
|
|
42
52
|
import { peerToChat, unmarkPeerId } from "./incoming";
|
|
53
|
+
import { stat } from "node:fs/promises";
|
|
43
54
|
import bigInt from "big-integer";
|
|
44
55
|
import { Api, TelegramClient } from "telegram";
|
|
45
56
|
import { CustomFile } from "telegram/client/uploads";
|
|
@@ -155,6 +166,7 @@ export class TelegramMtprotoTransport
|
|
|
155
166
|
implements
|
|
156
167
|
CommandMenuTransport,
|
|
157
168
|
ContactTransport,
|
|
169
|
+
DraftReaderTransport,
|
|
158
170
|
DraftTransport,
|
|
159
171
|
EditableMessageTransport,
|
|
160
172
|
EditedMessageTransport,
|
|
@@ -436,15 +448,60 @@ export class TelegramMtprotoTransport
|
|
|
436
448
|
: {}),
|
|
437
449
|
}),
|
|
438
450
|
);
|
|
451
|
+
await this.withPeerRetry(draft.recipient, save);
|
|
452
|
+
this.lastActivityAt = new Date();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Черновик в поле ввода — перед saveDraft, чтобы не затереть набранное человеком. Тема форума хранит свой
|
|
457
|
+
* черновик в ForumTopic, общий чат — в Dialog. undefined — черновика нет.
|
|
458
|
+
*/
|
|
459
|
+
async readDraft(
|
|
460
|
+
recipient: MessageRecipient,
|
|
461
|
+
options: { threadId?: number } = {},
|
|
462
|
+
): Promise<StoredDraft | undefined> {
|
|
463
|
+
this.assertConnected();
|
|
464
|
+
if (!isTelegramRecipient(recipient)) {
|
|
465
|
+
throw new Error(`Telegram MTProto cannot read a draft for ${recipient.type}`);
|
|
466
|
+
}
|
|
467
|
+
const { threadId } = options;
|
|
468
|
+
if (threadId !== undefined && (!Number.isSafeInteger(threadId) || threadId <= 0)) {
|
|
469
|
+
throw new Error(`Telegram thread id must be a positive integer: ${threadId}`);
|
|
470
|
+
}
|
|
471
|
+
const read = async (peer: Api.TypeInputPeer): Promise<Api.TypeDraftMessage | undefined> => {
|
|
472
|
+
if (threadId !== undefined) {
|
|
473
|
+
const result = await this.client.invoke(
|
|
474
|
+
new Api.messages.GetForumTopicsByID({ peer, topics: [threadId] }),
|
|
475
|
+
);
|
|
476
|
+
const topic = result.topics.find(
|
|
477
|
+
(t): t is Api.ForumTopic => t instanceof Api.ForumTopic && t.id === threadId,
|
|
478
|
+
);
|
|
479
|
+
// Нет темы — не «пустое поле»: иначе черновик ушёл бы в General поверх набранного там.
|
|
480
|
+
if (!topic) throw new Error(`Telegram forum topic not found: ${threadId}`);
|
|
481
|
+
return topic.draft;
|
|
482
|
+
}
|
|
483
|
+
// InputPeer, а не InputDialogPeer: клиент при отправке сам оборачивает пир (_getInputDialog).
|
|
484
|
+
const result = await this.client.invoke(new Api.messages.GetPeerDialogs({ peers: [peer] }));
|
|
485
|
+
const dialog = result.dialogs.find((d): d is Api.Dialog => d instanceof Api.Dialog);
|
|
486
|
+
return dialog?.draft;
|
|
487
|
+
};
|
|
488
|
+
const draft = await this.withPeerRetry(recipient, read);
|
|
489
|
+
this.lastActivityAt = new Date();
|
|
490
|
+
return toStoredDraft(draft, threadId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Как у send: устаревший пир из хранилища — забыть и разрешить заново, один раз. */
|
|
494
|
+
private async withPeerRetry<T>(
|
|
495
|
+
recipient: MessageRecipient,
|
|
496
|
+
call: (peer: Api.TypeInputPeer) => Promise<T>,
|
|
497
|
+
): Promise<T> {
|
|
439
498
|
try {
|
|
440
|
-
await
|
|
499
|
+
return await call(await this.getInputPeer(recipient));
|
|
441
500
|
} catch (error: unknown) {
|
|
442
|
-
// Как у send: устаревший пир из хранилища — забыть и разрешить заново, один раз.
|
|
443
501
|
if (!this.isInvalidPeerError(error)) throw error;
|
|
444
|
-
await this.peerStore.delete(this.options.accountId,
|
|
445
|
-
|
|
502
|
+
await this.peerStore.delete(this.options.accountId, recipient);
|
|
503
|
+
return call(await this.getInputPeer(recipient));
|
|
446
504
|
}
|
|
447
|
-
this.lastActivityAt = new Date();
|
|
448
505
|
}
|
|
449
506
|
|
|
450
507
|
/** Свои новые исходящие — отправленное человеком с другого устройства; отправленное через send помечено sentByTransport. */
|
|
@@ -848,7 +905,25 @@ export class TelegramMtprotoTransport
|
|
|
848
905
|
|
|
849
906
|
async send(message: OutboundMessage): Promise<SendResult> {
|
|
850
907
|
this.assertConnected();
|
|
851
|
-
return this.
|
|
908
|
+
return this.sendUploaded(message);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Файлы грузятся один раз, до отправки: повтор после смены пира их не перезагружает. */
|
|
912
|
+
private async sendUploaded(message: OutboundMessage): Promise<SendResult> {
|
|
913
|
+
const documents = message.documents ?? [];
|
|
914
|
+
const asCaption = message.textAsCaption === true && documents.length > 0;
|
|
915
|
+
// Длина известна только с `format`; иначе разметку разбирает gramjs, и судит Telegram.
|
|
916
|
+
const formatted = formattedOf(message);
|
|
917
|
+
if (asCaption && formatted && formatted.text.length > CAPTION_LIMIT) {
|
|
918
|
+
throw new Error(`Подпись длиннее ${CAPTION_LIMIT} знаков`);
|
|
919
|
+
}
|
|
920
|
+
// Пир до загрузки: несуществующий получатель не стоит загрузки гигабайта.
|
|
921
|
+
if (documents.length > 0) await this.getInputPeer(message.recipient);
|
|
922
|
+
// Сначала все файлы в Telegram, потом сообщения: сбой загрузки не оставит в чате полсообщения.
|
|
923
|
+
const uploaded: Api.TypeInputFile[] = [];
|
|
924
|
+
for (const document of documents) uploaded.push(await this.uploadDocument(document));
|
|
925
|
+
// Своё ждут только на время отправки: долгая загрузка не задерживает чужие исходящие события.
|
|
926
|
+
return this.trackOwn(this.sendWithRetry(message, uploaded, false));
|
|
852
927
|
}
|
|
853
928
|
|
|
854
929
|
/**
|
|
@@ -990,7 +1065,12 @@ export class TelegramMtprotoTransport
|
|
|
990
1065
|
.sort((a, b) => Number(a.messageId) - Number(b.messageId));
|
|
991
1066
|
}
|
|
992
1067
|
|
|
993
|
-
private async sendWithRetry(
|
|
1068
|
+
private async sendWithRetry(
|
|
1069
|
+
message: OutboundMessage,
|
|
1070
|
+
uploaded: Api.TypeInputFile[],
|
|
1071
|
+
retried: boolean,
|
|
1072
|
+
): Promise<SendResult> {
|
|
1073
|
+
const parts: SentPart[] = [];
|
|
994
1074
|
try {
|
|
995
1075
|
const peer = await this.getInputPeer(message.recipient);
|
|
996
1076
|
// С `format` уходят готовые выделения: gramjs тогда не разбирает текст своим markdown вовсе.
|
|
@@ -998,37 +1078,68 @@ export class TelegramMtprotoTransport
|
|
|
998
1078
|
const text = ft ? ft.text : message.text;
|
|
999
1079
|
const parseMode = ft ? undefined : message.parseMode?.toLowerCase();
|
|
1000
1080
|
const formattingEntities = ft ? toTelegramEntities(ft) : undefined;
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
caption: document.caption,
|
|
1014
|
-
replyTo: message.threadId,
|
|
1081
|
+
const documents = message.documents ?? [];
|
|
1082
|
+
const asCaption = message.textAsCaption === true && documents.length > 0;
|
|
1083
|
+
|
|
1084
|
+
let first: Api.Message | undefined;
|
|
1085
|
+
const replyTo = message.replyToMessageId ?? message.threadId;
|
|
1086
|
+
if (!asCaption && (text.trim() !== "" || documents.length === 0)) {
|
|
1087
|
+
first = await this.client.sendMessage(peer, {
|
|
1088
|
+
message: text,
|
|
1089
|
+
parseMode,
|
|
1090
|
+
formattingEntities,
|
|
1091
|
+
replyTo,
|
|
1092
|
+
buttons: sendMarkup(message),
|
|
1015
1093
|
});
|
|
1016
|
-
this.rememberOwn(this.ownSent, peer,
|
|
1094
|
+
this.rememberOwn(this.ownSent, peer, first.id);
|
|
1095
|
+
parts.push({ messageId: String(first.id), kind: "text", media: false });
|
|
1096
|
+
}
|
|
1097
|
+
// Текстового сообщения нет — ответ и кнопки несёт первый файл.
|
|
1098
|
+
const fileLeads = first === undefined;
|
|
1099
|
+
for (const [index, document] of documents.entries()) {
|
|
1100
|
+
const captioned = asCaption && index === 0 && text.trim() !== "";
|
|
1101
|
+
const leads = fileLeads && index === 0;
|
|
1102
|
+
const sent = await this.client.sendFile(peer, {
|
|
1103
|
+
file: documentMedia(document, uploaded[index]!),
|
|
1104
|
+
caption: captioned ? text : document.caption,
|
|
1105
|
+
parseMode: captioned ? parseMode : undefined,
|
|
1106
|
+
formattingEntities: captioned ? formattingEntities : undefined,
|
|
1107
|
+
replyTo: leads ? replyTo : message.threadId,
|
|
1108
|
+
buttons: leads ? sendMarkup(message) : undefined,
|
|
1109
|
+
});
|
|
1110
|
+
this.rememberOwn(this.ownSent, peer, sent.id);
|
|
1111
|
+
first ??= sent;
|
|
1112
|
+
parts.push({ messageId: String(sent.id), kind: "document", media: hasFileMedia(sent) });
|
|
1017
1113
|
}
|
|
1018
1114
|
this.lastActivityAt = new Date();
|
|
1019
1115
|
// Получатель есть, только если ответ принёс users: короткий ответ лички (UpdateShortSentMessage) их не несёт.
|
|
1020
|
-
const chat =
|
|
1116
|
+
const chat = first!.chat;
|
|
1021
1117
|
return {
|
|
1022
1118
|
transportId: this.id,
|
|
1023
|
-
messageId: String(
|
|
1119
|
+
messageId: String(first!.id),
|
|
1024
1120
|
recipientId: this.getPeerId(peer),
|
|
1121
|
+
parts,
|
|
1025
1122
|
...(chat instanceof Api.User ? { mutualContact: Boolean(chat.mutualContact) } : {}),
|
|
1026
1123
|
};
|
|
1027
1124
|
} catch (error: unknown) {
|
|
1125
|
+
// Часть уже в чате — повтор её задвоит; вызывающему отдаём, что именно ушло.
|
|
1126
|
+
if (parts.length > 0) throw new PartialSendError(parts, error);
|
|
1028
1127
|
if (retried || !this.isInvalidPeerError(error)) throw error;
|
|
1029
1128
|
await this.peerStore.delete(this.options.accountId, message.recipient);
|
|
1030
|
-
return this.sendWithRetry(message, true);
|
|
1129
|
+
return this.sendWithRetry(message, uploaded, true);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Загрузка без отправки. С `path` файл читается с диска частями, без предела 20 МБ на буфер. */
|
|
1134
|
+
private async uploadDocument(document: OutboundDocument): Promise<Api.TypeInputFile> {
|
|
1135
|
+
if (document.data === undefined && !document.path) {
|
|
1136
|
+
throw new Error(`Вложение ${document.filename}: нет ни data, ни path`);
|
|
1031
1137
|
}
|
|
1138
|
+
const file =
|
|
1139
|
+
document.data !== undefined
|
|
1140
|
+
? new CustomFile(document.filename, document.data.length, "", Buffer.from(document.data))
|
|
1141
|
+
: new CustomFile(document.filename, (await stat(document.path)).size, document.path);
|
|
1142
|
+
return this.client.uploadFile({ file, workers: UPLOAD_WORKERS });
|
|
1032
1143
|
}
|
|
1033
1144
|
|
|
1034
1145
|
private async getInputPeer(recipient: MessageRecipient): Promise<Api.TypeInputPeer> {
|
|
@@ -1199,3 +1310,68 @@ export class TelegramMtprotoTransport
|
|
|
1199
1310
|
return telegramError.code === 400 && INVALID_PEER_ERRORS.has(telegramError.errorMessage ?? "");
|
|
1200
1311
|
}
|
|
1201
1312
|
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* DraftMessage → StoredDraft. В теме saveDraft без ответа ставит ответ на корень темы — это не ответ человеку.
|
|
1316
|
+
*/
|
|
1317
|
+
function toStoredDraft(
|
|
1318
|
+
draft: Api.TypeDraftMessage | undefined,
|
|
1319
|
+
threadId?: number,
|
|
1320
|
+
): StoredDraft | undefined {
|
|
1321
|
+
if (!(draft instanceof Api.DraftMessage)) return undefined;
|
|
1322
|
+
const r = draft.replyTo instanceof Api.InputReplyToMessage ? draft.replyTo : undefined;
|
|
1323
|
+
// Ответ в другой чат (replyToPeerId) — чужой id сообщения, здесь он не годится.
|
|
1324
|
+
const replyTo = r && !r.replyToPeerId ? r.replyToMsgId : undefined;
|
|
1325
|
+
const reply = replyTo !== undefined && replyTo !== threadId ? String(replyTo) : undefined;
|
|
1326
|
+
// Очистка в теме оставляет только ответ на корень. Что-то кроме этого (медиа, чужой ответ) — черновик есть.
|
|
1327
|
+
const onlyRoot =
|
|
1328
|
+
!draft.replyTo || (r !== undefined && !r.replyToPeerId && r.replyToMsgId === threadId);
|
|
1329
|
+
if (draft.message === "" && onlyRoot && !draft.media && !draft.richMessage) return undefined;
|
|
1330
|
+
return {
|
|
1331
|
+
text: draft.message,
|
|
1332
|
+
...(reply !== undefined ? { replyToMessageId: reply } : {}),
|
|
1333
|
+
date: new Date(draft.date * 1000),
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** Параллельных частей при загрузке: с одной 25 МБ идут по 512 КБ за круг. */
|
|
1338
|
+
const UPLOAD_WORKERS = 4;
|
|
1339
|
+
|
|
1340
|
+
/** Предел подписи к файлу у обычной (не Premium) учётки. */
|
|
1341
|
+
const CAPTION_LIMIT = 1024;
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Что отдать в `sendFile`. С `video` или `mimeType` не фото/звука — готовый InputMediaUploadedDocument:
|
|
1345
|
+
* sendFile сам `mimeType` не принимает, а видео без атрибутов шлёт 1×1 длиной 0. Фото и звук — gramjs.
|
|
1346
|
+
*/
|
|
1347
|
+
function documentMedia(document: OutboundDocument, file: Api.TypeInputFile) {
|
|
1348
|
+
const video = document.video;
|
|
1349
|
+
const own = document.mimeType && !/^(image|audio)\//.test(document.mimeType);
|
|
1350
|
+
if (!video && !own) return file;
|
|
1351
|
+
return new Api.InputMediaUploadedDocument({
|
|
1352
|
+
file,
|
|
1353
|
+
mimeType: document.mimeType ?? (video ? "video/mp4" : "application/octet-stream"),
|
|
1354
|
+
attributes: [
|
|
1355
|
+
new Api.DocumentAttributeFilename({ fileName: document.filename }),
|
|
1356
|
+
...(video
|
|
1357
|
+
? [
|
|
1358
|
+
new Api.DocumentAttributeVideo({
|
|
1359
|
+
w: video.width,
|
|
1360
|
+
h: video.height,
|
|
1361
|
+
duration: video.duration,
|
|
1362
|
+
supportsStreaming: video.supportsStreaming,
|
|
1363
|
+
}),
|
|
1364
|
+
]
|
|
1365
|
+
: []),
|
|
1366
|
+
],
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** Файл дошёл: у сообщения есть документ или фото. */
|
|
1371
|
+
function hasFileMedia(message: Api.Message): boolean {
|
|
1372
|
+
return (
|
|
1373
|
+
(message.media instanceof Api.MessageMediaDocument &&
|
|
1374
|
+
message.media.document instanceof Api.Document) ||
|
|
1375
|
+
(message.media instanceof Api.MessageMediaPhoto && message.media.photo instanceof Api.Photo)
|
|
1376
|
+
);
|
|
1377
|
+
}
|