@imessaging/telegram-mtproto 0.5.0 → 0.6.0

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.0",
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,189 @@
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
+
23
+ export type MtprotoMediaLike = {
24
+ className?: string;
25
+ document?: {
26
+ mimeType?: string;
27
+ size?: { toString(): string } | number;
28
+ attributes?: { className?: string; fileName?: string }[];
29
+ };
30
+ photo?: unknown;
31
+ };
32
+
33
+ /**
34
+ * Пир → пара «чат и его вид».
35
+ *
36
+ * `chatId` берётся из пира, а НЕ из отправителя: в группе они разные, и перепутав их, ответ уехал
37
+ * бы в личку тому, кто написал в общий чат.
38
+ */
39
+ export function peerToChat(peer: MtprotoPeerLike | undefined): {
40
+ chatId: string;
41
+ chatType: IncomingMessage["chatType"];
42
+ } {
43
+ if (peer?.channelId !== undefined)
44
+ return { chatId: peer.channelId.toString(), chatType: "channel" };
45
+ if (peer?.chatId !== undefined) return { chatId: peer.chatId.toString(), chatType: "group" };
46
+ if (peer?.userId !== undefined) return { chatId: peer.userId.toString(), chatType: "user" };
47
+ throw new Error("MTProto peer without userId, chatId or channelId");
48
+ }
49
+
50
+ /**
51
+ * Что приехало файлом.
52
+ *
53
+ * Имя ищется среди атрибутов документа: у MTProto оно не поле, а один из атрибутов, и его может не
54
+ * быть вовсе. Разбор форматов ведётся по имени, поэтому подставляется своё.
55
+ *
56
+ * Идентификатор вложения — пара «чат и сообщение», а не идентификатор файла: у MTProto скачивание
57
+ * идёт от СООБЩЕНИЯ, и file_id, как в Bot API, здесь просто не существует.
58
+ */
59
+ export function collectAttachments(
60
+ message: MtprotoMessageLike,
61
+ chatId: string,
62
+ ): IncomingAttachment[] {
63
+ const media = message.media;
64
+ if (!media) return [];
65
+ const id = `${chatId}:${message.id}`;
66
+
67
+ if (media.document) {
68
+ const named = media.document.attributes?.find(
69
+ (attribute) => attribute.className === "DocumentAttributeFilename",
70
+ );
71
+ const size = media.document.size;
72
+ return [
73
+ {
74
+ id,
75
+ filename: named?.fileName ?? "document",
76
+ mimeType: media.document.mimeType,
77
+ sizeBytes: size === undefined ? undefined : Number(size.toString()),
78
+ },
79
+ ];
80
+ }
81
+ if (media.photo) {
82
+ return [{ id, filename: "photo.jpg", mimeType: "image/jpeg" }];
83
+ }
84
+ return [];
85
+ }
86
+
87
+ /**
88
+ * Сообщение живой учётной записи → общий вид.
89
+ *
90
+ * Вынесено отдельной функцией по той же причине, что и у бота: это ЕДИНСТВЕННОЕ место, где теряются
91
+ * поля, и потеря не падает — она видна только тем, что агент не знает, на что ему отвечают.
92
+ */
93
+ export function toIncomingMessage(
94
+ message: MtprotoMessageLike,
95
+ transportId: string,
96
+ accountId: string,
97
+ ): IncomingMessage {
98
+ const { chatId, chatType } = peerToChat(message.peerId);
99
+ // Отправителя в личке MTProto не называет: `fromId` заполняется в группах, а в диалоге один на
100
+ // один он и есть собеседник, то есть сам чат.
101
+ const senderId = message.fromId
102
+ ? peerToChat(message.fromId).chatId
103
+ : chatType === "user"
104
+ ? chatId
105
+ : null;
106
+
107
+ return {
108
+ transportId,
109
+ accountId,
110
+ chatId,
111
+ chatType,
112
+ senderId,
113
+ messageId: String(message.id),
114
+ text: message.message ?? "",
115
+ attachments: collectAttachments(message, chatId),
116
+ replyToMessageId:
117
+ message.replyTo?.replyToMsgId === undefined
118
+ ? undefined
119
+ : String(message.replyTo.replyToMsgId),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Набор подписчиков на входящие и раздача им сообщения.
125
+ *
126
+ * Отдельным классом, а не полем транспорта, потому что это единственная часть приёма, которую можно
127
+ * проверить без сети: правило «своё сообщение не отдаём» живёт здесь, и оно важнее остального —
128
+ * живая учётная запись видит и то, что отправила сама, и без него шлюз отвечает на свой же ответ.
129
+ */
130
+ export class IncomingDispatcher {
131
+ private readonly handlers = new Set<(message: IncomingMessage) => void | Promise<void>>();
132
+
133
+ add(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
134
+ this.handlers.add(handler);
135
+ return () => {
136
+ this.handlers.delete(handler);
137
+ };
138
+ }
139
+
140
+ get size(): number {
141
+ return this.handlers.size;
142
+ }
143
+
144
+ /** Раздаёт входящее подписчикам. Своё исходящее не раздаётся никому. */
145
+ async dispatch(
146
+ message: MtprotoMessageLike,
147
+ transportId: string,
148
+ accountId: string,
149
+ ): Promise<boolean> {
150
+ if (message.out === true) return false;
151
+ if (this.handlers.size === 0) return false;
152
+ const incoming = toIncomingMessage(message, transportId, accountId);
153
+ for (const handler of this.handlers) await handler(incoming);
154
+ return true;
155
+ }
156
+ }
157
+
158
+ /** Клиент MTProto в той части, которой достаточно для скачивания. Объявлен ради двойника в тесте. */
159
+ export type DownloadingClient = {
160
+ getMessages(peer: string, params: { ids: number[] }): Promise<{ media?: unknown }[]>;
161
+ downloadMedia(message: unknown): Promise<Buffer | Uint8Array | undefined>;
162
+ };
163
+
164
+ /**
165
+ * Скачивание вложения по паре «чат и сообщение».
166
+ *
167
+ * Сообщение перезапрашивается, а не хранится: держать его до скачивания значило бы держать в
168
+ * памяти каждое входящее с файлом на случай, что файл однажды спросят.
169
+ */
170
+ export async function downloadFromMessage(
171
+ client: DownloadingClient,
172
+ attachment: { id: string },
173
+ ): Promise<Uint8Array> {
174
+ const separator = attachment.id.lastIndexOf(":");
175
+ if (separator <= 0) {
176
+ throw new Error(`Attachment id ${attachment.id} is not a "<chat>:<message>" pair`);
177
+ }
178
+ const chatId = attachment.id.slice(0, separator);
179
+ const messageId = Number(attachment.id.slice(separator + 1));
180
+ if (!Number.isInteger(messageId)) {
181
+ throw new Error(`Attachment id ${attachment.id} carries a non-numeric message id`);
182
+ }
183
+
184
+ const [message] = await client.getMessages(chatId, { ids: [messageId] });
185
+ if (!message) throw new Error(`Message ${attachment.id} is gone`);
186
+ const bytes = await client.downloadMedia(message);
187
+ if (!bytes) throw new Error(`Message ${attachment.id} carries no downloadable media`);
188
+ return new Uint8Array(bytes);
189
+ }
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,24 @@ 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";
15
20
  import bigInt from "big-integer";
16
21
  import { Api, TelegramClient } from "telegram";
17
22
  import { CustomFile } from "telegram/client/uploads";
18
23
  import { CallbackQuery } from "telegram/events/CallbackQuery";
24
+ import { NewMessage } from "telegram/events/NewMessage";
19
25
  import type { TelegramClientParams } from "telegram/client/telegramBaseClient";
20
26
  import { StringSession } from "telegram/sessions";
21
27
 
28
+ import {
29
+ downloadFromMessage,
30
+ IncomingDispatcher,
31
+ type DownloadingClient,
32
+ type MtprotoMessageLike,
33
+ } from "./incoming";
34
+
22
35
  export type TelegramMtprotoTransportOptions = {
23
36
  accountId: string;
24
37
  apiId: number;
@@ -57,12 +70,21 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
57
70
  return chat.className === "Channel";
58
71
  }
59
72
 
60
- export class TelegramMtprotoTransport implements EditableMessageTransport, ButtonPressTransport {
73
+ export class TelegramMtprotoTransport
74
+ implements
75
+ EditableMessageTransport,
76
+ ButtonPressTransport,
77
+ IncomingMessageTransport,
78
+ ReadReceiptTransport,
79
+ TypingTransport
80
+ {
61
81
  readonly id: string;
62
82
  private readonly client: TelegramClient;
63
83
  private readonly peerStore: TelegramPeerStore;
64
84
  private connected = false;
65
85
  private lastActivityAt?: Date;
86
+ private readonly incoming = new IncomingDispatcher();
87
+ private incomingListener: ((event: unknown) => void) | null = null;
66
88
 
67
89
  constructor(private readonly options: TelegramMtprotoTransportOptions) {
68
90
  if (!options.accountId.trim()) throw new Error("accountId must not be empty");
@@ -106,11 +128,13 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
106
128
  const listener = (event: unknown): void => {
107
129
  const query = event as {
108
130
  data?: Buffer | Uint8Array;
131
+ chatId?: { toString?: () => string };
109
132
  senderId?: { toJSNumber?: () => number };
110
133
  answer?: (options: { message: string; alert: boolean }) => Promise<unknown>;
111
134
  };
112
135
  const press: ButtonPress = {
113
136
  senderId: query.senderId?.toJSNumber?.() ?? null,
137
+ chatId: query.chatId?.toString?.(),
114
138
  data: query.data === undefined ? "" : Buffer.from(query.data).toString("utf8"),
115
139
  answer: async (text: string) => {
116
140
  await query.answer?.({ message: text, alert: true });
@@ -122,6 +146,66 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
122
146
  return () => this.client.removeEventHandler(listener, new CallbackQuery({}));
123
147
  }
124
148
 
149
+ /**
150
+ * Подписка на входящие.
151
+ *
152
+ * Опрос ЗАПУСКАЕТСЯ ПОДПИСКОЙ, а не соединением — то же правило, что у бота: учётная запись,
153
+ * которая только шлёт, не должна забирать себе обновления.
154
+ */
155
+ onMessage(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
156
+ const remove = this.incoming.add(handler);
157
+ this.startListeningIfNeeded();
158
+ return () => {
159
+ remove();
160
+ this.stopListeningIfIdle();
161
+ };
162
+ }
163
+
164
+ private startListeningIfNeeded(): void {
165
+ if (this.incomingListener !== null || this.incoming.size === 0) return;
166
+ const listener = (event: unknown): void => {
167
+ const message = (event as { message?: MtprotoMessageLike }).message;
168
+ if (!message) return;
169
+ this.lastActivityAt = new Date();
170
+ void this.incoming.dispatch(message, this.id, this.options.accountId);
171
+ };
172
+ this.incomingListener = listener;
173
+ this.client.addEventHandler(listener, new NewMessage({}));
174
+ }
175
+
176
+ private stopListeningIfIdle(): void {
177
+ if (this.incoming.size > 0 || this.incomingListener === null) return;
178
+ this.client.removeEventHandler(this.incomingListener, new NewMessage({}));
179
+ this.incomingListener = null;
180
+ }
181
+
182
+ /**
183
+ * Байты вложения.
184
+ *
185
+ * Идентификатор — пара «чат и сообщение»: у MTProto скачивание идёт от СООБЩЕНИЯ, а `file_id`,
186
+ * как в Bot API, здесь не существует вовсе.
187
+ */
188
+ async download(attachment: IncomingAttachment): Promise<Uint8Array> {
189
+ return downloadFromMessage(this.client as unknown as DownloadingClient, attachment);
190
+ }
191
+
192
+ /** Отметить прочитанным. Иначе у собеседника горит непрочитанное, хотя мы уже отвечаем. */
193
+ async markRead(recipient: MessageRecipient, messageId: string): Promise<void> {
194
+ const peer = await this.getInputPeer(recipient);
195
+ await this.client.markAsRead(peer, Number(messageId));
196
+ }
197
+
198
+ /** «Печатает…». Ход агента занимает секунды, и без признака работы человек пишет второй раз. */
199
+ async typing(recipient: MessageRecipient): Promise<void> {
200
+ if (!isTelegramRecipient(recipient)) {
201
+ throw new Error("Telegram account cannot show typing to an email recipient");
202
+ }
203
+ const peer = await this.getInputPeer(recipient);
204
+ await this.client.invoke(
205
+ new Api.messages.SetTyping({ peer, action: new Api.SendMessageTypingAction() }),
206
+ );
207
+ }
208
+
125
209
  async disconnect(): Promise<void> {
126
210
  await this.client.disconnect();
127
211
  this.connected = false;
@@ -190,6 +274,12 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
190
274
  const cached = await this.peerStore.get(this.options.accountId, recipient);
191
275
  if (cached) return this.toInputPeer(cached);
192
276
 
277
+ // Собеседник, который НАМ НАПИСАЛ, уже известен: обновление принесло его вместе с access hash,
278
+ // и клиент держит его у себя. Спрашиваем сначала клиента — иначе ответить на входящее нельзя
279
+ // без @username, а у человека его может не быть вовсе.
280
+ const known = await this.knownInputPeer(recipient);
281
+ if (known) return known;
282
+
193
283
  const resolved = await this.resolvePeer(recipient);
194
284
  await this.peerStore.set(
195
285
  this.options.accountId,
@@ -200,6 +290,21 @@ export class TelegramMtprotoTransport implements EditableMessageTransport, Butto
200
290
  return this.toInputPeer(resolved);
201
291
  }
202
292
 
293
+ /**
294
+ * Собеседник, уже известный клиенту.
295
+ *
296
+ * `null` — не известен, и тогда остаётся разрешение по имени. Отказ здесь НЕ ошибка: он значит
297
+ * «мы этому человеку ещё не писали и он нам тоже», а не поломку.
298
+ */
299
+ private async knownInputPeer(recipient: MessageRecipient): Promise<Api.TypeInputPeer | null> {
300
+ if (recipient.type === "email" || recipient.type === "username") return null;
301
+ try {
302
+ return await this.client.getInputEntity(bigInt(recipient.id));
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
203
308
  private async resolvePeer(recipient: MessageRecipient): Promise<ResolvedPeer> {
204
309
  // Почтовый получатель сюда доходить не должен: разрешать адрес как @username бессмысленно, а
205
310
  // сообщение об отказе тогда врало бы про Telegram.