@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imessaging/telegram-mtproto",
3
- "version": "0.8.19",
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.15",
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 { formatText, formattedOf, isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
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 save(await this.getInputPeer(draft.recipient));
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, draft.recipient);
445
- await save(await this.getInputPeer(draft.recipient));
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.trackOwn(this.sendWithRetry(message, false));
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(message: OutboundMessage, retried: boolean): Promise<SendResult> {
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 sent = await this.client.sendMessage(peer, {
1002
- message: text,
1003
- parseMode,
1004
- formattingEntities,
1005
- replyTo: message.replyToMessageId ?? message.threadId,
1006
- buttons: sendMarkup(message),
1007
- });
1008
- this.rememberOwn(this.ownSent, peer, sent.id);
1009
- for (const document of message.documents ?? []) {
1010
- const data = Buffer.from(document.data);
1011
- const file = await this.client.sendFile(peer, {
1012
- file: new CustomFile(document.filename, data.length, "", data),
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, file.id);
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 = sent.chat;
1116
+ const chat = first!.chat;
1021
1117
  return {
1022
1118
  transportId: this.id,
1023
- messageId: String(sent.id),
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
+ }