@imessaging/core 0.7.4 → 0.8.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 +1 -1
- package/src/index.ts +13 -0
- package/src/markup.ts +400 -0
- package/src/types.ts +16 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
formatText,
|
|
3
|
+
formattedOf,
|
|
4
|
+
isSafeUrl,
|
|
5
|
+
parseHtml,
|
|
6
|
+
parseMarkdown,
|
|
7
|
+
toHtml,
|
|
8
|
+
toPlainText,
|
|
9
|
+
type FormattedText,
|
|
10
|
+
type TextEntity,
|
|
11
|
+
type TextEntityType,
|
|
12
|
+
type TextFormat,
|
|
13
|
+
} from "./markup";
|
|
1
14
|
export { MemoryPeerStore } from "./memory-peer-store";
|
|
2
15
|
export { createPeerKey } from "./peer-key";
|
|
3
16
|
export {
|
package/src/markup.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Разметка исходящего текста — одна модель на все каналы.
|
|
3
|
+
*
|
|
4
|
+
* Сообщение пишется один раз (`format: "markdown"` или `"html"`), а каждый транспорт сам решает, во что
|
|
5
|
+
* его превратить: Telegram и MAX получают HTML, почта — HTML и простой текст, hh — простой текст.
|
|
6
|
+
* Раньше это делал каждый потребитель у себя, и у почты конвертера не было вовсе: `**Чек-лист: 92%**`
|
|
7
|
+
* доезжал до кандидата звёздочками.
|
|
8
|
+
*
|
|
9
|
+
* Промежуточная форма — текст плюс выделения со смещениями в кодовых единицах UTF-16. Так её меряет
|
|
10
|
+
* и Telegram, и JS-строка, поэтому `slice` по смещению точен и на эмодзи.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Как написан исходный текст сообщения. */
|
|
14
|
+
export type TextFormat = "plain" | "markdown" | "html";
|
|
15
|
+
|
|
16
|
+
export type TextEntityType = "bold" | "italic" | "underline" | "strike" | "code" | "pre" | "link";
|
|
17
|
+
|
|
18
|
+
export type TextEntity = {
|
|
19
|
+
type: TextEntityType;
|
|
20
|
+
offset: number;
|
|
21
|
+
length: number;
|
|
22
|
+
/** Адрес ссылки — только у `link`. */
|
|
23
|
+
url?: string;
|
|
24
|
+
/** Язык блока кода — только у `pre`, если был указан. */
|
|
25
|
+
language?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type FormattedText = { text: string; entities: TextEntity[] };
|
|
29
|
+
|
|
30
|
+
const ESCAPABLE = "\\`*_[]()~#-.!>|";
|
|
31
|
+
const WORD = /[\p{L}\p{N}_]/u;
|
|
32
|
+
const isWord = (ch: string | undefined) => ch !== undefined && WORD.test(ch);
|
|
33
|
+
const isSpace = (ch: string | undefined) => ch === undefined || /\s/.test(ch);
|
|
34
|
+
|
|
35
|
+
type Builder = { text: string; entities: TextEntity[] };
|
|
36
|
+
|
|
37
|
+
function wrap(
|
|
38
|
+
out: Builder,
|
|
39
|
+
type: TextEntityType,
|
|
40
|
+
write: () => void,
|
|
41
|
+
extra: Partial<TextEntity> = {},
|
|
42
|
+
): void {
|
|
43
|
+
const offset = out.text.length;
|
|
44
|
+
const at = out.entities.length;
|
|
45
|
+
write();
|
|
46
|
+
const length = out.text.length - offset;
|
|
47
|
+
// Внешнее выделение ставится ПЕРЕД вложенными: сериализатору нужен порядок «снаружи внутрь».
|
|
48
|
+
if (length > 0) out.entities.splice(at, 0, { type, offset, length, ...extra });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Закрывающий разделитель на той же строке: выделение через перевод строки — почти всегда опечатка. */
|
|
52
|
+
function findCloser(
|
|
53
|
+
src: string,
|
|
54
|
+
from: number,
|
|
55
|
+
delimiter: string,
|
|
56
|
+
accept: (at: number) => boolean,
|
|
57
|
+
): number {
|
|
58
|
+
let at = src.indexOf(delimiter, from);
|
|
59
|
+
while (at !== -1) {
|
|
60
|
+
if (src.slice(from, at).includes("\n")) return -1;
|
|
61
|
+
if (accept(at)) return at;
|
|
62
|
+
at = src.indexOf(delimiter, at + 1);
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseInline(src: string, out: Builder): void {
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < src.length) {
|
|
70
|
+
const ch = src[i]!;
|
|
71
|
+
const next = src[i + 1];
|
|
72
|
+
|
|
73
|
+
if (ch === "\\" && next !== undefined && ESCAPABLE.includes(next)) {
|
|
74
|
+
out.text += next;
|
|
75
|
+
i += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (ch === "`") {
|
|
80
|
+
const end = src.indexOf("`", i + 1);
|
|
81
|
+
if (end > i + 1 && !src.slice(i + 1, end).includes("\n")) {
|
|
82
|
+
wrap(out, "code", () => (out.text += src.slice(i + 1, end)));
|
|
83
|
+
i = end + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ссылка разбирается раньше выделений: иначе `_` внутри адреса стал бы курсивом.
|
|
89
|
+
if (ch === "[") {
|
|
90
|
+
const m = /^\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/.exec(src.slice(i));
|
|
91
|
+
if (m) {
|
|
92
|
+
wrap(out, "link", () => parseInline(m[1]!, out), { url: m[2]! });
|
|
93
|
+
i += m[0].length;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pair = src.slice(i, i + 2);
|
|
99
|
+
if (pair === "**" || pair === "__" || pair === "~~") {
|
|
100
|
+
// `__` — жирный только на границе слова: `snake__case` остаётся как есть.
|
|
101
|
+
const bounded = pair === "__";
|
|
102
|
+
if (!isSpace(src[i + 2]) && !(bounded && isWord(src[i - 1]))) {
|
|
103
|
+
const end = findCloser(
|
|
104
|
+
src,
|
|
105
|
+
i + 2,
|
|
106
|
+
pair,
|
|
107
|
+
(at) => at > i + 2 && !isSpace(src[at - 1]) && !(bounded && isWord(src[at + 2])),
|
|
108
|
+
);
|
|
109
|
+
if (end !== -1) {
|
|
110
|
+
wrap(out, pair === "~~" ? "strike" : "bold", () =>
|
|
111
|
+
parseInline(src.slice(i + 2, end), out),
|
|
112
|
+
);
|
|
113
|
+
i = end + 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if ((ch === "*" || ch === "_") && next !== ch && !isSpace(next)) {
|
|
120
|
+
// `_` — только на границе слова: иначе съедался бы в `stack_ids`, `@oksana_avd` и путях файлов.
|
|
121
|
+
const bounded = ch === "_";
|
|
122
|
+
if (!(bounded && isWord(src[i - 1]))) {
|
|
123
|
+
const end = findCloser(
|
|
124
|
+
src,
|
|
125
|
+
i + 1,
|
|
126
|
+
ch,
|
|
127
|
+
(at) =>
|
|
128
|
+
at > i + 1 &&
|
|
129
|
+
!isSpace(src[at - 1]) &&
|
|
130
|
+
src[at + 1] !== ch &&
|
|
131
|
+
!(bounded && isWord(src[at + 1])),
|
|
132
|
+
);
|
|
133
|
+
if (end !== -1) {
|
|
134
|
+
wrap(out, "italic", () => parseInline(src.slice(i + 1, end), out));
|
|
135
|
+
i = end + 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
out.text += ch;
|
|
142
|
+
i += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Markdown в том виде, в каком его пишут люди и модели: `**жирный**`, `__жирный__`, `*курсив*`, `_курсив_`,
|
|
148
|
+
* `~~зачёркнутый~~`, `` `код` ``, ```` ```блок``` ````, `[текст](https://…)`, `# заголовок`, `\*` — буквальная звёздочка.
|
|
149
|
+
* Заголовок становится жирной строкой, `* пункт` — `- пункт`: ни заголовков, ни списков в мессенджерах нет.
|
|
150
|
+
*/
|
|
151
|
+
export function parseMarkdown(source: string): FormattedText {
|
|
152
|
+
const out: Builder = { text: "", entities: [] };
|
|
153
|
+
const src = source.replace(/\r\n?/g, "\n");
|
|
154
|
+
// Язык — только если за ним перевод строки: в ```текст``` слово — содержимое, а не язык.
|
|
155
|
+
const fence = /```(?:([\w+-]+)\n|\n)?([\s\S]*?)```/g;
|
|
156
|
+
let last = 0;
|
|
157
|
+
for (const m of src.matchAll(fence)) {
|
|
158
|
+
parseLines(src.slice(last, m.index), out);
|
|
159
|
+
const language = m[1] || undefined;
|
|
160
|
+
wrap(out, "pre", () => (out.text += m[2]!.replace(/\n$/, "")), language ? { language } : {});
|
|
161
|
+
last = m.index + m[0].length;
|
|
162
|
+
}
|
|
163
|
+
parseLines(src.slice(last), out);
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseLines(block: string, out: Builder): void {
|
|
168
|
+
const lines = block.split("\n");
|
|
169
|
+
lines.forEach((line, n) => {
|
|
170
|
+
if (n > 0) out.text += "\n";
|
|
171
|
+
const heading = /^#{1,6}\s+(.*?)\s*#*$/.exec(line);
|
|
172
|
+
if (heading) {
|
|
173
|
+
wrap(out, "bold", () => parseInline(heading[1]!, out));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
parseInline(line.replace(/^(\s*)[*•●·]\s+/, "$1- "), out);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const HTML_TAGS: Record<string, TextEntityType> = {
|
|
181
|
+
b: "bold",
|
|
182
|
+
strong: "bold",
|
|
183
|
+
i: "italic",
|
|
184
|
+
em: "italic",
|
|
185
|
+
u: "underline",
|
|
186
|
+
ins: "underline",
|
|
187
|
+
s: "strike",
|
|
188
|
+
strike: "strike",
|
|
189
|
+
del: "strike",
|
|
190
|
+
code: "code",
|
|
191
|
+
pre: "pre",
|
|
192
|
+
a: "link",
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function decodeHtml(s: string): string {
|
|
196
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, name: string) => {
|
|
197
|
+
const lower = name.toLowerCase();
|
|
198
|
+
if (lower.startsWith("#x")) return String.fromCodePoint(Number.parseInt(lower.slice(2), 16));
|
|
199
|
+
if (lower.startsWith("#")) return String.fromCodePoint(Number.parseInt(lower.slice(1), 10));
|
|
200
|
+
return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0" }[lower] ?? whole;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* HTML в подмножестве Telegram: `b/strong`, `i/em`, `u/ins`, `s/strike/del`, `code`, `pre`, `a href`, `br`.
|
|
206
|
+
* Незнакомый тег снимается, текст внутри остаётся: письмо и hh не должны получить `<span>` буквами.
|
|
207
|
+
*/
|
|
208
|
+
export function parseHtml(source: string): FormattedText {
|
|
209
|
+
const out: Builder = { text: "", entities: [] };
|
|
210
|
+
const open: {
|
|
211
|
+
type: TextEntityType;
|
|
212
|
+
offset: number;
|
|
213
|
+
at: number;
|
|
214
|
+
tag: string;
|
|
215
|
+
url?: string;
|
|
216
|
+
language?: string;
|
|
217
|
+
}[] = [];
|
|
218
|
+
const tag = /<(\/?)([a-z][a-z0-9-]*)\b([^>]*)>/gi;
|
|
219
|
+
let last = 0;
|
|
220
|
+
for (const m of source.matchAll(tag)) {
|
|
221
|
+
out.text += decodeHtml(source.slice(last, m.index));
|
|
222
|
+
last = m.index + m[0].length;
|
|
223
|
+
const [, closing, rawName, attrs] = m;
|
|
224
|
+
const name = rawName!.toLowerCase();
|
|
225
|
+
if (name === "br") {
|
|
226
|
+
out.text += "\n";
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const type = HTML_TAGS[name];
|
|
230
|
+
if (!type) continue;
|
|
231
|
+
if (!closing) {
|
|
232
|
+
const href = type === "link" ? /href\s*=\s*["']([^"']*)["']/i.exec(attrs!)?.[1] : undefined;
|
|
233
|
+
const decoded = href === undefined ? undefined : decodeHtml(href).trim();
|
|
234
|
+
const url = isSafeUrl(decoded) ? decoded : undefined;
|
|
235
|
+
// `<pre><code class="language-ts">` — язык живёт на вложенном `code`, а блок один.
|
|
236
|
+
const language = /class\s*=\s*["']language-([\w+-]+)["']/i.exec(attrs!)?.[1];
|
|
237
|
+
const parentPre = open.at(-1)?.type === "pre";
|
|
238
|
+
if (type === "code" && parentPre) {
|
|
239
|
+
if (language) open.at(-1)!.language = language;
|
|
240
|
+
open.push({ type, offset: -1, at: -1, tag: name });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
open.push({
|
|
244
|
+
type,
|
|
245
|
+
offset: out.text.length,
|
|
246
|
+
at: out.entities.length,
|
|
247
|
+
tag: name,
|
|
248
|
+
...(url ? { url } : {}),
|
|
249
|
+
...(language ? { language } : {}),
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const index = open.findLastIndex((o) => o.tag === name);
|
|
254
|
+
if (index === -1) continue;
|
|
255
|
+
const [entry] = open.splice(index, 1);
|
|
256
|
+
if (!entry || entry.offset < 0) continue;
|
|
257
|
+
const length = out.text.length - entry.offset;
|
|
258
|
+
if (length === 0 || (entry.type === "link" && !entry.url)) continue;
|
|
259
|
+
out.entities.splice(entry.at, 0, {
|
|
260
|
+
type: entry.type,
|
|
261
|
+
offset: entry.offset,
|
|
262
|
+
length,
|
|
263
|
+
...(entry.url ? { url: entry.url } : {}),
|
|
264
|
+
...(entry.language ? { language: entry.language } : {}),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
out.text += decodeHtml(source.slice(last));
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Выделения сообщения с заданным `format`; без него — `undefined`, и транспорт идёт прежним путём через `parseMode`. */
|
|
272
|
+
export function formattedOf(message: {
|
|
273
|
+
text: string;
|
|
274
|
+
format?: TextFormat;
|
|
275
|
+
}): FormattedText | undefined {
|
|
276
|
+
return message.format === undefined ? undefined : formatText(message.text, message.format);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function formatText(text: string, format: TextFormat): FormattedText {
|
|
280
|
+
if (format === "markdown") return parseMarkdown(text);
|
|
281
|
+
if (format === "html") return parseHtml(text);
|
|
282
|
+
return { text, entities: [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Схемы, которые ссылка может нести. `javascript:` и `data:` в письме — это исполняемый код в HTML,
|
|
287
|
+
* поэтому такая ссылка остаётся текстом и в разборе, и в выводе.
|
|
288
|
+
*/
|
|
289
|
+
const SAFE_URL = /^(https?:\/\/|mailto:|tg:\/\/)/i;
|
|
290
|
+
export const isSafeUrl = (url: string | undefined): url is string =>
|
|
291
|
+
url !== undefined && SAFE_URL.test(url.trim());
|
|
292
|
+
|
|
293
|
+
const escapeHtml = (s: string) =>
|
|
294
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
295
|
+
const escapeAttribute = (s: string) => escapeHtml(s).replace(/"/g, """).replace(/'/g, "'");
|
|
296
|
+
|
|
297
|
+
function openTag(e: TextEntity): string {
|
|
298
|
+
switch (e.type) {
|
|
299
|
+
case "bold":
|
|
300
|
+
return "<b>";
|
|
301
|
+
case "italic":
|
|
302
|
+
return "<i>";
|
|
303
|
+
case "underline":
|
|
304
|
+
return "<u>";
|
|
305
|
+
case "strike":
|
|
306
|
+
return "<s>";
|
|
307
|
+
case "code":
|
|
308
|
+
return "<code>";
|
|
309
|
+
case "pre":
|
|
310
|
+
return e.language ? `<pre><code class="language-${escapeAttribute(e.language)}">` : "<pre>";
|
|
311
|
+
case "link":
|
|
312
|
+
return `<a href="${escapeAttribute(e.url ?? "")}">`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function closeTag(e: TextEntity): string {
|
|
317
|
+
const name = {
|
|
318
|
+
bold: "b",
|
|
319
|
+
italic: "i",
|
|
320
|
+
underline: "u",
|
|
321
|
+
strike: "s",
|
|
322
|
+
code: "code",
|
|
323
|
+
pre: "pre",
|
|
324
|
+
link: "a",
|
|
325
|
+
}[e.type];
|
|
326
|
+
return e.type === "pre" && e.language ? "</code></pre>" : `</${name}>`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Сортировка «снаружи внутрь»: раньше начало, при равном — длиннее; при полном совпадении — порядок разбора. */
|
|
330
|
+
function ordered(entities: TextEntity[]): TextEntity[] {
|
|
331
|
+
return entities
|
|
332
|
+
.map((e, index) => ({ e, index }))
|
|
333
|
+
.sort((a, b) => a.e.offset - b.e.offset || b.e.length - a.e.length || a.index - b.index)
|
|
334
|
+
.map(({ e }) => e);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Подмножество HTML, которое понимают и Telegram, и MAX. `lineBreaks: "br"` — для письма, где перевод
|
|
339
|
+
* строки без тега схлопнется в пробел; внутри `pre` переводы остаются как есть.
|
|
340
|
+
*/
|
|
341
|
+
export function toHtml(
|
|
342
|
+
formatted: FormattedText,
|
|
343
|
+
options: { lineBreaks?: "newline" | "br" } = {},
|
|
344
|
+
): string {
|
|
345
|
+
const { text } = formatted;
|
|
346
|
+
const stack: TextEntity[] = [];
|
|
347
|
+
// Выделения собраны потребителем, а не только нашими разборщиками: небезопасная ссылка выводится текстом.
|
|
348
|
+
const pending = ordered(formatted.entities.filter((e) => e.type !== "link" || isSafeUrl(e.url)));
|
|
349
|
+
let html = "";
|
|
350
|
+
let pos = 0;
|
|
351
|
+
|
|
352
|
+
const emit = (until: number) => {
|
|
353
|
+
const chunk = escapeHtml(text.slice(pos, until));
|
|
354
|
+
const inPre = stack.some((e) => e.type === "pre");
|
|
355
|
+
html += options.lineBreaks === "br" && !inPre ? chunk.replace(/\n/g, "<br>\n") : chunk;
|
|
356
|
+
pos = until;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
while (pos < text.length || stack.length > 0 || pending.length > 0) {
|
|
360
|
+
const top = stack.at(-1);
|
|
361
|
+
const nextOpen = pending[0];
|
|
362
|
+
const topEnd = top ? top.offset + top.length : Number.POSITIVE_INFINITY;
|
|
363
|
+
if (top && (!nextOpen || topEnd <= nextOpen.offset)) {
|
|
364
|
+
emit(topEnd);
|
|
365
|
+
html += closeTag(stack.pop()!);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (nextOpen) {
|
|
369
|
+
emit(nextOpen.offset);
|
|
370
|
+
// Пересечение без вложенности в Telegram возможно; в HTML нет — обрезаем по внешнему.
|
|
371
|
+
const clipped = top
|
|
372
|
+
? { ...nextOpen, length: Math.min(nextOpen.length, topEnd - nextOpen.offset) }
|
|
373
|
+
: nextOpen;
|
|
374
|
+
html += openTag(clipped);
|
|
375
|
+
stack.push(clipped);
|
|
376
|
+
pending.shift();
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
emit(text.length);
|
|
380
|
+
}
|
|
381
|
+
return html;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Простой текст: выделения снимаются, ссылка раскрывается в `текст: адрес` — в письме и на hh по ней
|
|
386
|
+
* кликают глазами. Ссылка, чей текст и есть адрес, не удваивается.
|
|
387
|
+
*/
|
|
388
|
+
export function toPlainText(formatted: FormattedText): string {
|
|
389
|
+
const links = formatted.entities
|
|
390
|
+
.filter((e) => e.type === "link" && e.url)
|
|
391
|
+
.sort((a, b) => b.offset - a.offset);
|
|
392
|
+
let text = formatted.text;
|
|
393
|
+
for (const link of links) {
|
|
394
|
+
const end = link.offset + link.length;
|
|
395
|
+
const label = text.slice(link.offset, end);
|
|
396
|
+
if (label.trim() === link.url || `${label.trim()}/` === link.url) continue;
|
|
397
|
+
text = `${text.slice(0, end)}: ${link.url}${text.slice(end)}`;
|
|
398
|
+
}
|
|
399
|
+
return text;
|
|
400
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TextFormat } from "./markup";
|
|
2
|
+
|
|
1
3
|
export type UsernameRecipient = {
|
|
2
4
|
type: "username";
|
|
3
5
|
username: string;
|
|
@@ -82,6 +84,13 @@ export type OutboundMessage = {
|
|
|
82
84
|
threadId?: number;
|
|
83
85
|
documents?: OutboundDocument[];
|
|
84
86
|
buttons?: OutboundButton[][];
|
|
87
|
+
/**
|
|
88
|
+
* Как написан `text`: транспорт сам переводит разметку в формат своего канала — Telegram и MAX
|
|
89
|
+
* получают выделения, почта HTML и простой текст, hh простой текст. Не задан — прежнее поведение
|
|
90
|
+
* через `parseMode`.
|
|
91
|
+
*/
|
|
92
|
+
format?: TextFormat;
|
|
93
|
+
/** @deprecated Режим разбора Telegram, уходит в канал как есть. Вместо него — `format`; заданы оба — действует `format`. */
|
|
85
94
|
parseMode?: "HTML" | "Markdown" | "MarkdownV2";
|
|
86
95
|
metadata?: Readonly<Record<string, unknown>>;
|
|
87
96
|
};
|
|
@@ -106,6 +115,13 @@ export type EditableMessage = {
|
|
|
106
115
|
messageId: string;
|
|
107
116
|
text: string;
|
|
108
117
|
buttons?: OutboundButton[][];
|
|
118
|
+
/**
|
|
119
|
+
* Как написан `text`: транспорт сам переводит разметку в формат своего канала — Telegram и MAX
|
|
120
|
+
* получают выделения, почта HTML и простой текст, hh простой текст. Не задан — прежнее поведение
|
|
121
|
+
* через `parseMode`.
|
|
122
|
+
*/
|
|
123
|
+
format?: TextFormat;
|
|
124
|
+
/** @deprecated Режим разбора Telegram, уходит в канал как есть. Вместо него — `format`; заданы оба — действует `format`. */
|
|
109
125
|
parseMode?: "HTML" | "Markdown" | "MarkdownV2";
|
|
110
126
|
};
|
|
111
127
|
|