@imessaging/telegram-mtproto 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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.5.0",
34
+ "@imessaging/core": "0.6.0",
35
35
  "big-integer": "^1.6.52"
36
36
  },
37
37
  "peerDependencies": {
@@ -0,0 +1,199 @@
1
+ import type { IncomingAttachment, IncomingMessage } from "@imessaging/core";
2
+
3
+ /** Пир MTProto в той части, по которой определяется чат. Приходит из `Api.TypePeer`. */
4
+ export type MtprotoPeerLike = {
5
+ className?: string;
6
+ userId?: { toString(): string };
7
+ chatId?: { toString(): string };
8
+ channelId?: { toString(): string };
9
+ };
10
+
11
+ /** Сообщение MTProto в той части, которая переносится в общий вид. */
12
+ export type MtprotoMessageLike = {
13
+ id: number | string;
14
+ message?: string;
15
+ /** `true` — сообщение отправлено САМОЙ учётной записью. */
16
+ out?: boolean;
17
+ peerId?: MtprotoPeerLike;
18
+ fromId?: MtprotoPeerLike;
19
+ media?: MtprotoMediaLike;
20
+ replyTo?: { replyToMsgId?: number | string };
21
+ /**
22
+ * Отправитель целиком — его приносит обновление вместе с `accessHash`.
23
+ *
24
+ * Нужен не ради имени: по одному номеру ответить нельзя, пока собеседник клиенту неизвестен, а
25
+ * `accessHash` живёт только здесь. Поле необязательное: в обновлении его может не быть.
26
+ */
27
+ sender?: { username?: string; accessHash?: { toString(): string } | string | number };
28
+ };
29
+
30
+ export type MtprotoMediaLike = {
31
+ className?: string;
32
+ document?: {
33
+ mimeType?: string;
34
+ size?: { toString(): string } | number;
35
+ attributes?: { className?: string; fileName?: string }[];
36
+ };
37
+ photo?: unknown;
38
+ };
39
+
40
+ /**
41
+ * Пир → пара «чат и его вид».
42
+ *
43
+ * `chatId` берётся из пира, а НЕ из отправителя: в группе они разные, и перепутав их, ответ уехал
44
+ * бы в личку тому, кто написал в общий чат.
45
+ */
46
+ export function peerToChat(peer: MtprotoPeerLike | undefined): {
47
+ chatId: string;
48
+ chatType: IncomingMessage["chatType"];
49
+ } {
50
+ if (peer?.channelId !== undefined)
51
+ return { chatId: peer.channelId.toString(), chatType: "channel" };
52
+ if (peer?.chatId !== undefined) return { chatId: peer.chatId.toString(), chatType: "group" };
53
+ if (peer?.userId !== undefined) return { chatId: peer.userId.toString(), chatType: "user" };
54
+ throw new Error("MTProto peer without userId, chatId or channelId");
55
+ }
56
+
57
+ /**
58
+ * Что приехало файлом.
59
+ *
60
+ * Имя ищется среди атрибутов документа: у MTProto оно не поле, а один из атрибутов, и его может не
61
+ * быть вовсе. Разбор форматов ведётся по имени, поэтому подставляется своё.
62
+ *
63
+ * Идентификатор вложения — пара «чат и сообщение», а не идентификатор файла: у MTProto скачивание
64
+ * идёт от СООБЩЕНИЯ, и file_id, как в Bot API, здесь просто не существует.
65
+ */
66
+ export function collectAttachments(
67
+ message: MtprotoMessageLike,
68
+ chatId: string,
69
+ ): IncomingAttachment[] {
70
+ const media = message.media;
71
+ if (!media) return [];
72
+ const id = `${chatId}:${message.id}`;
73
+
74
+ if (media.document) {
75
+ const named = media.document.attributes?.find(
76
+ (attribute) => attribute.className === "DocumentAttributeFilename",
77
+ );
78
+ const size = media.document.size;
79
+ return [
80
+ {
81
+ id,
82
+ filename: named?.fileName ?? "document",
83
+ mimeType: media.document.mimeType,
84
+ sizeBytes: size === undefined ? undefined : Number(size.toString()),
85
+ },
86
+ ];
87
+ }
88
+ if (media.photo) {
89
+ return [{ id, filename: "photo.jpg", mimeType: "image/jpeg" }];
90
+ }
91
+ return [];
92
+ }
93
+
94
+ /**
95
+ * Сообщение живой учётной записи → общий вид.
96
+ *
97
+ * Вынесено отдельной функцией по той же причине, что и у бота: это ЕДИНСТВЕННОЕ место, где теряются
98
+ * поля, и потеря не падает — она видна только тем, что агент не знает, на что ему отвечают.
99
+ */
100
+ export function toIncomingMessage(
101
+ message: MtprotoMessageLike,
102
+ transportId: string,
103
+ accountId: string,
104
+ ): IncomingMessage {
105
+ const { chatId, chatType } = peerToChat(message.peerId);
106
+ // Отправителя в личке MTProto не называет: `fromId` заполняется в группах, а в диалоге один на
107
+ // один он и есть собеседник, то есть сам чат.
108
+ const senderId = message.fromId
109
+ ? peerToChat(message.fromId).chatId
110
+ : chatType === "user"
111
+ ? chatId
112
+ : null;
113
+
114
+ return {
115
+ transportId,
116
+ accountId,
117
+ chatId,
118
+ chatType,
119
+ senderId,
120
+ // Имя отправителя: по нему отвечают, когда номер клиенту неизвестен, и по нему же продукты
121
+ // связывают человека со своей учётной записью.
122
+ senderUsername: message.sender?.username,
123
+ messageId: String(message.id),
124
+ text: message.message ?? "",
125
+ attachments: collectAttachments(message, chatId),
126
+ replyToMessageId:
127
+ message.replyTo?.replyToMsgId === undefined
128
+ ? undefined
129
+ : String(message.replyTo.replyToMsgId),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Набор подписчиков на входящие и раздача им сообщения.
135
+ *
136
+ * Отдельным классом, а не полем транспорта, потому что это единственная часть приёма, которую можно
137
+ * проверить без сети: правило «своё сообщение не отдаём» живёт здесь, и оно важнее остального —
138
+ * живая учётная запись видит и то, что отправила сама, и без него шлюз отвечает на свой же ответ.
139
+ */
140
+ export class IncomingDispatcher {
141
+ private readonly handlers = new Set<(message: IncomingMessage) => void | Promise<void>>();
142
+
143
+ add(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
144
+ this.handlers.add(handler);
145
+ return () => {
146
+ this.handlers.delete(handler);
147
+ };
148
+ }
149
+
150
+ get size(): number {
151
+ return this.handlers.size;
152
+ }
153
+
154
+ /** Раздаёт входящее подписчикам. Своё исходящее не раздаётся никому. */
155
+ async dispatch(
156
+ message: MtprotoMessageLike,
157
+ transportId: string,
158
+ accountId: string,
159
+ ): Promise<boolean> {
160
+ if (message.out === true) return false;
161
+ if (this.handlers.size === 0) return false;
162
+ const incoming = toIncomingMessage(message, transportId, accountId);
163
+ for (const handler of this.handlers) await handler(incoming);
164
+ return true;
165
+ }
166
+ }
167
+
168
+ /** Клиент MTProto в той части, которой достаточно для скачивания. Объявлен ради двойника в тесте. */
169
+ export type DownloadingClient = {
170
+ getMessages(peer: string, params: { ids: number[] }): Promise<{ media?: unknown }[]>;
171
+ downloadMedia(message: unknown): Promise<Buffer | Uint8Array | undefined>;
172
+ };
173
+
174
+ /**
175
+ * Скачивание вложения по паре «чат и сообщение».
176
+ *
177
+ * Сообщение перезапрашивается, а не хранится: держать его до скачивания значило бы держать в
178
+ * памяти каждое входящее с файлом на случай, что файл однажды спросят.
179
+ */
180
+ export async function downloadFromMessage(
181
+ client: DownloadingClient,
182
+ attachment: { id: string },
183
+ ): Promise<Uint8Array> {
184
+ const separator = attachment.id.lastIndexOf(":");
185
+ if (separator <= 0) {
186
+ throw new Error(`Attachment id ${attachment.id} is not a "<chat>:<message>" pair`);
187
+ }
188
+ const chatId = attachment.id.slice(0, separator);
189
+ const messageId = Number(attachment.id.slice(separator + 1));
190
+ if (!Number.isInteger(messageId)) {
191
+ throw new Error(`Attachment id ${attachment.id} carries a non-numeric message id`);
192
+ }
193
+
194
+ const [message] = await client.getMessages(chatId, { ids: [messageId] });
195
+ if (!message) throw new Error(`Message ${attachment.id} is gone`);
196
+ const bytes = await client.downloadMedia(message);
197
+ if (!bytes) throw new Error(`Message ${attachment.id} carries no downloadable media`);
198
+ return new Uint8Array(bytes);
199
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,14 @@ export {
2
2
  TelegramMtprotoTransport,
3
3
  type TelegramMtprotoTransportOptions,
4
4
  } from "./telegram-mtproto-transport";
5
+ export {
6
+ collectAttachments,
7
+ downloadFromMessage,
8
+ IncomingDispatcher,
9
+ peerToChat,
10
+ toIncomingMessage,
11
+ type DownloadingClient,
12
+ type MtprotoMediaLike,
13
+ type MtprotoMessageLike,
14
+ type MtprotoPeerLike,
15
+ } from "./incoming";
@@ -1,6 +1,10 @@
1
1
  import type {
2
2
  ButtonPress,
3
3
  ButtonPressTransport,
4
+ IncomingAttachment,
5
+ IncomingMessage,
6
+ IncomingMessageTransport,
7
+ ReadReceiptTransport,
4
8
  EditableMessage,
5
9
  EditableMessageTransport,
6
10
  EditResult,
@@ -10,15 +14,25 @@ import type {
10
14
  SendResult,
11
15
  TelegramPeerStore,
12
16
  TransportStatus,
17
+ TypingTransport,
13
18
  } from "@imessaging/core";
14
19
  import { isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
20
+ import { peerToChat } from "./incoming";
15
21
  import bigInt from "big-integer";
16
22
  import { Api, TelegramClient } from "telegram";
17
23
  import { CustomFile } from "telegram/client/uploads";
18
24
  import { CallbackQuery } from "telegram/events/CallbackQuery";
25
+ import { NewMessage } from "telegram/events/NewMessage";
19
26
  import type { TelegramClientParams } from "telegram/client/telegramBaseClient";
20
27
  import { StringSession } from "telegram/sessions";
21
28
 
29
+ import {
30
+ downloadFromMessage,
31
+ IncomingDispatcher,
32
+ type DownloadingClient,
33
+ type MtprotoMessageLike,
34
+ } from "./incoming";
35
+
22
36
  export type TelegramMtprotoTransportOptions = {
23
37
  accountId: string;
24
38
  apiId: number;
@@ -57,12 +71,21 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
57
71
  return chat.className === "Channel";
58
72
  }
59
73
 
60
- export class TelegramMtprotoTransport implements EditableMessageTransport, ButtonPressTransport {
74
+ export class TelegramMtprotoTransport
75
+ implements
76
+ EditableMessageTransport,
77
+ ButtonPressTransport,
78
+ IncomingMessageTransport,
79
+ ReadReceiptTransport,
80
+ TypingTransport
81
+ {
61
82
  readonly id: string;
62
83
  private readonly client: TelegramClient;
63
84
  private readonly peerStore: TelegramPeerStore;
64
85
  private connected = false;
65
86
  private lastActivityAt?: Date;
87
+ private readonly incoming = new IncomingDispatcher();
88
+ private incomingListener: ((event: unknown) => void) | null = null;
66
89
 
67
90
  constructor(private readonly options: TelegramMtprotoTransportOptions) {
68
91
  if (!options.accountId.trim()) throw new Error("accountId must not be empty");
@@ -106,11 +129,13 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
106
129
  const listener = (event: unknown): void => {
107
130
  const query = event as {
108
131
  data?: Buffer | Uint8Array;
132
+ chatId?: { toString?: () => string };
109
133
  senderId?: { toJSNumber?: () => number };
110
134
  answer?: (options: { message: string; alert: boolean }) => Promise<unknown>;
111
135
  };
112
136
  const press: ButtonPress = {
113
137
  senderId: query.senderId?.toJSNumber?.() ?? null,
138
+ chatId: query.chatId?.toString?.(),
114
139
  data: query.data === undefined ? "" : Buffer.from(query.data).toString("utf8"),
115
140
  answer: async (text: string) => {
116
141
  await query.answer?.({ message: text, alert: true });
@@ -122,6 +147,100 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
122
147
  return () => this.client.removeEventHandler(listener, new CallbackQuery({}));
123
148
  }
124
149
 
150
+ /**
151
+ * Подписка на входящие.
152
+ *
153
+ * Опрос ЗАПУСКАЕТСЯ ПОДПИСКОЙ, а не соединением — то же правило, что у бота: учётная запись,
154
+ * которая только шлёт, не должна забирать себе обновления.
155
+ */
156
+ onMessage(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
157
+ const remove = this.incoming.add(handler);
158
+ this.startListeningIfNeeded();
159
+ return () => {
160
+ remove();
161
+ this.stopListeningIfIdle();
162
+ };
163
+ }
164
+
165
+ private startListeningIfNeeded(): void {
166
+ if (this.incomingListener !== null || this.incoming.size === 0) return;
167
+ const listener = (event: unknown): void => {
168
+ const message = (event as { message?: MtprotoMessageLike }).message;
169
+ if (!message) return;
170
+ this.lastActivityAt = new Date();
171
+ // Собеседника запоминаем СРАЗУ: ответить ему по одному номеру нельзя, `accessHash` приносит
172
+ // только это обновление, и к следующему ходу его уже негде взять.
173
+ void this.rememberSender(message);
174
+ void this.incoming.dispatch(message, this.id, this.options.accountId);
175
+ };
176
+ this.incomingListener = listener;
177
+ this.client.addEventHandler(listener, new NewMessage({}));
178
+ }
179
+
180
+ /**
181
+ * Запомнить написавшего нам человека.
182
+ *
183
+ * Отказ здесь ничего не ломает и не сообщается: приём сообщения важнее, чем возможность
184
+ * ответить по номеру, а без записи остаётся ответ по имени.
185
+ */
186
+ private async rememberSender(message: MtprotoMessageLike): Promise<void> {
187
+ try {
188
+ const hash = message.sender?.accessHash;
189
+ if (hash === undefined || hash === null) return;
190
+ const { chatId, chatType } = peerToChat(message.peerId);
191
+ // В группе отправитель и чат — разные пиры, и запись сложила бы одно вместо другого.
192
+ if (chatType !== "user") return;
193
+ const recipient = { type: "user", id: chatId } as const;
194
+ await this.peerStore.set(
195
+ this.options.accountId,
196
+ recipient,
197
+ {
198
+ type: "user",
199
+ id: chatId,
200
+ accessHash: String(hash),
201
+ username: message.sender?.username,
202
+ resolvedAt: new Date().toISOString(),
203
+ },
204
+ this.options.peerTtlSeconds,
205
+ );
206
+ } catch {
207
+ // приём важнее записи
208
+ }
209
+ }
210
+
211
+ private stopListeningIfIdle(): void {
212
+ if (this.incoming.size > 0 || this.incomingListener === null) return;
213
+ this.client.removeEventHandler(this.incomingListener, new NewMessage({}));
214
+ this.incomingListener = null;
215
+ }
216
+
217
+ /**
218
+ * Байты вложения.
219
+ *
220
+ * Идентификатор — пара «чат и сообщение»: у MTProto скачивание идёт от СООБЩЕНИЯ, а `file_id`,
221
+ * как в Bot API, здесь не существует вовсе.
222
+ */
223
+ async download(attachment: IncomingAttachment): Promise<Uint8Array> {
224
+ return downloadFromMessage(this.client as unknown as DownloadingClient, attachment);
225
+ }
226
+
227
+ /** Отметить прочитанным. Иначе у собеседника горит непрочитанное, хотя мы уже отвечаем. */
228
+ async markRead(recipient: MessageRecipient, messageId: string): Promise<void> {
229
+ const peer = await this.getInputPeer(recipient);
230
+ await this.client.markAsRead(peer, Number(messageId));
231
+ }
232
+
233
+ /** «Печатает…». Ход агента занимает секунды, и без признака работы человек пишет второй раз. */
234
+ async typing(recipient: MessageRecipient): Promise<void> {
235
+ if (!isTelegramRecipient(recipient)) {
236
+ throw new Error("Telegram account cannot show typing to an email recipient");
237
+ }
238
+ const peer = await this.getInputPeer(recipient);
239
+ await this.client.invoke(
240
+ new Api.messages.SetTyping({ peer, action: new Api.SendMessageTypingAction() }),
241
+ );
242
+ }
243
+
125
244
  async disconnect(): Promise<void> {
126
245
  await this.client.disconnect();
127
246
  this.connected = false;
@@ -190,6 +309,12 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
190
309
  const cached = await this.peerStore.get(this.options.accountId, recipient);
191
310
  if (cached) return this.toInputPeer(cached);
192
311
 
312
+ // Собеседник, который НАМ НАПИСАЛ, уже известен: обновление принесло его вместе с access hash,
313
+ // и клиент держит его у себя. Спрашиваем сначала клиента — иначе ответить на входящее нельзя
314
+ // без @username, а у человека его может не быть вовсе.
315
+ const known = await this.knownInputPeer(recipient);
316
+ if (known) return known;
317
+
193
318
  const resolved = await this.resolvePeer(recipient);
194
319
  await this.peerStore.set(
195
320
  this.options.accountId,
@@ -200,6 +325,21 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
200
325
  return this.toInputPeer(resolved);
201
326
  }
202
327
 
328
+ /**
329
+ * Собеседник, уже известный клиенту.
330
+ *
331
+ * `null` — не известен, и тогда остаётся разрешение по имени. Отказ здесь НЕ ошибка: он значит
332
+ * «мы этому человеку ещё не писали и он нам тоже», а не поломку.
333
+ */
334
+ private async knownInputPeer(recipient: MessageRecipient): Promise<Api.TypeInputPeer | null> {
335
+ if (recipient.type === "email" || recipient.type === "username") return null;
336
+ try {
337
+ return await this.client.getInputEntity(bigInt(recipient.id));
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
203
343
  private async resolvePeer(recipient: MessageRecipient): Promise<ResolvedPeer> {
204
344
  // Почтовый получатель сюда доходить не должен: разрешать адрес как @username бессмысленно, а
205
345
  // сообщение об отказе тогда врало бы про Telegram.