@imessaging/telegram-mtproto 0.8.3 → 0.8.5

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.5",
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.5",
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,9 @@
1
1
  import type {
2
+ PhoneLookupTransport,
3
+ PhoneMatch,
4
+ DraftTransport,
5
+ MessageDraft,
6
+ OutgoingObserverTransport,
2
7
  ButtonPress,
3
8
  ButtonPressTransport,
4
9
  IncomingAttachment,
@@ -20,7 +25,7 @@ import type {
20
25
  TransportStatus,
21
26
  TypingTransport,
22
27
  } from "@imessaging/core";
23
- import { isTelegramRecipient, MemoryPeerStore, formattedOf } from "@imessaging/core";
28
+ import { formatText, formattedOf, isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
24
29
  import { toTelegramEntities } from "./entities";
25
30
  import { peerToChat, unmarkPeerId } from "./incoming";
26
31
  import bigInt from "big-integer";
@@ -35,6 +40,7 @@ import { StringSession } from "telegram/sessions";
35
40
  import {
36
41
  downloadFromMessage,
37
42
  IncomingDispatcher,
43
+ OutgoingDispatcher,
38
44
  toIncomingMessage,
39
45
  type DownloadingClient,
40
46
  type MtprotoMessageLike,
@@ -88,12 +94,28 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
88
94
  return chat.className === "Channel";
89
95
  }
90
96
 
97
+ const OWN_MEMORY_MS = 10 * 60 * 1000;
98
+
99
+ /** Номер не занят Telegram или не принят в этой форме — «не найдено», а не отказ. */
100
+ const PHONE_NOT_FOUND_ERRORS = new Set(["PHONE_NOT_OCCUPIED", "PHONE_NUMBER_INVALID"]);
101
+
102
+ /** Номер чата входного пира в том же маркированном виде, что chatId во входящем (peerToChat). */
103
+ function inputPeerChatId(peer: Api.TypeInputPeer): string {
104
+ if (peer instanceof Api.InputPeerChannel) return peerToChat({ channelId: peer.channelId }).chatId;
105
+ if (peer instanceof Api.InputPeerChat) return peerToChat({ chatId: peer.chatId }).chatId;
106
+ if (peer instanceof Api.InputPeerUser) return peerToChat({ userId: peer.userId }).chatId;
107
+ throw new Error(`Unsupported input peer: ${peer.className}`);
108
+ }
109
+
91
110
  export class TelegramMtprotoTransport
92
111
  implements
112
+ DraftTransport,
93
113
  EditableMessageTransport,
94
114
  EditedMessageTransport,
95
115
  ButtonPressTransport,
96
116
  IncomingMessageTransport,
117
+ OutgoingObserverTransport,
118
+ PhoneLookupTransport,
97
119
  ReactionTransport,
98
120
  ReadReceiptTransport,
99
121
  TypingTransport
@@ -107,6 +129,18 @@ export class TelegramMtprotoTransport
107
129
  private incomingListener: ((event: unknown) => void) | null = null;
108
130
  private readonly edits = new IncomingDispatcher();
109
131
  private editListener: ((event: unknown) => void) | null = null;
132
+ private readonly outgoing = new OutgoingDispatcher();
133
+ private outgoingListener: ((event: unknown) => void) | null = null;
134
+ private readonly outgoingEdits = new OutgoingDispatcher();
135
+ private outgoingEditListener: ((event: unknown) => void) | null = null;
136
+ /**
137
+ * Что отправил и поправил сам транспорт: «чат:сообщение» → когда. Своё приходит тем же обновлением
138
+ * out=true, что и отправленное человеком с телефона, и без этой памяти их не различить.
139
+ */
140
+ private readonly ownSent = new Map<string, number>();
141
+ private readonly ownEdited = new Map<string, number>();
142
+ /** Идущие send/edit: обновление о своём сообщении может прийти раньше, чем вызов вернёт его номер. */
143
+ private readonly ownPending = new Set<Promise<unknown>>();
110
144
  /** Отметка жизни подписки: срабатывает на любом обновлении, включая служебные. */
111
145
  private activityListener: ((event: unknown) => void) | null = null;
112
146
 
@@ -192,6 +226,193 @@ export class TelegramMtprotoTransport
192
226
  };
193
227
  }
194
228
 
229
+ /**
230
+ * Учётка по номеру телефона: сначала contacts.ResolvePhone (номер, открытый настройками приватности),
231
+ * затем contacts.ImportContacts (номер виден только контакту). Контакт, заведённый ради поиска, сразу
232
+ * удаляется — адресная книга не растёт; бывший контактом до поиска остаётся на месте.
233
+ */
234
+ async findByPhone(phone: string): Promise<PhoneMatch | null> {
235
+ this.assertConnected();
236
+ const digits = phone.replace(/[\s()-]/g, "").replace(/^\+/, "");
237
+ if (!/^\d{7,15}$/.test(digits))
238
+ throw new Error(`Phone must be E.164, e.g. +79991234567: ${phone}`);
239
+
240
+ // Одни серверы ждут номер без «+», другие с ним — так вёл поиск и прежний клиент italent.
241
+ for (const variant of [digits, `+${digits}`]) {
242
+ try {
243
+ const resolved = await this.client.invoke(
244
+ new Api.contacts.ResolvePhone({ phone: variant }),
245
+ );
246
+ const peer = resolved.peer;
247
+ const user = isPeerUser(peer)
248
+ ? resolved.users.find((u): u is Api.User => isUser(u) && u.id.equals(peer.userId))
249
+ : undefined;
250
+ if (user) return this.rememberPhoneMatch(user);
251
+ } catch (error: unknown) {
252
+ if (!PHONE_NOT_FOUND_ERRORS.has((error as TelegramRpcError).errorMessage ?? ""))
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ const known = new Set(
258
+ (await this.client.invoke(new Api.contacts.GetContactIDs({ hash: bigInt(0) }))).map(String),
259
+ );
260
+ const imported = await this.client.invoke(
261
+ new Api.contacts.ImportContacts({
262
+ contacts: [
263
+ new Api.InputPhoneContact({
264
+ clientId: bigInt(Date.now()),
265
+ phone: `+${digits}`,
266
+ firstName: `+${digits}`,
267
+ lastName: "",
268
+ }),
269
+ ],
270
+ }),
271
+ );
272
+ const user = imported.users.find((u): u is Api.User => isUser(u));
273
+ if (!user) return null;
274
+ try {
275
+ return await this.rememberPhoneMatch(user);
276
+ } finally {
277
+ if (!known.has(user.id.toString()) && user.accessHash) {
278
+ await this.client.invoke(
279
+ new Api.contacts.DeleteContacts({
280
+ id: [new Api.InputUser({ userId: user.id, accessHash: user.accessHash })],
281
+ }),
282
+ );
283
+ }
284
+ }
285
+ }
286
+
287
+ private async rememberPhoneMatch(user: Api.User): Promise<PhoneMatch> {
288
+ const userId = user.id.toString();
289
+ if (user.accessHash) {
290
+ await this.peerStore.set(
291
+ this.options.accountId,
292
+ { type: "user", id: userId },
293
+ {
294
+ type: "user",
295
+ id: userId,
296
+ accessHash: user.accessHash.toString(),
297
+ ...(user.username ? { username: user.username } : {}),
298
+ resolvedAt: new Date().toISOString(),
299
+ },
300
+ this.options.peerTtlSeconds,
301
+ );
302
+ }
303
+ return {
304
+ userId,
305
+ ...(user.username ? { username: user.username } : {}),
306
+ ...(user.firstName ? { firstName: user.firstName } : {}),
307
+ };
308
+ }
309
+
310
+ /**
311
+ * Черновик в поле ввода чата — ответ готовит машина, отправляет человек. Разметка — тем же рендером, что у
312
+ * send (`format`); без него текст уходит как есть. Пустой текст очищает черновик.
313
+ */
314
+ async saveDraft(draft: MessageDraft): Promise<void> {
315
+ this.assertConnected();
316
+ if (!isTelegramRecipient(draft.recipient)) {
317
+ throw new Error(`Telegram MTProto cannot save a draft for ${draft.recipient.type}`);
318
+ }
319
+ const replyTo =
320
+ draft.replyToMessageId === undefined ? undefined : Number(draft.replyToMessageId);
321
+ if (replyTo !== undefined && (!Number.isSafeInteger(replyTo) || replyTo <= 0)) {
322
+ throw new Error(`Telegram message id must be a positive integer: ${draft.replyToMessageId}`);
323
+ }
324
+ const ft = draft.format ? formatText(draft.text, draft.format) : undefined;
325
+ const save = async (peer: Api.TypeInputPeer) =>
326
+ this.client.invoke(
327
+ new Api.messages.SaveDraft({
328
+ peer,
329
+ message: ft ? ft.text : draft.text,
330
+ ...(ft && ft.entities.length > 0 ? { entities: toTelegramEntities(ft) } : {}),
331
+ ...(replyTo ? { replyTo: new Api.InputReplyToMessage({ replyToMsgId: replyTo }) } : {}),
332
+ }),
333
+ );
334
+ try {
335
+ await save(await this.getInputPeer(draft.recipient));
336
+ } catch (error: unknown) {
337
+ // Как у send: устаревший пир из хранилища — забыть и разрешить заново, один раз.
338
+ if (!this.isInvalidPeerError(error)) throw error;
339
+ await this.peerStore.delete(this.options.accountId, draft.recipient);
340
+ await save(await this.getInputPeer(draft.recipient));
341
+ }
342
+ this.lastActivityAt = new Date();
343
+ }
344
+
345
+ /** Свои новые исходящие — отправленное человеком с другого устройства; отправленное через send помечено sentByTransport. */
346
+ onOutgoing(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
347
+ const remove = this.outgoing.add(handler);
348
+ if (this.outgoingListener === null) {
349
+ this.outgoingListener = (event: unknown): void => {
350
+ const message = (event as { message?: MtprotoMessageLike }).message;
351
+ if (!message) return;
352
+ this.lastActivityAt = new Date();
353
+ void this.dispatchOwn(this.outgoing, this.ownSent, message);
354
+ };
355
+ this.client.addEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
356
+ }
357
+ return () => {
358
+ remove();
359
+ if (this.outgoing.size > 0 || this.outgoingListener === null) return;
360
+ this.client.removeEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
361
+ this.outgoingListener = null;
362
+ };
363
+ }
364
+
365
+ /** Правки своих сообщений; поправленное через edit помечено sentByTransport. */
366
+ onOutgoingEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
367
+ const remove = this.outgoingEdits.add(handler);
368
+ if (this.outgoingEditListener === null) {
369
+ this.outgoingEditListener = (event: unknown): void => {
370
+ const message = (event as { message?: MtprotoMessageLike }).message;
371
+ if (!message) return;
372
+ this.lastActivityAt = new Date();
373
+ void this.dispatchOwn(this.outgoingEdits, this.ownEdited, message);
374
+ };
375
+ this.client.addEventHandler(this.outgoingEditListener, new EditedMessage({ outgoing: true }));
376
+ }
377
+ return () => {
378
+ remove();
379
+ if (this.outgoingEdits.size > 0 || this.outgoingEditListener === null) return;
380
+ this.client.removeEventHandler(
381
+ this.outgoingEditListener,
382
+ new EditedMessage({ outgoing: true }),
383
+ );
384
+ this.outgoingEditListener = null;
385
+ };
386
+ }
387
+
388
+ private async dispatchOwn(
389
+ dispatcher: OutgoingDispatcher,
390
+ own: Map<string, number>,
391
+ message: MtprotoMessageLike,
392
+ ): Promise<void> {
393
+ if (message.out !== true || !message.peerId) return;
394
+ // Своё ли это, известно только после того, как идущие send/edit вернули номера.
395
+ await Promise.allSettled(this.ownPending);
396
+ const key = `${peerToChat(message.peerId).chatId}:${message.id}`;
397
+ await dispatcher.dispatch(message, this.id, this.options.accountId, own.has(key));
398
+ }
399
+
400
+ private trackOwn<T>(work: Promise<T>): Promise<T> {
401
+ this.ownPending.add(work);
402
+ void work.then(
403
+ () => this.ownPending.delete(work),
404
+ () => this.ownPending.delete(work),
405
+ );
406
+ return work;
407
+ }
408
+
409
+ /** Номер своего сообщения; память на 10 минут — обновление приходит за секунды, дольше держать незачем. */
410
+ private rememberOwn(own: Map<string, number>, peer: Api.TypeInputPeer, messageId: number): void {
411
+ const now = Date.now();
412
+ for (const [key, at] of own) if (now - at > OWN_MEMORY_MS) own.delete(key);
413
+ own.set(`${inputPeerChatId(peer)}:${messageId}`, now);
414
+ }
415
+
195
416
  onEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
196
417
  const remove = this.edits.add(handler);
197
418
  this.startEditListeningIfNeeded();
@@ -392,7 +613,7 @@ export class TelegramMtprotoTransport
392
613
 
393
614
  async send(message: OutboundMessage): Promise<SendResult> {
394
615
  this.assertConnected();
395
- return this.sendWithRetry(message, false);
616
+ return this.trackOwn(this.sendWithRetry(message, false));
396
617
  }
397
618
 
398
619
  /**
@@ -408,7 +629,7 @@ export class TelegramMtprotoTransport
408
629
  */
409
630
  async edit(message: EditableMessage): Promise<EditResult> {
410
631
  this.assertConnected();
411
- return this.editWithRetry(message, false);
632
+ return this.trackOwn(this.editWithRetry(message, false));
412
633
  }
413
634
 
414
635
  async getStatus(): Promise<TransportStatus> {
@@ -548,13 +769,15 @@ export class TelegramMtprotoTransport
548
769
  formattingEntities,
549
770
  replyTo: message.replyToMessageId ?? message.threadId,
550
771
  });
772
+ this.rememberOwn(this.ownSent, peer, sent.id);
551
773
  for (const document of message.documents ?? []) {
552
774
  const data = Buffer.from(document.data);
553
- await this.client.sendFile(peer, {
775
+ const file = await this.client.sendFile(peer, {
554
776
  file: new CustomFile(document.filename, data.length, "", data),
555
777
  caption: document.caption,
556
778
  replyTo: message.threadId,
557
779
  });
780
+ this.rememberOwn(this.ownSent, peer, file.id);
558
781
  }
559
782
  this.lastActivityAt = new Date();
560
783
  return {
@@ -705,6 +928,8 @@ export class TelegramMtprotoTransport
705
928
  let peer: Api.TypeInputPeer | null = null;
706
929
  try {
707
930
  peer = await this.getInputPeer(message.recipient);
931
+ // До вызова: обновление о правке может прийти раньше ответа на неё.
932
+ this.rememberOwn(this.ownEdited, peer, messageId);
708
933
  const ft = formattedOf(message);
709
934
  const text = ft ? ft.text : message.text;
710
935
  const parseMode = ft ? undefined : message.parseMode?.toLowerCase();