@imessaging/telegram-mtproto 0.8.2 → 0.8.4
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/entities.ts +4 -0
- package/src/incoming.ts +70 -1
- package/src/index.ts +1 -0
- package/src/telegram-mtproto-transport.ts +175 -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.4",
|
|
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.4",
|
|
35
35
|
"big-integer": "^1.6.52"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
package/src/entities.ts
CHANGED
|
@@ -22,6 +22,10 @@ export function toTelegramEntities(ft: FormattedText): Api.TypeMessageEntity[] {
|
|
|
22
22
|
return new Api.MessageEntityPre({ ...base, language: e.language ?? "" });
|
|
23
23
|
case "link":
|
|
24
24
|
return new Api.MessageEntityTextUrl({ ...base, url: e.url ?? "" });
|
|
25
|
+
case "spoiler":
|
|
26
|
+
return new Api.MessageEntitySpoiler(base);
|
|
27
|
+
case "quote":
|
|
28
|
+
return new Api.MessageEntityBlockquote(base);
|
|
25
29
|
}
|
|
26
30
|
});
|
|
27
31
|
}
|
package/src/incoming.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
IncomingAttachment,
|
|
3
|
+
IncomingLink,
|
|
4
|
+
IncomingMessage,
|
|
5
|
+
TextEntity,
|
|
6
|
+
TextEntityType,
|
|
7
|
+
} from "@imessaging/core";
|
|
2
8
|
|
|
3
9
|
/** Пир MTProto в той части, по которой определяется чат. Приходит из `Api.TypePeer`. */
|
|
4
10
|
export type MtprotoPeerLike = {
|
|
@@ -93,6 +99,7 @@ export type MtprotoEntityLike = {
|
|
|
93
99
|
offset?: number;
|
|
94
100
|
length?: number;
|
|
95
101
|
url?: string;
|
|
102
|
+
language?: string;
|
|
96
103
|
};
|
|
97
104
|
|
|
98
105
|
/** Кнопка под сообщением. Адрес несёт только `KeyboardButtonUrl`, у остальных его нет. */
|
|
@@ -233,6 +240,35 @@ export function collectLinks(message: MtprotoMessageLike): IncomingLink[] {
|
|
|
233
240
|
return links;
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
const FORMATTING: Record<string, TextEntityType> = {
|
|
244
|
+
MessageEntityBold: "bold",
|
|
245
|
+
MessageEntityItalic: "italic",
|
|
246
|
+
MessageEntityUnderline: "underline",
|
|
247
|
+
MessageEntityStrike: "strike",
|
|
248
|
+
MessageEntitySpoiler: "spoiler",
|
|
249
|
+
MessageEntityCode: "code",
|
|
250
|
+
MessageEntityPre: "pre",
|
|
251
|
+
MessageEntityTextUrl: "link",
|
|
252
|
+
MessageEntityBlockquote: "quote",
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export function formattingEntities(message: MtprotoMessageLike): TextEntity[] {
|
|
256
|
+
const result: TextEntity[] = [];
|
|
257
|
+
for (const entity of message.entities ?? []) {
|
|
258
|
+
// Упоминания, хэштеги, голые адреса — не форматирование: пары в общей модели у них нет.
|
|
259
|
+
const type = FORMATTING[entity.className ?? ""];
|
|
260
|
+
if (!type || entity.offset === undefined || entity.length === undefined) continue;
|
|
261
|
+
result.push({
|
|
262
|
+
type,
|
|
263
|
+
offset: entity.offset,
|
|
264
|
+
length: entity.length,
|
|
265
|
+
...(type === "link" && entity.url ? { url: entity.url } : {}),
|
|
266
|
+
...(type === "pre" && entity.language ? { language: entity.language } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
236
272
|
/**
|
|
237
273
|
* Сообщение живой учётной записи → общий вид.
|
|
238
274
|
*
|
|
@@ -297,6 +333,7 @@ export function toIncomingMessage(
|
|
|
297
333
|
sentAt: message.date ? new Date(message.date * 1000) : undefined,
|
|
298
334
|
// Непоправленное сообщение приходит с `editDate: null` — это не правка в 1970 году.
|
|
299
335
|
editedAt: message.editDate ? new Date(message.editDate * 1000) : undefined,
|
|
336
|
+
entities: formattingEntities(message),
|
|
300
337
|
attachments: collectAttachments(message, chatId),
|
|
301
338
|
links: collectLinks(message),
|
|
302
339
|
mentionsMe: typeof message.mentioned === "boolean" ? message.mentioned : undefined,
|
|
@@ -341,6 +378,38 @@ export class IncomingDispatcher {
|
|
|
341
378
|
}
|
|
342
379
|
}
|
|
343
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
|
+
|
|
344
413
|
/** Клиент MTProto в той части, которой достаточно для скачивания. Объявлен ради двойника в тесте. */
|
|
345
414
|
export type DownloadingClient = {
|
|
346
415
|
getMessages(peer: string, params: { ids: number[] }): Promise<{ media?: unknown }[]>;
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
DraftTransport,
|
|
3
|
+
MessageDraft,
|
|
4
|
+
OutgoingObserverTransport,
|
|
2
5
|
ButtonPress,
|
|
3
6
|
ButtonPressTransport,
|
|
4
7
|
IncomingAttachment,
|
|
@@ -7,6 +10,7 @@ import type {
|
|
|
7
10
|
ReadReceiptTransport,
|
|
8
11
|
EditableMessage,
|
|
9
12
|
EditableMessageTransport,
|
|
13
|
+
EditedMessageTransport,
|
|
10
14
|
EditResult,
|
|
11
15
|
MessageReaction,
|
|
12
16
|
MessageRecipient,
|
|
@@ -19,7 +23,7 @@ import type {
|
|
|
19
23
|
TransportStatus,
|
|
20
24
|
TypingTransport,
|
|
21
25
|
} from "@imessaging/core";
|
|
22
|
-
import { isTelegramRecipient, MemoryPeerStore
|
|
26
|
+
import { formatText, formattedOf, isTelegramRecipient, MemoryPeerStore } from "@imessaging/core";
|
|
23
27
|
import { toTelegramEntities } from "./entities";
|
|
24
28
|
import { peerToChat, unmarkPeerId } from "./incoming";
|
|
25
29
|
import bigInt from "big-integer";
|
|
@@ -27,12 +31,14 @@ import { Api, TelegramClient } from "telegram";
|
|
|
27
31
|
import { CustomFile } from "telegram/client/uploads";
|
|
28
32
|
import { CallbackQuery } from "telegram/events/CallbackQuery";
|
|
29
33
|
import { NewMessage } from "telegram/events/NewMessage";
|
|
34
|
+
import { EditedMessage } from "telegram/events/EditedMessage";
|
|
30
35
|
import type { TelegramClientParams } from "telegram/client/telegramBaseClient";
|
|
31
36
|
import { StringSession } from "telegram/sessions";
|
|
32
37
|
|
|
33
38
|
import {
|
|
34
39
|
downloadFromMessage,
|
|
35
40
|
IncomingDispatcher,
|
|
41
|
+
OutgoingDispatcher,
|
|
36
42
|
toIncomingMessage,
|
|
37
43
|
type DownloadingClient,
|
|
38
44
|
type MtprotoMessageLike,
|
|
@@ -86,11 +92,24 @@ function isChannel(chat: Api.TypeChat): chat is Api.Channel {
|
|
|
86
92
|
return chat.className === "Channel";
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
const OWN_MEMORY_MS = 10 * 60 * 1000;
|
|
96
|
+
|
|
97
|
+
/** Номер чата входного пира в том же маркированном виде, что chatId во входящем (peerToChat). */
|
|
98
|
+
function inputPeerChatId(peer: Api.TypeInputPeer): string {
|
|
99
|
+
if (peer instanceof Api.InputPeerChannel) return peerToChat({ channelId: peer.channelId }).chatId;
|
|
100
|
+
if (peer instanceof Api.InputPeerChat) return peerToChat({ chatId: peer.chatId }).chatId;
|
|
101
|
+
if (peer instanceof Api.InputPeerUser) return peerToChat({ userId: peer.userId }).chatId;
|
|
102
|
+
throw new Error(`Unsupported input peer: ${peer.className}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
export class TelegramMtprotoTransport
|
|
90
106
|
implements
|
|
107
|
+
DraftTransport,
|
|
91
108
|
EditableMessageTransport,
|
|
109
|
+
EditedMessageTransport,
|
|
92
110
|
ButtonPressTransport,
|
|
93
111
|
IncomingMessageTransport,
|
|
112
|
+
OutgoingObserverTransport,
|
|
94
113
|
ReactionTransport,
|
|
95
114
|
ReadReceiptTransport,
|
|
96
115
|
TypingTransport
|
|
@@ -102,6 +121,20 @@ export class TelegramMtprotoTransport
|
|
|
102
121
|
private lastActivityAt?: Date;
|
|
103
122
|
private readonly incoming = new IncomingDispatcher();
|
|
104
123
|
private incomingListener: ((event: unknown) => void) | null = null;
|
|
124
|
+
private readonly edits = new IncomingDispatcher();
|
|
125
|
+
private editListener: ((event: unknown) => void) | null = null;
|
|
126
|
+
private readonly outgoing = new OutgoingDispatcher();
|
|
127
|
+
private outgoingListener: ((event: unknown) => void) | null = null;
|
|
128
|
+
private readonly outgoingEdits = new OutgoingDispatcher();
|
|
129
|
+
private outgoingEditListener: ((event: unknown) => void) | null = null;
|
|
130
|
+
/**
|
|
131
|
+
* Что отправил и поправил сам транспорт: «чат:сообщение» → когда. Своё приходит тем же обновлением
|
|
132
|
+
* out=true, что и отправленное человеком с телефона, и без этой памяти их не различить.
|
|
133
|
+
*/
|
|
134
|
+
private readonly ownSent = new Map<string, number>();
|
|
135
|
+
private readonly ownEdited = new Map<string, number>();
|
|
136
|
+
/** Идущие send/edit: обновление о своём сообщении может прийти раньше, чем вызов вернёт его номер. */
|
|
137
|
+
private readonly ownPending = new Set<Promise<unknown>>();
|
|
105
138
|
/** Отметка жизни подписки: срабатывает на любом обновлении, включая служебные. */
|
|
106
139
|
private activityListener: ((event: unknown) => void) | null = null;
|
|
107
140
|
|
|
@@ -187,6 +220,140 @@ export class TelegramMtprotoTransport
|
|
|
187
220
|
};
|
|
188
221
|
}
|
|
189
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Черновик в поле ввода чата — ответ готовит машина, отправляет человек. Разметка — тем же рендером, что у
|
|
225
|
+
* send (`format`); без него текст уходит как есть. Пустой текст очищает черновик.
|
|
226
|
+
*/
|
|
227
|
+
async saveDraft(draft: MessageDraft): Promise<void> {
|
|
228
|
+
this.assertConnected();
|
|
229
|
+
if (!isTelegramRecipient(draft.recipient)) {
|
|
230
|
+
throw new Error(`Telegram MTProto cannot save a draft for ${draft.recipient.type}`);
|
|
231
|
+
}
|
|
232
|
+
const replyTo =
|
|
233
|
+
draft.replyToMessageId === undefined ? undefined : Number(draft.replyToMessageId);
|
|
234
|
+
if (replyTo !== undefined && (!Number.isSafeInteger(replyTo) || replyTo <= 0)) {
|
|
235
|
+
throw new Error(`Telegram message id must be a positive integer: ${draft.replyToMessageId}`);
|
|
236
|
+
}
|
|
237
|
+
const ft = draft.format ? formatText(draft.text, draft.format) : undefined;
|
|
238
|
+
const save = async (peer: Api.TypeInputPeer) =>
|
|
239
|
+
this.client.invoke(
|
|
240
|
+
new Api.messages.SaveDraft({
|
|
241
|
+
peer,
|
|
242
|
+
message: ft ? ft.text : draft.text,
|
|
243
|
+
...(ft && ft.entities.length > 0 ? { entities: toTelegramEntities(ft) } : {}),
|
|
244
|
+
...(replyTo ? { replyTo: new Api.InputReplyToMessage({ replyToMsgId: replyTo }) } : {}),
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
try {
|
|
248
|
+
await save(await this.getInputPeer(draft.recipient));
|
|
249
|
+
} catch (error: unknown) {
|
|
250
|
+
// Как у send: устаревший пир из хранилища — забыть и разрешить заново, один раз.
|
|
251
|
+
if (!this.isInvalidPeerError(error)) throw error;
|
|
252
|
+
await this.peerStore.delete(this.options.accountId, draft.recipient);
|
|
253
|
+
await save(await this.getInputPeer(draft.recipient));
|
|
254
|
+
}
|
|
255
|
+
this.lastActivityAt = new Date();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Свои новые исходящие — отправленное человеком с другого устройства; отправленное через send помечено sentByTransport. */
|
|
259
|
+
onOutgoing(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
|
|
260
|
+
const remove = this.outgoing.add(handler);
|
|
261
|
+
if (this.outgoingListener === null) {
|
|
262
|
+
this.outgoingListener = (event: unknown): void => {
|
|
263
|
+
const message = (event as { message?: MtprotoMessageLike }).message;
|
|
264
|
+
if (!message) return;
|
|
265
|
+
this.lastActivityAt = new Date();
|
|
266
|
+
void this.dispatchOwn(this.outgoing, this.ownSent, message);
|
|
267
|
+
};
|
|
268
|
+
this.client.addEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
|
|
269
|
+
}
|
|
270
|
+
return () => {
|
|
271
|
+
remove();
|
|
272
|
+
if (this.outgoing.size > 0 || this.outgoingListener === null) return;
|
|
273
|
+
this.client.removeEventHandler(this.outgoingListener, new NewMessage({ outgoing: true }));
|
|
274
|
+
this.outgoingListener = null;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Правки своих сообщений; поправленное через edit помечено sentByTransport. */
|
|
279
|
+
onOutgoingEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
|
|
280
|
+
const remove = this.outgoingEdits.add(handler);
|
|
281
|
+
if (this.outgoingEditListener === null) {
|
|
282
|
+
this.outgoingEditListener = (event: unknown): void => {
|
|
283
|
+
const message = (event as { message?: MtprotoMessageLike }).message;
|
|
284
|
+
if (!message) return;
|
|
285
|
+
this.lastActivityAt = new Date();
|
|
286
|
+
void this.dispatchOwn(this.outgoingEdits, this.ownEdited, message);
|
|
287
|
+
};
|
|
288
|
+
this.client.addEventHandler(this.outgoingEditListener, new EditedMessage({ outgoing: true }));
|
|
289
|
+
}
|
|
290
|
+
return () => {
|
|
291
|
+
remove();
|
|
292
|
+
if (this.outgoingEdits.size > 0 || this.outgoingEditListener === null) return;
|
|
293
|
+
this.client.removeEventHandler(
|
|
294
|
+
this.outgoingEditListener,
|
|
295
|
+
new EditedMessage({ outgoing: true }),
|
|
296
|
+
);
|
|
297
|
+
this.outgoingEditListener = null;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async dispatchOwn(
|
|
302
|
+
dispatcher: OutgoingDispatcher,
|
|
303
|
+
own: Map<string, number>,
|
|
304
|
+
message: MtprotoMessageLike,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
if (message.out !== true || !message.peerId) return;
|
|
307
|
+
// Своё ли это, известно только после того, как идущие send/edit вернули номера.
|
|
308
|
+
await Promise.allSettled(this.ownPending);
|
|
309
|
+
const key = `${peerToChat(message.peerId).chatId}:${message.id}`;
|
|
310
|
+
await dispatcher.dispatch(message, this.id, this.options.accountId, own.has(key));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private trackOwn<T>(work: Promise<T>): Promise<T> {
|
|
314
|
+
this.ownPending.add(work);
|
|
315
|
+
void work.then(
|
|
316
|
+
() => this.ownPending.delete(work),
|
|
317
|
+
() => this.ownPending.delete(work),
|
|
318
|
+
);
|
|
319
|
+
return work;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Номер своего сообщения; память на 10 минут — обновление приходит за секунды, дольше держать незачем. */
|
|
323
|
+
private rememberOwn(own: Map<string, number>, peer: Api.TypeInputPeer, messageId: number): void {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
for (const [key, at] of own) if (now - at > OWN_MEMORY_MS) own.delete(key);
|
|
326
|
+
own.set(`${inputPeerChatId(peer)}:${messageId}`, now);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
onEdit(handler: (message: IncomingMessage) => void | Promise<void>): () => void {
|
|
330
|
+
const remove = this.edits.add(handler);
|
|
331
|
+
this.startEditListeningIfNeeded();
|
|
332
|
+
return () => {
|
|
333
|
+
remove();
|
|
334
|
+
this.stopEditListeningIfIdle();
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private startEditListeningIfNeeded(): void {
|
|
339
|
+
if (this.editListener !== null || this.edits.size === 0) return;
|
|
340
|
+
const listener = (event: unknown): void => {
|
|
341
|
+
const message = (event as { message?: MtprotoMessageLike }).message;
|
|
342
|
+
if (!message) return;
|
|
343
|
+
this.lastActivityAt = new Date();
|
|
344
|
+
void this.rememberSender(message);
|
|
345
|
+
void this.edits.dispatch(message, this.id, this.options.accountId);
|
|
346
|
+
};
|
|
347
|
+
this.editListener = listener;
|
|
348
|
+
this.client.addEventHandler(listener, new EditedMessage({}));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private stopEditListeningIfIdle(): void {
|
|
352
|
+
if (this.edits.size > 0 || this.editListener === null) return;
|
|
353
|
+
this.client.removeEventHandler(this.editListener, new EditedMessage({}));
|
|
354
|
+
this.editListener = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
190
357
|
private startListeningIfNeeded(): void {
|
|
191
358
|
if (this.incomingListener !== null || this.incoming.size === 0) return;
|
|
192
359
|
const listener = (event: unknown): void => {
|
|
@@ -359,7 +526,7 @@ export class TelegramMtprotoTransport
|
|
|
359
526
|
|
|
360
527
|
async send(message: OutboundMessage): Promise<SendResult> {
|
|
361
528
|
this.assertConnected();
|
|
362
|
-
return this.sendWithRetry(message, false);
|
|
529
|
+
return this.trackOwn(this.sendWithRetry(message, false));
|
|
363
530
|
}
|
|
364
531
|
|
|
365
532
|
/**
|
|
@@ -375,7 +542,7 @@ export class TelegramMtprotoTransport
|
|
|
375
542
|
*/
|
|
376
543
|
async edit(message: EditableMessage): Promise<EditResult> {
|
|
377
544
|
this.assertConnected();
|
|
378
|
-
return this.editWithRetry(message, false);
|
|
545
|
+
return this.trackOwn(this.editWithRetry(message, false));
|
|
379
546
|
}
|
|
380
547
|
|
|
381
548
|
async getStatus(): Promise<TransportStatus> {
|
|
@@ -515,13 +682,15 @@ export class TelegramMtprotoTransport
|
|
|
515
682
|
formattingEntities,
|
|
516
683
|
replyTo: message.replyToMessageId ?? message.threadId,
|
|
517
684
|
});
|
|
685
|
+
this.rememberOwn(this.ownSent, peer, sent.id);
|
|
518
686
|
for (const document of message.documents ?? []) {
|
|
519
687
|
const data = Buffer.from(document.data);
|
|
520
|
-
await this.client.sendFile(peer, {
|
|
688
|
+
const file = await this.client.sendFile(peer, {
|
|
521
689
|
file: new CustomFile(document.filename, data.length, "", data),
|
|
522
690
|
caption: document.caption,
|
|
523
691
|
replyTo: message.threadId,
|
|
524
692
|
});
|
|
693
|
+
this.rememberOwn(this.ownSent, peer, file.id);
|
|
525
694
|
}
|
|
526
695
|
this.lastActivityAt = new Date();
|
|
527
696
|
return {
|
|
@@ -672,6 +841,8 @@ export class TelegramMtprotoTransport
|
|
|
672
841
|
let peer: Api.TypeInputPeer | null = null;
|
|
673
842
|
try {
|
|
674
843
|
peer = await this.getInputPeer(message.recipient);
|
|
844
|
+
// До вызова: обновление о правке может прийти раньше ответа на неё.
|
|
845
|
+
this.rememberOwn(this.ownEdited, peer, messageId);
|
|
675
846
|
const ft = formattedOf(message);
|
|
676
847
|
const text = ft ? ft.text : message.text;
|
|
677
848
|
const parseMode = ft ? undefined : message.parseMode?.toLowerCase();
|