@imessaging/telegram-mtproto 0.7.1 → 0.7.3
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 +111 -10
- package/src/index.ts +4 -0
- package/src/telegram-mtproto-transport.ts +91 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imessaging/telegram-mtproto",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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.7.
|
|
34
|
+
"@imessaging/core": "^0.7.3",
|
|
35
35
|
"big-integer": "^1.6.52"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
package/src/incoming.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingAttachment, IncomingMessage } from "@imessaging/core";
|
|
1
|
+
import type { IncomingAttachment, IncomingLink, IncomingMessage } from "@imessaging/core";
|
|
2
2
|
|
|
3
3
|
/** Пир MTProto в той части, по которой определяется чат. Приходит из `Api.TypePeer`. */
|
|
4
4
|
export type MtprotoPeerLike = {
|
|
@@ -14,10 +14,26 @@ export type MtprotoMessageLike = {
|
|
|
14
14
|
message?: string;
|
|
15
15
|
/** `true` — сообщение отправлено САМОЙ учётной записью. */
|
|
16
16
|
out?: boolean;
|
|
17
|
+
date?: number;
|
|
18
|
+
editDate?: number;
|
|
19
|
+
groupedId?: { toString(): string };
|
|
17
20
|
peerId?: MtprotoPeerLike;
|
|
18
21
|
fromId?: MtprotoPeerLike;
|
|
19
22
|
media?: MtprotoMediaLike;
|
|
20
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Разметка текста: жирное, ссылки, упоминания. Отсюда берутся адреса, которых В ТЕКСТЕ НЕТ.
|
|
25
|
+
*
|
|
26
|
+
* Смещения Telegram считает в кодовых единицах UTF-16 — тех же, которыми режет строку JS, так
|
|
27
|
+
* что `slice` по ним точен. Считать в символах значило бы промахиваться на каждом эмодзи.
|
|
28
|
+
*/
|
|
29
|
+
entities?: MtprotoEntityLike[];
|
|
30
|
+
/** Кнопки под сообщением. У поста в канале здесь `ReplyInlineMarkup`, у остальных пусто. */
|
|
31
|
+
replyMarkup?: { rows?: { buttons?: MtprotoButtonLike[] }[] };
|
|
32
|
+
replyTo?: {
|
|
33
|
+
replyToMsgId?: number | string;
|
|
34
|
+
replyToTopId?: number | string;
|
|
35
|
+
forumTopic?: boolean;
|
|
36
|
+
};
|
|
21
37
|
/** Заголовок форварда. Пусто — сообщение своё, не пересланное. */
|
|
22
38
|
fwdFrom?: {
|
|
23
39
|
fromId?: MtprotoPeerLike;
|
|
@@ -30,7 +46,13 @@ export type MtprotoMessageLike = {
|
|
|
30
46
|
* Нужен не ради имени: по одному номеру ответить нельзя, пока собеседник клиенту неизвестен, а
|
|
31
47
|
* `accessHash` живёт только здесь. Поле необязательное: в обновлении его может не быть.
|
|
32
48
|
*/
|
|
33
|
-
sender?: {
|
|
49
|
+
sender?: {
|
|
50
|
+
username?: string;
|
|
51
|
+
firstName?: string;
|
|
52
|
+
lastName?: string;
|
|
53
|
+
title?: string;
|
|
54
|
+
accessHash?: { toString(): string } | string | number;
|
|
55
|
+
};
|
|
34
56
|
/**
|
|
35
57
|
* Пир отправителя ВМЕСТЕ с `accessHash` — так его отдаёт само обновление.
|
|
36
58
|
*
|
|
@@ -52,6 +74,23 @@ export type MtprotoMediaLike = {
|
|
|
52
74
|
attributes?: { className?: string; fileName?: string }[];
|
|
53
75
|
};
|
|
54
76
|
photo?: unknown;
|
|
77
|
+
/** Предпросмотр страницы. Вложением НЕ считается: скачивать нечего, адрес есть. */
|
|
78
|
+
webpage?: { className?: string; url?: string; displayUrl?: string; title?: string };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Сущность разметки. `url` есть только у скрытой гиперссылки (`MessageEntityTextUrl`). */
|
|
82
|
+
export type MtprotoEntityLike = {
|
|
83
|
+
className?: string;
|
|
84
|
+
offset?: number;
|
|
85
|
+
length?: number;
|
|
86
|
+
url?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Кнопка под сообщением. Адрес несёт только `KeyboardButtonUrl`, у остальных его нет. */
|
|
90
|
+
export type MtprotoButtonLike = {
|
|
91
|
+
className?: string;
|
|
92
|
+
text?: string;
|
|
93
|
+
url?: string;
|
|
55
94
|
};
|
|
56
95
|
|
|
57
96
|
/**
|
|
@@ -96,9 +135,7 @@ export function unmarkPeerId(chatId: string): string {
|
|
|
96
135
|
* Пир источника МОЖЕТ отсутствовать: автор форварда вправе скрыть его. Тогда остаётся имя, а
|
|
97
136
|
* иногда и оно пустое — и это честнее выдумывания, чем подстановка чата, в котором сообщение лежит.
|
|
98
137
|
*/
|
|
99
|
-
function toForwardedFrom(
|
|
100
|
-
fwdFrom: MtprotoMessageLike["fwdFrom"],
|
|
101
|
-
): IncomingMessage["forwardedFrom"] {
|
|
138
|
+
function toForwardedFrom(fwdFrom: MtprotoMessageLike["fwdFrom"]): IncomingMessage["forwardedFrom"] {
|
|
102
139
|
if (!fwdFrom) return undefined;
|
|
103
140
|
const peer = fwdFrom.fromId ? peerToChat(fwdFrom.fromId) : null;
|
|
104
141
|
return {
|
|
@@ -146,6 +183,47 @@ export function collectAttachments(
|
|
|
146
183
|
return [];
|
|
147
184
|
}
|
|
148
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Адреса из сообщения: текст, скрытые гиперссылки, кнопки, предпросмотр.
|
|
188
|
+
*
|
|
189
|
+
* Разбирается ЗДЕСЬ, а не у потребителя, по одной причине: половины этих адресов в тексте нет.
|
|
190
|
+
* Слово «откликнуться» со скрытой ссылкой, надпись на кнопке и предпросмотр живут в разметке, и
|
|
191
|
+
* поиск регулярным выражением по `text` не находит их ВООБЩЕ — а выглядит это не промахом
|
|
192
|
+
* разбора, а вакансией без ссылки. Замер 16.09.2026 по боевой базе italent: у всех 379 вакансий,
|
|
193
|
+
* приехавших шлюзом, поле адреса пустое, тогда как у 8190 прежних оно заполнено.
|
|
194
|
+
*
|
|
195
|
+
* Повторы снимаются по адресу: один и тот же URL часто и в тексте, и в предпросмотре, и
|
|
196
|
+
* потребитель, считающий их разными, показал бы человеку две одинаковые ссылки.
|
|
197
|
+
*/
|
|
198
|
+
export function collectLinks(message: MtprotoMessageLike): IncomingLink[] {
|
|
199
|
+
const text = message.message ?? "";
|
|
200
|
+
const links: IncomingLink[] = [];
|
|
201
|
+
const seen = new Set<string>();
|
|
202
|
+
const add = (url: string | undefined, origin: IncomingLink["origin"], label?: string): void => {
|
|
203
|
+
const trimmed = url?.trim();
|
|
204
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
205
|
+
seen.add(trimmed);
|
|
206
|
+
links.push(label ? { url: trimmed, label, origin } : { url: trimmed, origin });
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Порядок — как в сообщении: смещения приходят по возрастанию, но полагаться на это не надо.
|
|
210
|
+
const entities = [...(message.entities ?? [])].sort((a, b) => (a.offset ?? 0) - (b.offset ?? 0));
|
|
211
|
+
for (const entity of entities) {
|
|
212
|
+
const shown = text.slice(entity.offset ?? 0, (entity.offset ?? 0) + (entity.length ?? 0));
|
|
213
|
+
// Голый адрес: Telegram размечает его сам, и сам адрес — это и есть показанный текст.
|
|
214
|
+
if (entity.className === "MessageEntityUrl") add(shown, "text");
|
|
215
|
+
// Скрытая гиперссылка: адрес в поле, а показанное слово — подпись к нему.
|
|
216
|
+
if (entity.className === "MessageEntityTextUrl") add(entity.url, "markup", shown || undefined);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const row of message.replyMarkup?.rows ?? []) {
|
|
220
|
+
for (const button of row.buttons ?? []) add(button.url, "button", button.text);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
add(message.media?.webpage?.url, "preview", message.media?.webpage?.title);
|
|
224
|
+
return links;
|
|
225
|
+
}
|
|
226
|
+
|
|
149
227
|
/**
|
|
150
228
|
* Сообщение живой учётной записи → общий вид.
|
|
151
229
|
*
|
|
@@ -166,6 +244,26 @@ export function toIncomingMessage(
|
|
|
166
244
|
? chatId
|
|
167
245
|
: null;
|
|
168
246
|
|
|
247
|
+
let senderName: string | undefined;
|
|
248
|
+
if (message.sender) {
|
|
249
|
+
const parts = [message.sender.firstName, message.sender.lastName].filter(Boolean) as string[];
|
|
250
|
+
if (parts.length > 0) senderName = parts.join(" ").trim();
|
|
251
|
+
else if (message.sender.title) senderName = message.sender.title;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let threadId: number | undefined;
|
|
255
|
+
let replyToMessageId: string | undefined;
|
|
256
|
+
if (message.replyTo?.forumTopic === true) {
|
|
257
|
+
threadId = Number(message.replyTo.replyToTopId ?? message.replyTo.replyToMsgId);
|
|
258
|
+
replyToMessageId =
|
|
259
|
+
message.replyTo.replyToTopId === undefined ? undefined : String(message.replyTo.replyToMsgId);
|
|
260
|
+
} else {
|
|
261
|
+
replyToMessageId =
|
|
262
|
+
message.replyTo?.replyToMsgId === undefined
|
|
263
|
+
? undefined
|
|
264
|
+
: String(message.replyTo.replyToMsgId);
|
|
265
|
+
}
|
|
266
|
+
|
|
169
267
|
return {
|
|
170
268
|
transportId,
|
|
171
269
|
accountId,
|
|
@@ -175,13 +273,16 @@ export function toIncomingMessage(
|
|
|
175
273
|
// Имя отправителя: по нему отвечают, когда номер клиенту неизвестен, и по нему же продукты
|
|
176
274
|
// связывают человека со своей учётной записью.
|
|
177
275
|
senderUsername: message.sender?.username,
|
|
276
|
+
senderName,
|
|
178
277
|
messageId: String(message.id),
|
|
278
|
+
albumId: message.groupedId?.toString(),
|
|
179
279
|
text: message.message ?? "",
|
|
280
|
+
sentAt: message.date !== undefined ? new Date(message.date * 1000) : undefined,
|
|
281
|
+
editedAt: message.editDate !== undefined ? new Date(message.editDate * 1000) : undefined,
|
|
180
282
|
attachments: collectAttachments(message, chatId),
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
: String(message.replyTo.replyToMsgId),
|
|
283
|
+
links: collectLinks(message),
|
|
284
|
+
replyToMessageId,
|
|
285
|
+
threadId,
|
|
185
286
|
forwardedFrom: toForwardedFrom(message.fwdFrom),
|
|
186
287
|
};
|
|
187
288
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
export {
|
|
2
2
|
TelegramMtprotoTransport,
|
|
3
|
+
type TelegramChatSummary,
|
|
3
4
|
type TelegramMtprotoTransportOptions,
|
|
4
5
|
} from "./telegram-mtproto-transport";
|
|
5
6
|
export {
|
|
6
7
|
collectAttachments,
|
|
8
|
+
collectLinks,
|
|
7
9
|
downloadFromMessage,
|
|
8
10
|
IncomingDispatcher,
|
|
9
11
|
peerToChat,
|
|
10
12
|
unmarkPeerId,
|
|
11
13
|
toIncomingMessage,
|
|
12
14
|
type DownloadingClient,
|
|
15
|
+
type MtprotoButtonLike,
|
|
16
|
+
type MtprotoEntityLike,
|
|
13
17
|
type MtprotoMediaLike,
|
|
14
18
|
type MtprotoMessageLike,
|
|
15
19
|
type MtprotoPeerLike,
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ResolvedPeer,
|
|
14
14
|
SendResult,
|
|
15
15
|
TelegramPeerStore,
|
|
16
|
+
TelegramRecipient,
|
|
16
17
|
TransportStatus,
|
|
17
18
|
TypingTransport,
|
|
18
19
|
} from "@imessaging/core";
|
|
@@ -29,10 +30,20 @@ import { StringSession } from "telegram/sessions";
|
|
|
29
30
|
import {
|
|
30
31
|
downloadFromMessage,
|
|
31
32
|
IncomingDispatcher,
|
|
33
|
+
toIncomingMessage,
|
|
32
34
|
type DownloadingClient,
|
|
33
35
|
type MtprotoMessageLike,
|
|
36
|
+
type MtprotoPeerLike,
|
|
34
37
|
} from "./incoming";
|
|
35
38
|
|
|
39
|
+
export type TelegramChatSummary = {
|
|
40
|
+
chatId: string;
|
|
41
|
+
chatType: IncomingMessage["chatType"];
|
|
42
|
+
title: string;
|
|
43
|
+
username?: string;
|
|
44
|
+
unreadCount: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
36
47
|
export type TelegramMtprotoTransportOptions = {
|
|
37
48
|
accountId: string;
|
|
38
49
|
apiId: number;
|
|
@@ -348,6 +359,86 @@ export class TelegramMtprotoTransport
|
|
|
348
359
|
};
|
|
349
360
|
}
|
|
350
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Диалоги учётки; `query` — подстрока названия без учёта регистра.
|
|
364
|
+
* Пир каждого диалога запоминается: следующий `history` по chatId не обходит диалоги заново.
|
|
365
|
+
*/
|
|
366
|
+
async listChats(options?: { query?: string; limit?: number }): Promise<TelegramChatSummary[]> {
|
|
367
|
+
this.assertConnected();
|
|
368
|
+
const limit = options?.limit ?? 100;
|
|
369
|
+
const query = options?.query?.toLowerCase();
|
|
370
|
+
// С запросом обходим всё: совпадение может лежать в самом конце списка. Без него — ровно limit.
|
|
371
|
+
const dialogs = await this.client.getDialogs(query === undefined ? { limit } : {});
|
|
372
|
+
const summaries: TelegramChatSummary[] = [];
|
|
373
|
+
|
|
374
|
+
for (const dialog of dialogs) {
|
|
375
|
+
if (!dialog.entity) continue;
|
|
376
|
+
const title = dialog.title ?? "";
|
|
377
|
+
if (query !== undefined && !title.toLowerCase().includes(query)) continue;
|
|
378
|
+
|
|
379
|
+
// Супергруппа — тоже канал (isChannel и isGroup сразу), поэтому канал проверяется первым.
|
|
380
|
+
const entityId = dialog.entity.id.toString();
|
|
381
|
+
const peerLike: MtprotoPeerLike | undefined = dialog.isChannel
|
|
382
|
+
? { channelId: entityId }
|
|
383
|
+
: dialog.isGroup
|
|
384
|
+
? { chatId: entityId }
|
|
385
|
+
: dialog.isUser
|
|
386
|
+
? { userId: entityId }
|
|
387
|
+
: undefined;
|
|
388
|
+
if (!peerLike) continue;
|
|
389
|
+
const { chatId, chatType } = peerToChat(peerLike);
|
|
390
|
+
const entity = dialog.entity as { accessHash?: { toString(): string }; username?: string };
|
|
391
|
+
const accessHash = entity.accessHash?.toString();
|
|
392
|
+
const username = entity.username ?? undefined;
|
|
393
|
+
|
|
394
|
+
// У обычной группы access hash не бывает — ей хватает номера, и запомнить её тоже нужно.
|
|
395
|
+
if (accessHash || chatType === "group") {
|
|
396
|
+
await this.peerStore.set(
|
|
397
|
+
this.options.accountId,
|
|
398
|
+
{ type: chatType, id: chatId },
|
|
399
|
+
{
|
|
400
|
+
type: chatType,
|
|
401
|
+
id: chatId,
|
|
402
|
+
accessHash,
|
|
403
|
+
username,
|
|
404
|
+
resolvedAt: new Date().toISOString(),
|
|
405
|
+
},
|
|
406
|
+
this.options.peerTtlSeconds,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
summaries.push({ chatId, chatType, title, username, unreadCount: dialog.unreadCount });
|
|
411
|
+
if (summaries.length >= limit) break;
|
|
412
|
+
}
|
|
413
|
+
return summaries;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Последние `limit` сообщений чата, от старых к новым, ВКЛЮЧАЯ свои — это чтение истории, а не приём.
|
|
418
|
+
* Служебные события (вход в группу, закреп) пропускаются: текста и вложений у них нет.
|
|
419
|
+
*/
|
|
420
|
+
async history(
|
|
421
|
+
recipient: TelegramRecipient,
|
|
422
|
+
options?: { limit?: number; threadId?: number },
|
|
423
|
+
): Promise<IncomingMessage[]> {
|
|
424
|
+
this.assertConnected();
|
|
425
|
+
const peer = await this.getInputPeer(recipient);
|
|
426
|
+
const messages = await this.client.getMessages(peer, {
|
|
427
|
+
limit: options?.limit ?? 30,
|
|
428
|
+
replyTo: options?.threadId,
|
|
429
|
+
});
|
|
430
|
+
return [...messages]
|
|
431
|
+
.reverse()
|
|
432
|
+
.filter((message) => message.className === "Message")
|
|
433
|
+
.map((message) =>
|
|
434
|
+
toIncomingMessage(
|
|
435
|
+
message as unknown as MtprotoMessageLike,
|
|
436
|
+
this.id,
|
|
437
|
+
this.options.accountId,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
351
442
|
private async sendWithRetry(message: OutboundMessage, retried: boolean): Promise<SendResult> {
|
|
352
443
|
try {
|
|
353
444
|
const peer = await this.getInputPeer(message.recipient);
|