@imessaging/telegram-mtproto 0.8.2 → 0.8.4

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.2",
3
+ "version": "0.8.4",
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.2",
34
+ "@imessaging/core": "^0.8.4",
35
35
  "big-integer": "^1.6.52"
36
36
  },
37
37
  "peerDependencies": {
package/src/entities.ts CHANGED
@@ -22,6 +22,10 @@ export function toTelegramEntities(ft: FormattedText): Api.TypeMessageEntity[] {
22
22
  return new Api.MessageEntityPre({ ...base, language: e.language ?? "" });
23
23
  case "link":
24
24
  return new Api.MessageEntityTextUrl({ ...base, url: e.url ?? "" });
25
+ case "spoiler":
26
+ return new Api.MessageEntitySpoiler(base);
27
+ case "quote":
28
+ return new Api.MessageEntityBlockquote(base);
25
29
  }
26
30
  });
27
31
  }
package/src/incoming.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { IncomingAttachment, IncomingLink, IncomingMessage } from "@imessaging/core";
1
+ import type {
2
+ IncomingAttachment,
3
+ IncomingLink,
4
+ IncomingMessage,
5
+ TextEntity,
6
+ TextEntityType,
7
+ } from "@imessaging/core";
2
8
 
3
9
  /** Пир MTProto в той части, по которой определяется чат. Приходит из `Api.TypePeer`. */
4
10
  export type MtprotoPeerLike = {
@@ -93,6 +99,7 @@ export type MtprotoEntityLike = {
93
99
  offset?: number;
94
100
  length?: number;
95
101
  url?: string;
102
+ language?: string;
96
103
  };
97
104
 
98
105
  /** Кнопка под сообщением. Адрес несёт только `KeyboardButtonUrl`, у остальных его нет. */
@@ -233,6 +240,35 @@ export function collectLinks(message: MtprotoMessageLike): IncomingLink[] {
233
240
  return links;
234
241
  }
235
242
 
243
+ const FORMATTING: Record<string, TextEntityType> = {
244
+ MessageEntityBold: "bold",
245
+ MessageEntityItalic: "italic",
246
+ MessageEntityUnderline: "underline",
247
+ MessageEntityStrike: "strike",
248
+ MessageEntitySpoiler: "spoiler",
249
+ MessageEntityCode: "code",
250
+ MessageEntityPre: "pre",
251
+ MessageEntityTextUrl: "link",
252
+ MessageEntityBlockquote: "quote",
253
+ };
254
+
255
+ export function formattingEntities(message: MtprotoMessageLike): TextEntity[] {
256
+ const result: TextEntity[] = [];
257
+ for (const entity of message.entities ?? []) {
258
+ // Упоминания, хэштеги, голые адреса — не форматирование: пары в общей модели у них нет.
259
+ const type = FORMATTING[entity.className ?? ""];
260
+ if (!type || entity.offset === undefined || entity.length === undefined) continue;
261
+ result.push({
262
+ type,
263
+ offset: entity.offset,
264
+ length: entity.length,
265
+ ...(type === "link" && entity.url ? { url: entity.url } : {}),
266
+ ...(type === "pre" && entity.language ? { language: entity.language } : {}),
267
+ });
268
+ }
269
+ return result;
270
+ }
271
+
236
272
  /**
237
273
  * Сообщение живой учётной записи → общий вид.
238
274
  *
@@ -297,6 +333,7 @@ export function toIncomingMessage(
297
333
  sentAt: message.date ? new Date(message.date * 1000) : undefined,
298
334
  // Непоправленное сообщение приходит с `editDate: null` — это не правка в 1970 году.
299
335
  editedAt: message.editDate ? new Date(message.editDate * 1000) : undefined,
336
+ entities: formattingEntities(message),
300
337
  attachments: collectAttachments(message, chatId),
301
338
  links: collectLinks(message),
302
339
  mentionsMe: typeof message.mentioned === "boolean" ? message.mentioned : undefined,
@@ -341,6 +378,38 @@ export class IncomingDispatcher {
341
378
  }
342
379
  }
343
380
 
381
+ /**
382
+ * Раздача СВОИХ исходящих — зеркало IncomingDispatcher: отдаёт только `out: true`, чужое пропускает.
383
+ * Своё ли это отправление транспорта, решает вызывающий: он знает, что сам отправлял.
384
+ */
385
+ export class OutgoingDispatcher {
386
+ private readonly handlers = new Set<(message: IncomingMessage) => void | Promise<void>>();
387
+
388
+ add(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
389
+ this.handlers.add(handler);
390
+ return () => {
391
+ this.handlers.delete(handler);
392
+ };
393
+ }
394
+
395
+ get size(): number {
396
+ return this.handlers.size;
397
+ }
398
+
399
+ async dispatch(
400
+ message: MtprotoMessageLike,
401
+ transportId: string,
402
+ accountId: string,
403
+ sentByTransport: boolean,
404
+ ): Promise<boolean> {
405
+ if (message.out !== true) return false;
406
+ if (this.handlers.size === 0) return false;
407
+ const outgoing = { ...toIncomingMessage(message, transportId, accountId), sentByTransport };
408
+ for (const handler of this.handlers) await handler(outgoing);
409
+ return true;
410
+ }
411
+ }
412
+
344
413
  /** Клиент MTProto в той части, которой достаточно для скачивания. Объявлен ради двойника в тесте. */
345
414
  export type DownloadingClient = {
346
415
  getMessages(peer: string, params: { ids: number[] }): Promise<{ media?: unknown }[]>;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  collectAttachments,
8
8
  collectLinks,
9
9
  downloadFromMessage,
10
+ formattingEntities,
10
11
  IncomingDispatcher,
11
12
  peerToChat,
12
13
  unmarkPeerId,
@@ -1,4 +1,7 @@
1
1
  import type {
2
+ DraftTransport,
3
+ MessageDraft,
4
+ OutgoingObserverTransport,
2
5
  ButtonPress,
3
6
  ButtonPressTransport,
4
7
  IncomingAttachment,
@@ -7,6 +10,7 @@ import type {
7
10
  ReadReceiptTransport,
8
11
  EditableMessage,
9
12
  EditableMessageTransport,
13
+ EditedMessageTransport,
10
14
  EditResult,
11
15
  MessageReaction,
12
16
  MessageRecipient,
@@ -19,7 +23,7 @@ import type {
19
23
  TransportStatus,
20
24
  TypingTransport,
21
25
  } from "@imessaging/core";
22
- import { isTelegramRecipient, MemoryPeerStore, formattedOf } from "@imessaging/core";
26
+ import { formatText, formattedOf, isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
23
27
  import { toTelegramEntities } from "./entities";
24
28
  import { peerToChat, unmarkPeerId } from "./incoming";
25
29
  import bigInt from "big-integer";
@@ -27,12 +31,14 @@ import { Api, TelegramClient } from "telegram";
27
31
  import { CustomFile } from "telegram/client/uploads";
28
32
  import { CallbackQuery } from "telegram/events/CallbackQuery";
29
33
  import { NewMessage } from "telegram/events/NewMessage";
34
+ import { EditedMessage } from "telegram/events/EditedMessage";
30
35
  import type { TelegramClientParams } from "telegram/client/telegramBaseClient";
31
36
  import { StringSession } from "telegram/sessions";
32
37
 
33
38
  import {
34
39
  downloadFromMessage,
35
40
  IncomingDispatcher,
41
+ OutgoingDispatcher,
36
42
  toIncomingMessage,
37
43
  type DownloadingClient,
38
44
  type MtprotoMessageLike,
@@ -86,11 +92,24 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
86
92
  return chat.className === "Channel";
87
93
  }
88
94
 
95
+ const OWN_MEMORY_MS = 10 * 60 * 1000;
96
+
97
+ /** Номер чата входного пира в том же маркированном виде, что chatId во входящем (peerToChat). */
98
+ function inputPeerChatId(peer: Api.TypeInputPeer): string {
99
+ if (peer instanceof Api.InputPeerChannel) return peerToChat({ channelId: peer.channelId }).chatId;
100
+ if (peer instanceof Api.InputPeerChat) return peerToChat({ chatId: peer.chatId }).chatId;
101
+ if (peer instanceof Api.InputPeerUser) return peerToChat({ userId: peer.userId }).chatId;
102
+ throw new Error(`Unsupported input peer: ${peer.className}`);
103
+ }
104
+
89
105
  export class TelegramMtprotoTransport
90
106
  implements
107
+ DraftTransport,
91
108
  EditableMessageTransport,
109
+ EditedMessageTransport,
92
110
  ButtonPressTransport,
93
111
  IncomingMessageTransport,
112
+ OutgoingObserverTransport,
94
113
  ReactionTransport,
95
114
  ReadReceiptTransport,
96
115
  TypingTransport
@@ -102,6 +121,20 @@ export class TelegramMtprotoTransport
102
121
  private lastActivityAt?: Date;
103
122
  private readonly incoming = new IncomingDispatcher();
104
123
  private incomingListener: ((event: unknown) => void) | null = null;
124
+ private readonly edits = new IncomingDispatcher();
125
+ private editListener: ((event: unknown) => void) | null = null;
126
+ private readonly outgoing = new OutgoingDispatcher();
127
+ private outgoingListener: ((event: unknown) => void) | null = null;
128
+ private readonly outgoingEdits = new OutgoingDispatcher();
129
+ private outgoingEditListener: ((event: unknown) => void) | null = null;
130
+ /**
131
+ * Что отправил и поправил сам транспорт: «чат:сообщение» → когда. Своё приходит тем же обновлением
132
+ * out=true, что и отправленное человеком с телефона, и без этой памяти их не различить.
133
+ */
134
+ private readonly ownSent = new Map<string, number>();
135
+ private readonly ownEdited = new Map<string, number>();
136
+ /** Идущие send/edit: обновление о своём сообщении может прийти раньше, чем вызов вернёт его номер. */
137
+ private readonly ownPending = new Set<Promise<unknown>>();
105
138
  /** Отметка жизни подписки: срабатывает на любом обновлении, включая служебные. */
106
139
  private activityListener: ((event: unknown) => void) | null = null;
107
140
 
@@ -187,6 +220,140 @@ export class TelegramMtprotoTransport
187
220
  };
188
221
  }
189
222
 
223
+ /**
224
+ * Черновик в поле ввода чата — ответ готовит машина, отправляет человек. Разметка — тем же рендером, что у
225
+ * send (`format`); без него текст уходит как есть. Пустой текст очищает черновик.
226
+ */
227
+ async saveDraft(draft: MessageDraft): Promise<void> {
228
+ this.assertConnected();
229
+ if (!isTelegramRecipient(draft.recipient)) {
230
+ throw new Error(`Telegram MTProto cannot save a draft for ${draft.recipient.type}`);
231
+ }
232
+ const replyTo =
233
+ draft.replyToMessageId === undefined ? undefined : Number(draft.replyToMessageId);
234
+ if (replyTo !== undefined && (!Number.isSafeInteger(replyTo) || replyTo <= 0)) {
235
+ throw new Error(`Telegram message id must be a positive integer: ${draft.replyToMessageId}`);
236
+ }
237
+ const ft = draft.format ? formatText(draft.text, draft.format) : undefined;
238
+ const save = async (peer: Api.TypeInputPeer) =>
239
+ this.client.invoke(
240
+ new Api.messages.SaveDraft({
241
+ peer,
242
+ message: ft ? ft.text : draft.text,
243
+ ...(ft && ft.entities.length > 0 ? { entities: toTelegramEntities(ft) } : {}),
244
+ ...(replyTo ? { replyTo: new Api.InputReplyToMessage({ replyToMsgId: replyTo }) } : {}),
245
+ }),
246
+ );
247
+ try {
248
+ await save(await this.getInputPeer(draft.recipient));
249
+ } catch (error: unknown) {
250
+ // Как у send: устаревший пир из хранилища — забыть и разрешить заново, один раз.
251
+ if (!this.isInvalidPeerError(error)) throw error;
252
+ await this.peerStore.delete(this.options.accountId, draft.recipient);
253
+ await save(await this.getInputPeer(draft.recipient));
254
+ }
255
+ this.lastActivityAt = new Date();
256
+ }
257
+
258
+ /** Свои новые исходящие — отправленное человеком с другого устройства; отправленное через send помечено sentByTransport. */
259
+ onOutgoing(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
260
+ const remove = this.outgoing.add(handler);
261
+ if (this.outgoingListener === null) {
262
+ this.outgoingListener = (event: unknown): void => {
263
+ const message = (event as { message?: MtprotoMessageLike }).message;
264
+ if (!message) return;
265
+ this.lastActivityAt = new Date();
266
+ void this.dispatchOwn(this.outgoing, this.ownSent, message);
267
+ };
268
+ this.client.addEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
269
+ }
270
+ return () => {
271
+ remove();
272
+ if (this.outgoing.size > 0 || this.outgoingListener === null) return;
273
+ this.client.removeEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
274
+ this.outgoingListener = null;
275
+ };
276
+ }
277
+
278
+ /** Правки своих сообщений; поправленное через edit помечено sentByTransport. */
279
+ onOutgoingEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
280
+ const remove = this.outgoingEdits.add(handler);
281
+ if (this.outgoingEditListener === null) {
282
+ this.outgoingEditListener = (event: unknown): void => {
283
+ const message = (event as { message?: MtprotoMessageLike }).message;
284
+ if (!message) return;
285
+ this.lastActivityAt = new Date();
286
+ void this.dispatchOwn(this.outgoingEdits, this.ownEdited, message);
287
+ };
288
+ this.client.addEventHandler(this.outgoingEditListener, new EditedMessage({ outgoing: true }));
289
+ }
290
+ return () => {
291
+ remove();
292
+ if (this.outgoingEdits.size > 0 || this.outgoingEditListener === null) return;
293
+ this.client.removeEventHandler(
294
+ this.outgoingEditListener,
295
+ new EditedMessage({ outgoing: true }),
296
+ );
297
+ this.outgoingEditListener = null;
298
+ };
299
+ }
300
+
301
+ private async dispatchOwn(
302
+ dispatcher: OutgoingDispatcher,
303
+ own: Map<string, number>,
304
+ message: MtprotoMessageLike,
305
+ ): Promise<void> {
306
+ if (message.out !== true || !message.peerId) return;
307
+ // Своё ли это, известно только после того, как идущие send/edit вернули номера.
308
+ await Promise.allSettled(this.ownPending);
309
+ const key = `${peerToChat(message.peerId).chatId}:${message.id}`;
310
+ await dispatcher.dispatch(message, this.id, this.options.accountId, own.has(key));
311
+ }
312
+
313
+ private trackOwn<T>(work: Promise<T>): Promise<T> {
314
+ this.ownPending.add(work);
315
+ void work.then(
316
+ () => this.ownPending.delete(work),
317
+ () => this.ownPending.delete(work),
318
+ );
319
+ return work;
320
+ }
321
+
322
+ /** Номер своего сообщения; память на 10 минут — обновление приходит за секунды, дольше держать незачем. */
323
+ private rememberOwn(own: Map<string, number>, peer: Api.TypeInputPeer, messageId: number): void {
324
+ const now = Date.now();
325
+ for (const [key, at] of own) if (now - at > OWN_MEMORY_MS) own.delete(key);
326
+ own.set(`${inputPeerChatId(peer)}:${messageId}`, now);
327
+ }
328
+
329
+ onEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
330
+ const remove = this.edits.add(handler);
331
+ this.startEditListeningIfNeeded();
332
+ return () => {
333
+ remove();
334
+ this.stopEditListeningIfIdle();
335
+ };
336
+ }
337
+
338
+ private startEditListeningIfNeeded(): void {
339
+ if (this.editListener !== null || this.edits.size === 0) return;
340
+ const listener = (event: unknown): void => {
341
+ const message = (event as { message?: MtprotoMessageLike }).message;
342
+ if (!message) return;
343
+ this.lastActivityAt = new Date();
344
+ void this.rememberSender(message);
345
+ void this.edits.dispatch(message, this.id, this.options.accountId);
346
+ };
347
+ this.editListener = listener;
348
+ this.client.addEventHandler(listener, new EditedMessage({}));
349
+ }
350
+
351
+ private stopEditListeningIfIdle(): void {
352
+ if (this.edits.size > 0 || this.editListener === null) return;
353
+ this.client.removeEventHandler(this.editListener, new EditedMessage({}));
354
+ this.editListener = null;
355
+ }
356
+
190
357
  private startListeningIfNeeded(): void {
191
358
  if (this.incomingListener !== null || this.incoming.size === 0) return;
192
359
  const listener = (event: unknown): void => {
@@ -359,7 +526,7 @@ export class TelegramMtprotoTransport
359
526
 
360
527
  async send(message: OutboundMessage): Promise<SendResult> {
361
528
  this.assertConnected();
362
- return this.sendWithRetry(message, false);
529
+ return this.trackOwn(this.sendWithRetry(message, false));
363
530
  }
364
531
 
365
532
  /**
@@ -375,7 +542,7 @@ export class TelegramMtprotoTransport
375
542
  */
376
543
  async edit(message: EditableMessage): Promise<EditResult> {
377
544
  this.assertConnected();
378
- return this.editWithRetry(message, false);
545
+ return this.trackOwn(this.editWithRetry(message, false));
379
546
  }
380
547
 
381
548
  async getStatus(): Promise<TransportStatus> {
@@ -515,13 +682,15 @@ export class TelegramMtprotoTransport
515
682
  formattingEntities,
516
683
  replyTo: message.replyToMessageId ?? message.threadId,
517
684
  });
685
+ this.rememberOwn(this.ownSent, peer, sent.id);
518
686
  for (const document of message.documents ?? []) {
519
687
  const data = Buffer.from(document.data);
520
- await this.client.sendFile(peer, {
688
+ const file = await this.client.sendFile(peer, {
521
689
  file: new CustomFile(document.filename, data.length, "", data),
522
690
  caption: document.caption,
523
691
  replyTo: message.threadId,
524
692
  });
693
+ this.rememberOwn(this.ownSent, peer, file.id);
525
694
  }
526
695
  this.lastActivityAt = new Date();
527
696
  return {
@@ -672,6 +841,8 @@ export class TelegramMtprotoTransport
672
841
  let peer: Api.TypeInputPeer | null = null;
673
842
  try {
674
843
  peer = await this.getInputPeer(message.recipient);
844
+ // До вызова: обновление о правке может прийти раньше ответа на неё.
845
+ this.rememberOwn(this.ownEdited, peer, messageId);
675
846
  const ft = formattedOf(message);
676
847
  const text = ft ? ft.text : message.text;
677
848
  const parseMode = ft ? undefined : message.parseMode?.toLowerCase();