@imessaging/telegram-mtproto 0.8.3 → 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.3",
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.3",
34
+ "@imessaging/core": "^0.8.4",
35
35
  "big-integer": "^1.6.52"
36
36
  },
37
37
  "peerDependencies": {
package/src/incoming.ts CHANGED
@@ -378,6 +378,38 @@ export class IncomingDispatcher {
378
378
  }
379
379
  }
380
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
+
381
413
  /** Клиент MTProto в той части, которой достаточно для скачивания. Объявлен ради двойника в тесте. */
382
414
  export type DownloadingClient = {
383
415
  getMessages(peer: string, params: { ids: number[] }): Promise<{ media?: unknown }[]>;
@@ -1,4 +1,7 @@
1
1
  import type {
2
+ DraftTransport,
3
+ MessageDraft,
4
+ OutgoingObserverTransport,
2
5
  ButtonPress,
3
6
  ButtonPressTransport,
4
7
  IncomingAttachment,
@@ -20,7 +23,7 @@ import type {
20
23
  TransportStatus,
21
24
  TypingTransport,
22
25
  } from "@imessaging/core";
23
- import { isTelegramRecipient, MemoryPeerStore, formattedOf } from "@imessaging/core";
26
+ import { formatText, formattedOf, isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
24
27
  import { toTelegramEntities } from "./entities";
25
28
  import { peerToChat, unmarkPeerId } from "./incoming";
26
29
  import bigInt from "big-integer";
@@ -35,6 +38,7 @@ import { StringSession } from "telegram/sessions";
35
38
  import {
36
39
  downloadFromMessage,
37
40
  IncomingDispatcher,
41
+ OutgoingDispatcher,
38
42
  toIncomingMessage,
39
43
  type DownloadingClient,
40
44
  type MtprotoMessageLike,
@@ -88,12 +92,24 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
88
92
  return chat.className === "Channel";
89
93
  }
90
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
+
91
105
  export class TelegramMtprotoTransport
92
106
  implements
107
+ DraftTransport,
93
108
  EditableMessageTransport,
94
109
  EditedMessageTransport,
95
110
  ButtonPressTransport,
96
111
  IncomingMessageTransport,
112
+ OutgoingObserverTransport,
97
113
  ReactionTransport,
98
114
  ReadReceiptTransport,
99
115
  TypingTransport
@@ -107,6 +123,18 @@ export class TelegramMtprotoTransport
107
123
  private incomingListener: ((event: unknown) => void) | null = null;
108
124
  private readonly edits = new IncomingDispatcher();
109
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>>();
110
138
  /** Отметка жизни подписки: срабатывает на любом обновлении, включая служебные. */
111
139
  private activityListener: ((event: unknown) => void) | null = null;
112
140
 
@@ -192,6 +220,112 @@ export class TelegramMtprotoTransport
192
220
  };
193
221
  }
194
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
+
195
329
  onEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
196
330
  const remove = this.edits.add(handler);
197
331
  this.startEditListeningIfNeeded();
@@ -392,7 +526,7 @@ export class TelegramMtprotoTransport
392
526
 
393
527
  async send(message: OutboundMessage): Promise<SendResult> {
394
528
  this.assertConnected();
395
- return this.sendWithRetry(message, false);
529
+ return this.trackOwn(this.sendWithRetry(message, false));
396
530
  }
397
531
 
398
532
  /**
@@ -408,7 +542,7 @@ export class TelegramMtprotoTransport
408
542
  */
409
543
  async edit(message: EditableMessage): Promise<EditResult> {
410
544
  this.assertConnected();
411
- return this.editWithRetry(message, false);
545
+ return this.trackOwn(this.editWithRetry(message, false));
412
546
  }
413
547
 
414
548
  async getStatus(): Promise<TransportStatus> {
@@ -548,13 +682,15 @@ export class TelegramMtprotoTransport
548
682
  formattingEntities,
549
683
  replyTo: message.replyToMessageId ?? message.threadId,
550
684
  });
685
+ this.rememberOwn(this.ownSent, peer, sent.id);
551
686
  for (const document of message.documents ?? []) {
552
687
  const data = Buffer.from(document.data);
553
- await this.client.sendFile(peer, {
688
+ const file = await this.client.sendFile(peer, {
554
689
  file: new CustomFile(document.filename, data.length, "", data),
555
690
  caption: document.caption,
556
691
  replyTo: message.threadId,
557
692
  });
693
+ this.rememberOwn(this.ownSent, peer, file.id);
558
694
  }
559
695
  this.lastActivityAt = new Date();
560
696
  return {
@@ -705,6 +841,8 @@ export class TelegramMtprotoTransport
705
841
  let peer: Api.TypeInputPeer | null = null;
706
842
  try {
707
843
  peer = await this.getInputPeer(message.recipient);
844
+ // До вызова: обновление о правке может прийти раньше ответа на неё.
845
+ this.rememberOwn(this.ownEdited, peer, messageId);
708
846
  const ft = formattedOf(message);
709
847
  const text = ft ? ft.text : message.text;
710
848
  const parseMode = ft ? undefined : message.parseMode?.toLowerCase();