@volter/twin-telegram 1.0.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/LICENSE +202 -0
- package/README.md +53 -0
- package/dist/generated/pack-facts.json +53 -0
- package/dist/journeys/coverage.json +138 -0
- package/dist/journeys/customer-life.json +1229 -0
- package/dist/journeys/customer-life.outline.md +99 -0
- package/dist/journeys/customer-life.outline.verdict.json +76 -0
- package/dist/journeys/customer-life.verdict.json +134 -0
- package/dist/journeys/decisions.json +95 -0
- package/dist/journeys/demand.json +152 -0
- package/dist/journeys/score.json +163 -0
- package/dist/journeys/unreachable.json +29 -0
- package/dist/journeys/vendor-examples.json +1554 -0
- package/dist/journeys/vendor-examples.verdict.json +14 -0
- package/dist/spec/SOURCE.md +51 -0
- package/dist/spec/bot-api.html.gz +0 -0
- package/dist/spec/doc-examples.json +344 -0
- package/dist/spec/grouping.json +1 -0
- package/dist/spec/openapi.json.gz +0 -0
- package/dist/spec/page-to-openapi.ts +258 -0
- package/dist/spec/patches.json +289 -0
- package/dist/spec/sources.json +416 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +21 -0
- package/dist/src/engine/media.d.ts +17 -0
- package/dist/src/engine/media.js +75 -0
- package/dist/src/engine/text.d.ts +45 -0
- package/dist/src/engine/text.js +512 -0
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +17 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +9 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +104 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +32 -0
- package/dist/src/semantics/available-methods.d.ts +12 -0
- package/dist/src/semantics/available-methods.js +266 -0
- package/dist/src/semantics/doors.d.ts +18 -0
- package/dist/src/semantics/doors.js +292 -0
- package/dist/src/semantics/getting-updates.d.ts +3 -0
- package/dist/src/semantics/getting-updates.js +68 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +19 -0
- package/dist/src/semantics/shared.d.ts +112 -0
- package/dist/src/semantics/shared.js +424 -0
- package/dist/src/semantics/states.d.ts +18 -0
- package/dist/src/semantics/states.js +37 -0
- package/dist/src/semantics/updating-messages.d.ts +2 -0
- package/dist/src/semantics/updating-messages.js +48 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +24 -0
- package/generated/pack-facts.json +53 -0
- package/journeys/coverage.json +138 -0
- package/journeys/customer-life.json +1229 -0
- package/journeys/customer-life.outline.md +99 -0
- package/journeys/customer-life.outline.verdict.json +76 -0
- package/journeys/customer-life.verdict.json +134 -0
- package/journeys/decisions.json +95 -0
- package/journeys/demand.json +152 -0
- package/journeys/score.json +163 -0
- package/journeys/unreachable.json +29 -0
- package/journeys/vendor-examples.json +1554 -0
- package/journeys/vendor-examples.verdict.json +14 -0
- package/package.json +37 -0
- package/spec/SOURCE.md +51 -0
- package/spec/bot-api.html.gz +0 -0
- package/spec/doc-examples.json +344 -0
- package/spec/grouping.json +1 -0
- package/spec/openapi.json.gz +0 -0
- package/spec/page-to-openapi.ts +258 -0
- package/spec/patches.json +289 -0
- package/spec/sources.json +416 -0
- package/src/cli.ts +22 -0
- package/src/engine/media.ts +67 -0
- package/src/engine/text.ts +409 -0
- package/src/fetch.ts +18 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +114 -0
- package/src/semantics/around.ts +33 -0
- package/src/semantics/available-methods.ts +253 -0
- package/src/semantics/doors.ts +277 -0
- package/src/semantics/getting-updates.ts +64 -0
- package/src/semantics/index.ts +23 -0
- package/src/semantics/shared.ts +428 -0
- package/src/semantics/states.ts +38 -0
- package/src/semantics/updating-messages.ts +41 -0
- package/src/server.ts +17 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// A message's text as Telegram makes it from what a client sends: the parse modes (HTML, MarkdownV2 and the legacy
|
|
2
|
+
// Markdown) read into plain text and entities, the entities the server finds itself in the text (mentions, commands,
|
|
3
|
+
// hashtags, links), and the trim. Pure computation over strings; offsets and lengths are UTF-16 code units, as the Bot
|
|
4
|
+
// API counts them (https://core.telegram.org/bots/api#messageentity, "Offset in UTF-16 code units to the start of the
|
|
5
|
+
// entity").
|
|
6
|
+
//
|
|
7
|
+
// The parses are the ones TDLib runs for the Bot API (td/telegram/MessageEntity.cpp at tdlib/td 42e6a52: parse_html,
|
|
8
|
+
// parse_markdown_v2 and parse_markdown), read as code and written again here: their tags and marks, their character
|
|
9
|
+
// references and escapes, their errors and their words. A link to a user (tg://user?id=) is answered as a `user_id` the
|
|
10
|
+
// caller resolves to the user, or drops.
|
|
11
|
+
/** UTF-8 byte length of a string: TDLib's errors name byte offsets. */
|
|
12
|
+
const bytes = (s) => new TextEncoder().encode(s).length;
|
|
13
|
+
const TAGS = new Set(['a', 'b', 'strong', 'i', 'em', 's', 'strike', 'del', 'u', 'ins', 'tg-spoiler', 'tg-emoji', 'tg-time', 'span', 'pre', 'code', 'blockquote']);
|
|
14
|
+
const SPACE = /[ \t\n\r\f\v]/;
|
|
15
|
+
/** The named references TDLib decodes (lt, gt, amp, quot) and numeric ones; anything else stays as it is written. */
|
|
16
|
+
function decodeEntity(text, i) {
|
|
17
|
+
let end = i + 1;
|
|
18
|
+
let code = 0;
|
|
19
|
+
if (text[i + 1] === '#') {
|
|
20
|
+
end++;
|
|
21
|
+
if (text[i + 2] === 'x') {
|
|
22
|
+
end++;
|
|
23
|
+
while (end < text.length && /[0-9a-fA-F]/.test(text[end]))
|
|
24
|
+
code = code * 16 + parseInt(text[end++], 16);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
while (end < text.length && /[0-9]/.test(text[end]))
|
|
28
|
+
code = code * 10 + Number(text[end++]);
|
|
29
|
+
}
|
|
30
|
+
if (code === 0 || code >= 0x10ffff || end - i >= 10)
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
while (end < text.length && /[A-Za-z]/.test(text[end]))
|
|
35
|
+
end++;
|
|
36
|
+
const name = text.slice(i + 1, end);
|
|
37
|
+
code = name === 'lt' ? 60 : name === 'gt' ? 62 : name === 'amp' ? 38 : name === 'quot' ? 34 : 0;
|
|
38
|
+
if (!code)
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (text[end] === ';')
|
|
42
|
+
end++;
|
|
43
|
+
return { code, end };
|
|
44
|
+
}
|
|
45
|
+
/** The HTML parse mode: the text and its entities, or the parser's refusal in TDLib's words. */
|
|
46
|
+
export function parseHtml(input) {
|
|
47
|
+
let out = '';
|
|
48
|
+
const entities = [];
|
|
49
|
+
const open = [];
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < input.length) {
|
|
52
|
+
const c = input[i];
|
|
53
|
+
if (c === '&') {
|
|
54
|
+
const d = decodeEntity(input, i);
|
|
55
|
+
if (d) {
|
|
56
|
+
out += String.fromCodePoint(d.code);
|
|
57
|
+
i = d.end;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (c !== '<') {
|
|
62
|
+
out += c;
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const begin = bytes(input.slice(0, i));
|
|
67
|
+
i++;
|
|
68
|
+
if (input[i] !== '/') {
|
|
69
|
+
let j = i;
|
|
70
|
+
while (j < input.length && !SPACE.test(input[j]) && input[j] !== '>')
|
|
71
|
+
j++;
|
|
72
|
+
if (j >= input.length)
|
|
73
|
+
return { error: `Unclosed start tag at byte offset ${begin}` };
|
|
74
|
+
const tag = input.slice(i, j).toLowerCase();
|
|
75
|
+
if (!TAGS.has(tag))
|
|
76
|
+
return { error: `Unsupported start tag "${tag}" at byte offset ${begin}` };
|
|
77
|
+
let argument = '';
|
|
78
|
+
let date = 0;
|
|
79
|
+
while (input[j] !== '>') {
|
|
80
|
+
while (j < input.length && SPACE.test(input[j]))
|
|
81
|
+
j++;
|
|
82
|
+
if (input[j] === '>')
|
|
83
|
+
break;
|
|
84
|
+
const nameStart = j;
|
|
85
|
+
while (j < input.length && !SPACE.test(input[j]) && !'=>/"\''.includes(input[j]))
|
|
86
|
+
j++;
|
|
87
|
+
const name = input.slice(nameStart, j);
|
|
88
|
+
if (!name)
|
|
89
|
+
return { error: `Empty attribute name in the tag "${tag}" at byte offset ${begin}` };
|
|
90
|
+
while (j < input.length && SPACE.test(input[j]))
|
|
91
|
+
j++;
|
|
92
|
+
if (input[j] !== '=') {
|
|
93
|
+
if (j >= input.length)
|
|
94
|
+
return { error: `Unclosed start tag "${tag}" at byte offset ${begin}` };
|
|
95
|
+
if (tag === 'blockquote' && name === 'expandable')
|
|
96
|
+
argument = '1';
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
j++;
|
|
100
|
+
while (j < input.length && SPACE.test(input[j]))
|
|
101
|
+
j++;
|
|
102
|
+
if (j >= input.length)
|
|
103
|
+
return { error: `Unclosed start tag "${tag}" at byte offset ${begin}` };
|
|
104
|
+
// a value is a quoted string (its references decoded), or a name token (letters, digits, periods and hyphens,
|
|
105
|
+
// read without case)
|
|
106
|
+
const quote = input[j] === '"' || input[j] === "'" ? input[j++] : undefined;
|
|
107
|
+
const valueStart = j;
|
|
108
|
+
let value = '';
|
|
109
|
+
while (j < input.length && (quote ? input[j] !== quote : /[A-Za-z0-9.-]/.test(input[j]))) {
|
|
110
|
+
const d = quote && input[j] === '&' ? decodeEntity(input, j) : undefined;
|
|
111
|
+
value += d ? String.fromCodePoint(d.code) : input[j];
|
|
112
|
+
j = d ? d.end : j + 1;
|
|
113
|
+
}
|
|
114
|
+
if (quote && input[j] === quote)
|
|
115
|
+
j++;
|
|
116
|
+
if (!quote)
|
|
117
|
+
value = value.toLowerCase();
|
|
118
|
+
if (!quote && j < input.length && !SPACE.test(input[j]) && input[j] !== '>')
|
|
119
|
+
return { error: `Unexpected end of name token at byte offset ${bytes(input.slice(0, valueStart))}` };
|
|
120
|
+
if (j >= input.length)
|
|
121
|
+
return { error: `Unclosed start tag at byte offset ${begin}` };
|
|
122
|
+
if (tag === 'a' && name === 'href')
|
|
123
|
+
argument = value;
|
|
124
|
+
else if (tag === 'code' && name === 'class' && value.startsWith('language-'))
|
|
125
|
+
argument = value.slice(9);
|
|
126
|
+
else if (tag === 'span' && name === 'class' && value.startsWith('tg-'))
|
|
127
|
+
argument = value.slice(3);
|
|
128
|
+
else if (tag === 'blockquote' && name === 'expandable')
|
|
129
|
+
argument = '1';
|
|
130
|
+
else if (tag === 'tg-emoji' && name === 'emoji-id')
|
|
131
|
+
argument = value;
|
|
132
|
+
else if (tag === 'tg-time' && name === 'unix')
|
|
133
|
+
date = /^-?\d+$/.test(value) ? Number(value) : 0;
|
|
134
|
+
else if (tag === 'tg-time' && name === 'format')
|
|
135
|
+
argument = value;
|
|
136
|
+
}
|
|
137
|
+
if (tag === 'span' && argument !== 'spoiler')
|
|
138
|
+
return { error: `Tag "span" must have class "tg-spoiler" at byte offset ${begin}` };
|
|
139
|
+
open.push({ tag, argument, date, offset: out.length, start: out.length });
|
|
140
|
+
i = j + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// an end tag
|
|
144
|
+
if (!open.length)
|
|
145
|
+
return { error: `Unexpected end tag at byte offset ${begin}` };
|
|
146
|
+
let j = i + 1;
|
|
147
|
+
while (j < input.length && !SPACE.test(input[j]) && input[j] !== '>')
|
|
148
|
+
j++;
|
|
149
|
+
const endTag = input.slice(i + 1, j).toLowerCase();
|
|
150
|
+
while (j < input.length && SPACE.test(input[j]))
|
|
151
|
+
j++;
|
|
152
|
+
if (input[j] !== '>')
|
|
153
|
+
return { error: `Unclosed end tag at byte offset ${begin}` };
|
|
154
|
+
const top = open.at(-1);
|
|
155
|
+
if (endTag && endTag !== top.tag)
|
|
156
|
+
return { error: `Unmatched end tag at byte offset ${begin}, expected "</${top.tag}>", found "</${endTag}>"` };
|
|
157
|
+
const length = out.length - top.offset;
|
|
158
|
+
if (length > 0) {
|
|
159
|
+
const at = { offset: top.offset, length };
|
|
160
|
+
if (top.tag === 'i' || top.tag === 'em')
|
|
161
|
+
entities.push({ type: 'italic', ...at });
|
|
162
|
+
else if (top.tag === 'b' || top.tag === 'strong')
|
|
163
|
+
entities.push({ type: 'bold', ...at });
|
|
164
|
+
else if (top.tag === 's' || top.tag === 'strike' || top.tag === 'del')
|
|
165
|
+
entities.push({ type: 'strikethrough', ...at });
|
|
166
|
+
else if (top.tag === 'u' || top.tag === 'ins')
|
|
167
|
+
entities.push({ type: 'underline', ...at });
|
|
168
|
+
else if (top.tag === 'tg-spoiler' || top.tag === 'span')
|
|
169
|
+
entities.push({ type: 'spoiler', ...at });
|
|
170
|
+
else if (top.tag === 'tg-emoji') {
|
|
171
|
+
if (!/^\d+$/.test(top.argument) || /^0+$/.test(top.argument))
|
|
172
|
+
return { error: 'Invalid custom emoji identifier specified' };
|
|
173
|
+
entities.push({ type: 'custom_emoji', ...at, custom_emoji_id: top.argument });
|
|
174
|
+
}
|
|
175
|
+
else if (top.tag === 'tg-time') {
|
|
176
|
+
if (!/^([rR]|[tTdDwW]*)$/.test(top.argument))
|
|
177
|
+
return { error: 'Invalid date format used' };
|
|
178
|
+
if (top.date > 0)
|
|
179
|
+
entities.push({ type: 'date_time', ...at, unix_time: top.date, ...(top.argument ? { date_time_format: top.argument } : {}) });
|
|
180
|
+
}
|
|
181
|
+
else if (top.tag === 'a') {
|
|
182
|
+
const link = linkOf(top.argument || out.slice(top.start));
|
|
183
|
+
if (link)
|
|
184
|
+
entities.push(linkEntity(link, at.offset, at.length));
|
|
185
|
+
}
|
|
186
|
+
else if (top.tag === 'pre') {
|
|
187
|
+
const last = entities.at(-1);
|
|
188
|
+
if (last?.type === 'code' && last.offset === at.offset && last.length === at.length && last.language)
|
|
189
|
+
entities[entities.length - 1] = { type: 'pre', ...at, language: last.language };
|
|
190
|
+
else
|
|
191
|
+
entities.push({ type: 'pre', ...at });
|
|
192
|
+
}
|
|
193
|
+
else if (top.tag === 'code') {
|
|
194
|
+
const last = entities.at(-1);
|
|
195
|
+
if (last?.type === 'pre' && last.offset === at.offset && last.length === at.length && top.argument)
|
|
196
|
+
entities[entities.length - 1] = { type: 'pre', ...at, language: top.argument };
|
|
197
|
+
else
|
|
198
|
+
entities.push({ type: 'code', ...at, ...(top.argument ? { language: top.argument } : {}) });
|
|
199
|
+
}
|
|
200
|
+
else if (top.tag === 'blockquote')
|
|
201
|
+
entities.push({ type: top.argument ? 'expandable_blockquote' : 'blockquote', ...at });
|
|
202
|
+
}
|
|
203
|
+
open.pop();
|
|
204
|
+
i = j + 1;
|
|
205
|
+
}
|
|
206
|
+
if (open.length)
|
|
207
|
+
return { error: `Can't find end tag corresponding to start tag "${open.at(-1).tag}"` };
|
|
208
|
+
// a code entity's language is kept only on pre
|
|
209
|
+
for (const e of entities)
|
|
210
|
+
if (e.type === 'code')
|
|
211
|
+
delete e.language;
|
|
212
|
+
return { text: out, entities: sortEntities(entities) };
|
|
213
|
+
}
|
|
214
|
+
/** What a link's address makes of its text (TDLib's LinkManager: get_link_user_id, then get_checked_link): a mention of
|
|
215
|
+
* a user by id, a link (an address with no scheme is taken as http), or nothing for an address it does not take. */
|
|
216
|
+
function linkOf(url) {
|
|
217
|
+
const user = /^tg:\/\/user\?id=(\d+)$/i.exec(url);
|
|
218
|
+
if (user)
|
|
219
|
+
return { type: 'text_mention', user_id: Number(user[1]) };
|
|
220
|
+
return /^(https?|tg|ton):\/\//i.test(url) || /^[^\s:/]+\.[^\s:/]+/.test(url) ? { type: 'text_link', url: /^[a-z]+:\/\//i.test(url) ? url : `http://${url}` } : undefined;
|
|
221
|
+
}
|
|
222
|
+
/** A link's entity over a range of the text. */
|
|
223
|
+
const linkEntity = (link, offset, length) => link.type === 'text_mention' ? { type: link.type, offset, length, user_id: link.user_id } : { type: link.type, offset, length, url: link.url };
|
|
224
|
+
/** A custom emoji's id from its tg://emoji link, or undefined. */
|
|
225
|
+
const emojiOf = (url) => {
|
|
226
|
+
const m = /^tg:\/\/emoji\?id=(\d+)$/i.exec(url);
|
|
227
|
+
return m && !/^0+$/.test(m[1]) ? m[1] : undefined;
|
|
228
|
+
};
|
|
229
|
+
/** A date's moment and format from its tg://time link (the same formats the HTML mode's tg-time takes), or undefined. */
|
|
230
|
+
const timeOf = (url) => {
|
|
231
|
+
const m = /^tg:\/\/time\?(.*)$/i.exec(url);
|
|
232
|
+
if (!m)
|
|
233
|
+
return undefined;
|
|
234
|
+
const q = new URLSearchParams(m[1]);
|
|
235
|
+
const unix = q.get('unix') ?? '';
|
|
236
|
+
const format = q.get('format') ?? '';
|
|
237
|
+
if (!/^\d+$/.test(unix) || Number(unix) <= 0 || !/^([rR]|[tTdDwW]*)$/.test(format))
|
|
238
|
+
return undefined;
|
|
239
|
+
return { unix_time: Number(unix), ...(format ? { date_time_format: format } : {}) };
|
|
240
|
+
};
|
|
241
|
+
/** A link's address in the Markdown modes: up to its closing parenthesis, a backslash escaping the character after it. */
|
|
242
|
+
function addressAt(input, from) {
|
|
243
|
+
let url = '';
|
|
244
|
+
let i = from;
|
|
245
|
+
while (i < input.length && input[i] !== ')') {
|
|
246
|
+
const next = input.charCodeAt(i + 1);
|
|
247
|
+
if (input[i] === '\\' && next > 0 && next <= 126) {
|
|
248
|
+
url += input[i + 1];
|
|
249
|
+
i += 2;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
url += input[i++];
|
|
253
|
+
}
|
|
254
|
+
if (input[i] !== ')')
|
|
255
|
+
return { error: `Can't find end of a URL at byte offset ${bytes(input.slice(0, from))}` };
|
|
256
|
+
return { url, end: i };
|
|
257
|
+
}
|
|
258
|
+
/** A mark's name in TDLib's errors. */
|
|
259
|
+
const MARK_NAME = {
|
|
260
|
+
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', spoiler: 'Spoiler', code: 'Code',
|
|
261
|
+
pre: 'Pre', pre_code: 'PreCode', text_link: 'TextUrl', custom_emoji: 'CustomEmoji', blockquote: 'BlockQuote',
|
|
262
|
+
};
|
|
263
|
+
const RESERVED = '_*[]()~`>#+-=|{}.!\n';
|
|
264
|
+
/** Whether a character closes the innermost mark open (TDLib's is_end_of_an_entity, a quotation aside). */
|
|
265
|
+
const closesMark = (mark, c, twice, third) => mark === 'bold' ? c === '*' : mark === 'italic' ? c === '_' && !twice : mark === 'code' ? c === '`' : mark === 'pre' || mark === 'pre_code' ? c === '`' && twice && third === '`'
|
|
266
|
+
: mark === 'text_link' || mark === 'custom_emoji' ? c === ']' : mark === 'underline' ? c === '_' && twice : mark === 'strikethrough' ? c === '~' : mark === 'spoiler' ? c === '|' && twice : false;
|
|
267
|
+
/** The MarkdownV2 parse mode: the text and its entities, or the parser's refusal in TDLib's words. A backslash escapes
|
|
268
|
+
* any ASCII character; outside code every one of `_*[]()~`>#+-=|{}.!` that opens no mark must be escaped; inside code
|
|
269
|
+
* only the backquote is reserved. */
|
|
270
|
+
export function parseMarkdownV2(input) {
|
|
271
|
+
let out = '';
|
|
272
|
+
const entities = [];
|
|
273
|
+
const nested = [];
|
|
274
|
+
const unended = (e) => ({ error: `Can't find end of ${MARK_NAME[e.mark]} entity at byte offset ${bytes(input.slice(0, e.at))}` });
|
|
275
|
+
let haveBlockquote = false;
|
|
276
|
+
let canStartBlockquote = true;
|
|
277
|
+
for (let i = 0; i < input.length; i++) {
|
|
278
|
+
const c = input[i];
|
|
279
|
+
const next = input.charCodeAt(i + 1);
|
|
280
|
+
const top = nested.at(-1);
|
|
281
|
+
const inCode = top !== undefined && (top.mark === 'code' || top.mark === 'pre' || top.mark === 'pre_code');
|
|
282
|
+
const escaped = c === '\\' && next > 0 && next <= 126;
|
|
283
|
+
if (escaped || !(inCode ? '`' : RESERVED).includes(c)) {
|
|
284
|
+
if (escaped)
|
|
285
|
+
i++;
|
|
286
|
+
out += input[i];
|
|
287
|
+
// a quotation starts only at a line's start
|
|
288
|
+
if (input[i] !== '\r')
|
|
289
|
+
canStartBlockquote = escaped && input[i] === '\n';
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const twice = input[i + 1] === c;
|
|
293
|
+
// a line that does not go on with > ends the quotation
|
|
294
|
+
const closes = top !== undefined && ((haveBlockquote && c === '\n' && input[i + 1] !== '>') || closesMark(top.mark, c, twice, input[i + 2]));
|
|
295
|
+
if (!closes) {
|
|
296
|
+
if (c === '\n') {
|
|
297
|
+
out += c;
|
|
298
|
+
canStartBlockquote = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// a quotation's next line goes on the quotation already open
|
|
302
|
+
if (c === '>' && canStartBlockquote && haveBlockquote)
|
|
303
|
+
continue;
|
|
304
|
+
const mark = c === '_' ? (twice ? 'underline' : 'italic') : c === '*' ? 'bold' : c === '~' ? 'strikethrough' : c === '|' && twice ? 'spoiler' : c === '[' ? 'text_link'
|
|
305
|
+
: c === '`' ? (twice && input[i + 2] === '`' ? 'pre' : 'code') : c === '!' && input[i + 1] === '[' ? 'custom_emoji' : c === '>' && canStartBlockquote ? 'blockquote' : undefined;
|
|
306
|
+
if (!mark)
|
|
307
|
+
return { error: `Character '${c}' is reserved and must be escaped with the preceding '\\'` };
|
|
308
|
+
const at = i;
|
|
309
|
+
let argument = '';
|
|
310
|
+
if (mark === 'underline' || mark === 'spoiler' || mark === 'custom_emoji')
|
|
311
|
+
i++;
|
|
312
|
+
if (mark === 'pre') {
|
|
313
|
+
i += 3;
|
|
314
|
+
// a language is the word right after the opening, ended by a space or a new line
|
|
315
|
+
let end = i;
|
|
316
|
+
while (end < input.length && !SPACE.test(input[end]) && input[end] !== '`')
|
|
317
|
+
end++;
|
|
318
|
+
if (i !== end && end < input.length && input[end] !== '`') {
|
|
319
|
+
argument = input.slice(i, end);
|
|
320
|
+
i = end;
|
|
321
|
+
}
|
|
322
|
+
// one new line at the start of the block is skipped
|
|
323
|
+
if (input[i] === '\n' || input[i] === '\r')
|
|
324
|
+
i += (input[i + 1] === '\n' || input[i + 1] === '\r') && input[i] !== input[i + 1] ? 2 : 1;
|
|
325
|
+
i--;
|
|
326
|
+
}
|
|
327
|
+
haveBlockquote ||= mark === 'blockquote';
|
|
328
|
+
nested.push({ mark: mark === 'pre' && argument ? 'pre_code' : mark, argument, offset: out.length, at });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// a mark closes
|
|
332
|
+
const open = top;
|
|
333
|
+
let type = open.mark;
|
|
334
|
+
if (c === '\n' && open.mark !== 'blockquote') {
|
|
335
|
+
// only || ending a quotation's line closes there, and makes the quotation expandable
|
|
336
|
+
if (open.mark !== 'spoiler' || !(open.at === i - 2 || (open.at === i - 3 && out.endsWith('\r'))))
|
|
337
|
+
return unended(open);
|
|
338
|
+
nested.pop();
|
|
339
|
+
if (nested.at(-1).mark !== 'blockquote')
|
|
340
|
+
return unended(nested.at(-1));
|
|
341
|
+
type = 'expandable_blockquote';
|
|
342
|
+
}
|
|
343
|
+
const cur = nested.at(-1);
|
|
344
|
+
let skip = out.length === cur.offset;
|
|
345
|
+
let made;
|
|
346
|
+
if (type === 'underline' || type === 'spoiler')
|
|
347
|
+
i++;
|
|
348
|
+
if (type === 'pre' || type === 'pre_code')
|
|
349
|
+
i += 2;
|
|
350
|
+
if (type === 'text_link' || type === 'custom_emoji') {
|
|
351
|
+
// a link's address follows in parentheses (a text link with none links its own text)
|
|
352
|
+
if (type === 'custom_emoji' && input[i + 1] !== '(')
|
|
353
|
+
return { error: 'The entity must contain a tg://emoji or tg://time URL' };
|
|
354
|
+
const address = input[i + 1] === '(' ? addressAt(input, i + 2) : { url: out.slice(cur.offset), end: i };
|
|
355
|
+
if ('error' in address)
|
|
356
|
+
return address;
|
|
357
|
+
i = address.end;
|
|
358
|
+
const link = type === 'text_link' ? linkOf(address.url) : undefined;
|
|
359
|
+
const emoji = type === 'custom_emoji' ? emojiOf(address.url) : undefined;
|
|
360
|
+
const time = type === 'custom_emoji' && !emoji ? timeOf(address.url) : undefined;
|
|
361
|
+
if (type === 'custom_emoji' && !emoji && !time)
|
|
362
|
+
return { error: 'Invalid tg://emoji or tg://time URL specified' };
|
|
363
|
+
made = link ? linkEntity(link, cur.offset, 0) : emoji ? { type: 'custom_emoji', offset: cur.offset, length: 0, custom_emoji_id: emoji } : time ? { type: 'date_time', offset: cur.offset, length: 0, ...time } : undefined;
|
|
364
|
+
// an address that is no link leaves its text plain
|
|
365
|
+
skip ||= !made;
|
|
366
|
+
}
|
|
367
|
+
if (type === 'blockquote' || type === 'expandable_blockquote') {
|
|
368
|
+
// the line's end is the quotation's last character
|
|
369
|
+
haveBlockquote = false;
|
|
370
|
+
out += c;
|
|
371
|
+
canStartBlockquote = true;
|
|
372
|
+
skip = false;
|
|
373
|
+
}
|
|
374
|
+
const length = out.length - cur.offset;
|
|
375
|
+
if (!skip)
|
|
376
|
+
entities.push(made ? { ...made, length } : type === 'pre_code' ? { type: 'pre', offset: cur.offset, length, language: cur.argument } : { type, offset: cur.offset, length });
|
|
377
|
+
nested.pop();
|
|
378
|
+
}
|
|
379
|
+
if (haveBlockquote) {
|
|
380
|
+
// a quotation that ends the text with || is expandable
|
|
381
|
+
const expandable = nested.at(-1).mark === 'spoiler' && nested.at(-1).at === input.length - 2;
|
|
382
|
+
if (expandable)
|
|
383
|
+
nested.pop();
|
|
384
|
+
const quote = nested.at(-1);
|
|
385
|
+
if (quote.mark === 'blockquote' && out.length !== quote.offset)
|
|
386
|
+
entities.push({ type: expandable ? 'expandable_blockquote' : 'blockquote', offset: quote.offset, length: out.length - quote.offset });
|
|
387
|
+
if (quote.mark === 'blockquote')
|
|
388
|
+
nested.pop();
|
|
389
|
+
}
|
|
390
|
+
if (nested.length)
|
|
391
|
+
return unended(nested.at(-1));
|
|
392
|
+
return { text: out, entities: sortEntities(entities) };
|
|
393
|
+
}
|
|
394
|
+
/** The legacy Markdown parse mode: *bold*, _italic_, `code`, ```pre``` (a language after its opening), [text](link),
|
|
395
|
+
* none nested; a backslash escapes only _ * ` and [. The text and its entities, or the parser's refusal in TDLib's
|
|
396
|
+
* words. */
|
|
397
|
+
export function parseMarkdown(input) {
|
|
398
|
+
let out = '';
|
|
399
|
+
const entities = [];
|
|
400
|
+
for (let i = 0; i < input.length; i++) {
|
|
401
|
+
const c = input[i];
|
|
402
|
+
// a backslash escapes a mark's character outside an entity
|
|
403
|
+
if (c === '\\' && input[i + 1] !== undefined && '_*`['.includes(input[i + 1]))
|
|
404
|
+
i++;
|
|
405
|
+
if (c === '\\' || !'_*`['.includes(c)) {
|
|
406
|
+
out += input[i];
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const begin = i;
|
|
410
|
+
const endChar = c === '[' ? ']' : c;
|
|
411
|
+
const pre = c === '`' && input[i + 1] === '`' && input[i + 2] === '`';
|
|
412
|
+
let language = '';
|
|
413
|
+
i += pre ? 3 : 1;
|
|
414
|
+
if (pre) {
|
|
415
|
+
// a language is the word right after the opening, ended by a space or a new line
|
|
416
|
+
let end = i;
|
|
417
|
+
while (end < input.length && !SPACE.test(input[end]) && input[end] !== '`')
|
|
418
|
+
end++;
|
|
419
|
+
if (i !== end && end < input.length && input[end] !== '`') {
|
|
420
|
+
language = input.slice(i, end);
|
|
421
|
+
i = end;
|
|
422
|
+
}
|
|
423
|
+
// one new line at the start of the block is skipped
|
|
424
|
+
if (input[i] === '\n' || input[i] === '\r')
|
|
425
|
+
i += (input[i + 1] === '\n' || input[i + 1] === '\r') && input[i] !== input[i + 1] ? 2 : 1;
|
|
426
|
+
}
|
|
427
|
+
const offset = out.length;
|
|
428
|
+
while (i < input.length && (input[i] !== endChar || (pre && !(input[i + 1] === '`' && input[i + 2] === '`'))))
|
|
429
|
+
out += input[i++];
|
|
430
|
+
if (i === input.length)
|
|
431
|
+
return { error: `Can't find end of the entity starting at byte offset ${bytes(input.slice(0, begin))}` };
|
|
432
|
+
const length = out.length - offset;
|
|
433
|
+
// a link's address follows in parentheses (one with none links its own text); an empty entity is no entity
|
|
434
|
+
const close = c === '[' && length && input[i + 1] === '(' ? input.indexOf(')', i + 2) : -1;
|
|
435
|
+
const url = c !== '[' || !length ? '' : input[i + 1] === '(' ? input.slice(i + 2, close < 0 ? input.length : close) : input.slice(begin + 1, i);
|
|
436
|
+
if (c === '[' && length && input[i + 1] === '(')
|
|
437
|
+
i = close < 0 ? input.length : close;
|
|
438
|
+
const link = url ? linkOf(url) : undefined;
|
|
439
|
+
const made = !length ? undefined : c === '_' ? { type: 'italic', offset, length } : c === '*' ? { type: 'bold', offset, length }
|
|
440
|
+
: c === '`' ? (pre ? { type: 'pre', offset, length, ...(language ? { language } : {}) } : { type: 'code', offset, length }) : link ? linkEntity(link, offset, length) : undefined;
|
|
441
|
+
if (made)
|
|
442
|
+
entities.push(made);
|
|
443
|
+
if (pre)
|
|
444
|
+
i += 2;
|
|
445
|
+
}
|
|
446
|
+
return { text: out, entities: sortEntities(entities) };
|
|
447
|
+
}
|
|
448
|
+
/** Entities in TDLib's order: by offset, the longer first. */
|
|
449
|
+
export function sortEntities(entities) {
|
|
450
|
+
return [...entities].sort((a, b) => a.offset - b.offset || b.length - a.length);
|
|
451
|
+
}
|
|
452
|
+
const WORD = /[\p{L}\p{N}_]/u;
|
|
453
|
+
const isWord = (ch) => ch !== undefined && WORD.test(ch);
|
|
454
|
+
/** The entities the server finds in a text by itself: mentions (@name, 2-32 letters, digits and underscores, not
|
|
455
|
+
* inside a word), bot commands (/command, 1-64, optionally @bot), hashtags (#tag with a letter), cashtags ($ABC),
|
|
456
|
+
* links (http, https, or www.) and e-mail addresses. Where the twin stops: TDLib's own link matcher also takes bare
|
|
457
|
+
* domains by their top-level domain, and finds bank card numbers; the twin does not. */
|
|
458
|
+
export function findEntities(text, skipBotCommands) {
|
|
459
|
+
const found = [];
|
|
460
|
+
const scan = (re, type, ok = () => true) => {
|
|
461
|
+
for (const m of text.matchAll(re)) {
|
|
462
|
+
const at = m.index;
|
|
463
|
+
if (!ok(m))
|
|
464
|
+
continue;
|
|
465
|
+
found.push({ type, offset: at, length: m[0].length });
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
scan(/@[A-Za-z0-9_]{2,32}/g, 'mention', (m) => !isWord(text[m.index - 1]) && !isWord(text[m.index + m[0].length]) && (m[0].length - 1 >= 4 || ['gif', 'nft', 'pic', 'ufc', 'vid'].includes(m[0].slice(1).toLowerCase())));
|
|
469
|
+
const commandEdge = (ch) => ch !== undefined && (isWord(ch) || '/<>'.includes(ch));
|
|
470
|
+
if (!skipBotCommands)
|
|
471
|
+
scan(/\/[A-Za-z0-9_]{1,64}(?:@[A-Za-z0-9_]{3,32})?/g, 'bot_command', (m) => !commandEdge(text[m.index - 1]) && !commandEdge(text[m.index + m[0].length]));
|
|
472
|
+
scan(/#[\p{L}\p{N}_]{1,256}/gu, 'hashtag', (m) => !isWord(text[m.index - 1]) && /\p{L}/u.test(m[0]) && text[m.index + m[0].length] !== '#');
|
|
473
|
+
scan(/\$[A-Z]{1,8}/g, 'cashtag', (m) => !isWord(text[m.index - 1]) && text[m.index - 1] !== '$' && !isWord(text[m.index + m[0].length]));
|
|
474
|
+
scan(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, 'email');
|
|
475
|
+
scan(/\b(?:https?:\/\/|www\.)[^\s<>"]+/gi, 'url', (m) => !found.some((e) => e.type === 'email' && m.index < e.offset + e.length && e.offset < m.index + m[0].length));
|
|
476
|
+
// a link ends before trailing punctuation
|
|
477
|
+
for (const e of found)
|
|
478
|
+
if (e.type === 'url')
|
|
479
|
+
while (e.length > 1 && /[.,;:!?)\]'"]/.test(text[e.offset + e.length - 1]))
|
|
480
|
+
e.length--;
|
|
481
|
+
return found;
|
|
482
|
+
}
|
|
483
|
+
/** What the server keeps of a formatted text (TDLib's fix_formatted_text): whitespace trimmed from both ends (the
|
|
484
|
+
* entities moved and cut with it), the entities it finds itself added where they cross none given, sorted. Empty
|
|
485
|
+
* is the caller's to refuse. */
|
|
486
|
+
export function finish(f, opts) {
|
|
487
|
+
let text = f.text;
|
|
488
|
+
let entities = f.entities.map((e) => ({ ...e }));
|
|
489
|
+
// rtrim: the text ends at its last character that is not white space, every entity cut there
|
|
490
|
+
const lastNonSpace = text.search(/\S\s*$/);
|
|
491
|
+
if (lastNonSpace < 0)
|
|
492
|
+
return { text: '', entities: [] };
|
|
493
|
+
text = text.slice(0, lastNonSpace + 1);
|
|
494
|
+
entities = entities.filter((e) => e.offset < text.length).map((e) => ({ ...e, length: Math.min(e.length, text.length - e.offset) }));
|
|
495
|
+
// ltrim: spaces and newlines before the first entity
|
|
496
|
+
const firstEntity = entities.length ? Math.min(...entities.map((e) => e.offset)) : text.length;
|
|
497
|
+
let lead = 0;
|
|
498
|
+
while (lead < firstEntity && (text[lead] === ' ' || text[lead] === '\n'))
|
|
499
|
+
lead++;
|
|
500
|
+
if (lead) {
|
|
501
|
+
text = text.slice(lead);
|
|
502
|
+
entities = entities.map((e) => ({ ...e, offset: e.offset - lead }));
|
|
503
|
+
}
|
|
504
|
+
if (opts.find) {
|
|
505
|
+
for (const e of findEntities(text, opts.skipBotCommands)) {
|
|
506
|
+
const crosses = entities.some((g) => e.offset < g.offset + g.length && g.offset < e.offset + e.length && (['code', 'pre', 'text_link', 'text_mention', 'custom_emoji', 'date_time', 'url', 'email', 'mention', 'bot_command', 'hashtag', 'cashtag'].includes(g.type) || e.offset < g.offset || e.offset + e.length > g.offset + g.length));
|
|
507
|
+
if (!crosses)
|
|
508
|
+
entities.push(e);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return { text, entities: sortEntities(entities) };
|
|
512
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts (--init, --index): a fixed file. The kernel serves the discovery door, the clock,
|
|
2
|
+
// the World's doors, the vendor's screens and the API from the pack's parts (architecture, "The pack contract").
|
|
3
|
+
import { createPackFetch } from '@volter/world-core';
|
|
4
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
5
|
+
import { manifest } from "./manifest.js";
|
|
6
|
+
import { around } from "./semantics/around.js";
|
|
7
|
+
import * as doors from "./semantics/doors.js";
|
|
8
|
+
import { telegramSemantics } from "./semantics/index.js";
|
|
9
|
+
export function createTelegramFetch(options = {}) {
|
|
10
|
+
return createPackFetch({
|
|
11
|
+
manifest,
|
|
12
|
+
surface: surface,
|
|
13
|
+
handlers: telegramSemantics,
|
|
14
|
+
doors,
|
|
15
|
+
around,
|
|
16
|
+
}, options);
|
|
17
|
+
}
|