@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -0
  3. package/dist/generated/pack-facts.json +53 -0
  4. package/dist/journeys/coverage.json +138 -0
  5. package/dist/journeys/customer-life.json +1229 -0
  6. package/dist/journeys/customer-life.outline.md +99 -0
  7. package/dist/journeys/customer-life.outline.verdict.json +76 -0
  8. package/dist/journeys/customer-life.verdict.json +134 -0
  9. package/dist/journeys/decisions.json +95 -0
  10. package/dist/journeys/demand.json +152 -0
  11. package/dist/journeys/score.json +163 -0
  12. package/dist/journeys/unreachable.json +29 -0
  13. package/dist/journeys/vendor-examples.json +1554 -0
  14. package/dist/journeys/vendor-examples.verdict.json +14 -0
  15. package/dist/spec/SOURCE.md +51 -0
  16. package/dist/spec/bot-api.html.gz +0 -0
  17. package/dist/spec/doc-examples.json +344 -0
  18. package/dist/spec/grouping.json +1 -0
  19. package/dist/spec/openapi.json.gz +0 -0
  20. package/dist/spec/page-to-openapi.ts +258 -0
  21. package/dist/spec/patches.json +289 -0
  22. package/dist/spec/sources.json +416 -0
  23. package/dist/src/cli.d.ts +2 -0
  24. package/dist/src/cli.js +21 -0
  25. package/dist/src/engine/media.d.ts +17 -0
  26. package/dist/src/engine/media.js +75 -0
  27. package/dist/src/engine/text.d.ts +45 -0
  28. package/dist/src/engine/text.js +512 -0
  29. package/dist/src/fetch.d.ts +2 -0
  30. package/dist/src/fetch.js +17 -0
  31. package/dist/src/generated/surface.gen.json +1 -0
  32. package/dist/src/generated/ui.gen.json +1 -0
  33. package/dist/src/index.d.ts +5 -0
  34. package/dist/src/index.js +9 -0
  35. package/dist/src/manifest.d.ts +2 -0
  36. package/dist/src/manifest.js +104 -0
  37. package/dist/src/semantics/around.d.ts +2 -0
  38. package/dist/src/semantics/around.js +32 -0
  39. package/dist/src/semantics/available-methods.d.ts +12 -0
  40. package/dist/src/semantics/available-methods.js +266 -0
  41. package/dist/src/semantics/doors.d.ts +18 -0
  42. package/dist/src/semantics/doors.js +292 -0
  43. package/dist/src/semantics/getting-updates.d.ts +3 -0
  44. package/dist/src/semantics/getting-updates.js +68 -0
  45. package/dist/src/semantics/index.d.ts +2 -0
  46. package/dist/src/semantics/index.js +19 -0
  47. package/dist/src/semantics/shared.d.ts +112 -0
  48. package/dist/src/semantics/shared.js +424 -0
  49. package/dist/src/semantics/states.d.ts +18 -0
  50. package/dist/src/semantics/states.js +37 -0
  51. package/dist/src/semantics/updating-messages.d.ts +2 -0
  52. package/dist/src/semantics/updating-messages.js +48 -0
  53. package/dist/src/server.d.ts +7 -0
  54. package/dist/src/server.js +24 -0
  55. package/generated/pack-facts.json +53 -0
  56. package/journeys/coverage.json +138 -0
  57. package/journeys/customer-life.json +1229 -0
  58. package/journeys/customer-life.outline.md +99 -0
  59. package/journeys/customer-life.outline.verdict.json +76 -0
  60. package/journeys/customer-life.verdict.json +134 -0
  61. package/journeys/decisions.json +95 -0
  62. package/journeys/demand.json +152 -0
  63. package/journeys/score.json +163 -0
  64. package/journeys/unreachable.json +29 -0
  65. package/journeys/vendor-examples.json +1554 -0
  66. package/journeys/vendor-examples.verdict.json +14 -0
  67. package/package.json +37 -0
  68. package/spec/SOURCE.md +51 -0
  69. package/spec/bot-api.html.gz +0 -0
  70. package/spec/doc-examples.json +344 -0
  71. package/spec/grouping.json +1 -0
  72. package/spec/openapi.json.gz +0 -0
  73. package/spec/page-to-openapi.ts +258 -0
  74. package/spec/patches.json +289 -0
  75. package/spec/sources.json +416 -0
  76. package/src/cli.ts +22 -0
  77. package/src/engine/media.ts +67 -0
  78. package/src/engine/text.ts +409 -0
  79. package/src/fetch.ts +18 -0
  80. package/src/generated/surface.gen.json +1 -0
  81. package/src/generated/ui.gen.json +1 -0
  82. package/src/index.ts +11 -0
  83. package/src/manifest.ts +114 -0
  84. package/src/semantics/around.ts +33 -0
  85. package/src/semantics/available-methods.ts +253 -0
  86. package/src/semantics/doors.ts +277 -0
  87. package/src/semantics/getting-updates.ts +64 -0
  88. package/src/semantics/index.ts +23 -0
  89. package/src/semantics/shared.ts +428 -0
  90. package/src/semantics/states.ts +38 -0
  91. package/src/semantics/updating-messages.ts +41 -0
  92. 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,2 @@
1
+ import { type DerivedFetch, type PackFetchOptions } from '@volter/world-core';
2
+ export declare function createTelegramFetch(options?: PackFetchOptions): DerivedFetch;
@@ -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
+ }