@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 +2 -2
- package/src/incoming.ts +32 -0
- package/src/telegram-mtproto-transport.ts +229 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imessaging/telegram-mtproto",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
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
|
|
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();
|