@llblab/pi-telegram 0.2.7 → 0.2.9
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/AGENTS.md +2 -1
- package/CHANGELOG.md +5 -0
- package/README.md +36 -120
- package/docs/architecture.md +13 -6
- package/index.ts +13 -12
- package/lib/polling.ts +2 -0
- package/lib/preview.ts +212 -0
- package/lib/rendering.ts +616 -59
- package/lib/replies.ts +2 -181
- package/lib/updates.ts +0 -8
- package/package.json +1 -1
- package/tests/menu.test.ts +46 -15
- package/tests/preview.test.ts +441 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +167 -0
- package/tests/replies.test.ts +2 -222
- package/tests/updates.test.ts +15 -24
package/lib/rendering.ts
CHANGED
|
@@ -46,11 +46,220 @@ function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
|
46
46
|
return parts.length > 0 ? parts : [line];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
interface ParsedMarkdownInlineLink {
|
|
50
|
+
startIndex: number;
|
|
51
|
+
endIndex: number;
|
|
52
|
+
label: string;
|
|
53
|
+
destination: string;
|
|
54
|
+
isImage: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ParsedMarkdownAutolink {
|
|
58
|
+
startIndex: number;
|
|
59
|
+
endIndex: number;
|
|
60
|
+
destination: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isEscapedMarkdownCharacter(text: string, index: number): boolean {
|
|
64
|
+
let backslashCount = 0;
|
|
65
|
+
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
66
|
+
backslashCount += 1;
|
|
67
|
+
}
|
|
68
|
+
return backslashCount % 2 === 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findMarkdownClosingBracket(
|
|
72
|
+
text: string,
|
|
73
|
+
startIndex: number,
|
|
74
|
+
): number | undefined {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
for (let index = startIndex; index < text.length; index += 1) {
|
|
77
|
+
if (isEscapedMarkdownCharacter(text, index)) continue;
|
|
78
|
+
const char = text[index] ?? "";
|
|
79
|
+
if (char === "[") {
|
|
80
|
+
depth += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (char !== "]") continue;
|
|
84
|
+
depth -= 1;
|
|
85
|
+
if (depth === 0) return index;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseMarkdownLinkTarget(
|
|
91
|
+
text: string,
|
|
92
|
+
openParenIndex: number,
|
|
93
|
+
): { destination: string; endIndex: number } | undefined {
|
|
94
|
+
let index = openParenIndex + 1;
|
|
95
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
96
|
+
index += 1;
|
|
97
|
+
}
|
|
98
|
+
if (index >= text.length) return undefined;
|
|
99
|
+
let destination = "";
|
|
100
|
+
if (text[index] === "<") {
|
|
101
|
+
const destinationStart = index + 1;
|
|
102
|
+
index += 1;
|
|
103
|
+
while (index < text.length) {
|
|
104
|
+
if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
index += 1;
|
|
108
|
+
}
|
|
109
|
+
if (index >= text.length) return undefined;
|
|
110
|
+
destination = text.slice(destinationStart, index).trim();
|
|
111
|
+
index += 1;
|
|
112
|
+
} else {
|
|
113
|
+
const destinationStart = index;
|
|
114
|
+
let parenDepth = 0;
|
|
115
|
+
while (index < text.length) {
|
|
116
|
+
if (isEscapedMarkdownCharacter(text, index)) {
|
|
117
|
+
index += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const char = text[index] ?? "";
|
|
121
|
+
if (/\s/.test(char) && parenDepth === 0) break;
|
|
122
|
+
if (char === "(") {
|
|
123
|
+
parenDepth += 1;
|
|
124
|
+
index += 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (char === ")") {
|
|
128
|
+
if (parenDepth === 0) break;
|
|
129
|
+
parenDepth -= 1;
|
|
130
|
+
index += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
index += 1;
|
|
134
|
+
}
|
|
135
|
+
destination = text.slice(destinationStart, index).trim();
|
|
136
|
+
}
|
|
137
|
+
if (!destination) return undefined;
|
|
138
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
139
|
+
index += 1;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
index < text.length &&
|
|
143
|
+
(text[index] === '"' || text[index] === "'" || text[index] === "(")
|
|
144
|
+
) {
|
|
145
|
+
const titleDelimiter = text[index] ?? '"';
|
|
146
|
+
const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
|
|
147
|
+
index += 1;
|
|
148
|
+
while (index < text.length) {
|
|
149
|
+
if (
|
|
150
|
+
!isEscapedMarkdownCharacter(text, index) &&
|
|
151
|
+
text[index] === closingTitleDelimiter
|
|
152
|
+
) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
index += 1;
|
|
156
|
+
}
|
|
157
|
+
if (index >= text.length) return undefined;
|
|
158
|
+
index += 1;
|
|
159
|
+
while (index < text.length && /\s/.test(text[index] ?? "")) {
|
|
160
|
+
index += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (text[index] !== ")") return undefined;
|
|
164
|
+
return { destination, endIndex: index };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isSupportedMarkdownLinkDestination(destination: string): boolean {
|
|
168
|
+
return /^(?:https?:\/\/|mailto:)/i.test(destination.trim());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseMarkdownInlineLinkAt(
|
|
172
|
+
text: string,
|
|
173
|
+
index: number,
|
|
174
|
+
): ParsedMarkdownInlineLink | undefined {
|
|
175
|
+
const isImage = text[index] === "!" && text[index + 1] === "[";
|
|
176
|
+
const labelStartIndex = isImage ? index + 1 : index;
|
|
177
|
+
if (text[labelStartIndex] !== "[") return undefined;
|
|
178
|
+
if (
|
|
179
|
+
isEscapedMarkdownCharacter(text, labelStartIndex) ||
|
|
180
|
+
(isImage && isEscapedMarkdownCharacter(text, index))
|
|
181
|
+
) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const labelEndIndex = findMarkdownClosingBracket(text, labelStartIndex);
|
|
185
|
+
if (labelEndIndex === undefined || text[labelEndIndex + 1] !== "(") {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const target = parseMarkdownLinkTarget(text, labelEndIndex + 1);
|
|
189
|
+
if (!target) return undefined;
|
|
190
|
+
return {
|
|
191
|
+
startIndex: index,
|
|
192
|
+
endIndex: target.endIndex,
|
|
193
|
+
label: text.slice(labelStartIndex + 1, labelEndIndex),
|
|
194
|
+
destination: target.destination,
|
|
195
|
+
isImage,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseMarkdownAutolinkAt(
|
|
200
|
+
text: string,
|
|
201
|
+
index: number,
|
|
202
|
+
): ParsedMarkdownAutolink | undefined {
|
|
203
|
+
if (text[index] !== "<" || isEscapedMarkdownCharacter(text, index)) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
let endIndex = index + 1;
|
|
207
|
+
while (endIndex < text.length) {
|
|
208
|
+
if (!isEscapedMarkdownCharacter(text, endIndex) && text[endIndex] === ">") {
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
endIndex += 1;
|
|
212
|
+
}
|
|
213
|
+
if (endIndex >= text.length) return undefined;
|
|
214
|
+
const destination = text.slice(index + 1, endIndex).trim();
|
|
215
|
+
if (!isSupportedMarkdownLinkDestination(destination)) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
return { startIndex: index, endIndex, destination };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function replaceMarkdownLinkLike(
|
|
222
|
+
text: string,
|
|
223
|
+
options: {
|
|
224
|
+
renderInlineLink: (
|
|
225
|
+
link: ParsedMarkdownInlineLink,
|
|
226
|
+
supported: boolean,
|
|
227
|
+
) => string;
|
|
228
|
+
renderAutolink: (link: ParsedMarkdownAutolink) => string;
|
|
229
|
+
},
|
|
230
|
+
): string {
|
|
231
|
+
let result = "";
|
|
232
|
+
for (let index = 0; index < text.length; ) {
|
|
233
|
+
const inlineLink = parseMarkdownInlineLinkAt(text, index);
|
|
234
|
+
if (inlineLink) {
|
|
235
|
+
result += options.renderInlineLink(
|
|
236
|
+
inlineLink,
|
|
237
|
+
isSupportedMarkdownLinkDestination(inlineLink.destination),
|
|
238
|
+
);
|
|
239
|
+
index = inlineLink.endIndex + 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const autolink = parseMarkdownAutolinkAt(text, index);
|
|
243
|
+
if (autolink) {
|
|
244
|
+
result += options.renderAutolink(autolink);
|
|
245
|
+
index = autolink.endIndex + 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
result += text[index] ?? "";
|
|
249
|
+
index += 1;
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
49
254
|
function stripInlineMarkdownToPlainText(text: string): string {
|
|
50
|
-
let result = text
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
255
|
+
let result = replaceMarkdownLinkLike(text, {
|
|
256
|
+
renderInlineLink: (link, supported) => {
|
|
257
|
+
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
258
|
+
if (plainLabel.length > 0) return plainLabel;
|
|
259
|
+
return supported ? link.destination : "";
|
|
260
|
+
},
|
|
261
|
+
renderAutolink: (link) => link.destination,
|
|
262
|
+
});
|
|
54
263
|
result = result.replace(/`([^`\n]+)`/g, "$1");
|
|
55
264
|
result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
|
|
56
265
|
result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
|
|
@@ -129,8 +338,301 @@ function stripIndentedCodePrefix(line: string): string {
|
|
|
129
338
|
return line;
|
|
130
339
|
}
|
|
131
340
|
|
|
341
|
+
function normalizeMarkdownDocument(markdown: string): string {
|
|
342
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
343
|
+
let start = 0;
|
|
344
|
+
while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
|
|
345
|
+
start += 1;
|
|
346
|
+
}
|
|
347
|
+
let end = lines.length;
|
|
348
|
+
while (end > start && (lines[end - 1] ?? "").trim().length === 0) {
|
|
349
|
+
end -= 1;
|
|
350
|
+
}
|
|
351
|
+
return lines.slice(start, end).join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isMarkdownNumberedListMarker(marker: string): boolean {
|
|
355
|
+
return /^\d+\.$/.test(marker);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function matchMarkdownHeadingLine(line: string): RegExpMatchArray | null {
|
|
359
|
+
return line.match(/^(\s*)#{1,6}\s+(.+)$/);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function endsWithMarkdownHeadingLine(markdown: string): boolean {
|
|
363
|
+
const lines = markdown.split("\n");
|
|
364
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
365
|
+
const line = lines[index] ?? "";
|
|
366
|
+
if (line.trim().length === 0) continue;
|
|
367
|
+
return matchMarkdownHeadingLine(line) !== null;
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function splitLeadingMarkdownBlankLines(markdown: string): {
|
|
373
|
+
blankLines: number;
|
|
374
|
+
remainingText: string;
|
|
375
|
+
} {
|
|
376
|
+
const lines = markdown.split("\n");
|
|
377
|
+
let start = 0;
|
|
378
|
+
while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
|
|
379
|
+
start += 1;
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
blankLines: start,
|
|
383
|
+
remainingText: lines.slice(start).join("\n"),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
|
|
388
|
+
|
|
389
|
+
export interface TelegramPreviewSnapshotStateLike {
|
|
390
|
+
pendingText: string;
|
|
391
|
+
lastSentText: string;
|
|
392
|
+
lastSentParseMode?: "HTML";
|
|
393
|
+
lastSentStrategy?: TelegramPreviewRenderStrategy;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
|
|
397
|
+
sourceText: string;
|
|
398
|
+
strategy: TelegramPreviewRenderStrategy;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function buildTelegramPreviewFlushText(options: {
|
|
402
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
403
|
+
maxMessageLength: number;
|
|
404
|
+
renderPreviewText: (markdown: string) => string;
|
|
405
|
+
}): string | undefined {
|
|
406
|
+
const rawText = options.state.pendingText.trim();
|
|
407
|
+
const previewText = options.renderPreviewText(rawText).trim();
|
|
408
|
+
if (!previewText || previewText === options.state.lastSentText) {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
return previewText.length > options.maxMessageLength
|
|
412
|
+
? previewText.slice(0, options.maxMessageLength)
|
|
413
|
+
: previewText;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function buildTelegramPlainPreviewSnapshot(options: {
|
|
417
|
+
sourceText: string;
|
|
418
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
419
|
+
maxMessageLength: number;
|
|
420
|
+
renderPreviewText: (markdown: string) => string;
|
|
421
|
+
}): TelegramPreviewSnapshot | undefined {
|
|
422
|
+
const previewText = options.renderPreviewText(options.sourceText).trim();
|
|
423
|
+
if (!previewText) return undefined;
|
|
424
|
+
const truncatedPreviewText =
|
|
425
|
+
previewText.length > options.maxMessageLength
|
|
426
|
+
? previewText.slice(0, options.maxMessageLength)
|
|
427
|
+
: previewText;
|
|
428
|
+
if (
|
|
429
|
+
truncatedPreviewText === options.state.lastSentText &&
|
|
430
|
+
options.state.lastSentStrategy === "plain"
|
|
431
|
+
) {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
text: truncatedPreviewText,
|
|
436
|
+
sourceText: options.sourceText,
|
|
437
|
+
strategy: "plain",
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
interface TelegramStablePreviewSplit {
|
|
442
|
+
stableMarkdown: string;
|
|
443
|
+
unstableTail: string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function splitTelegramStablePreviewMarkdown(
|
|
447
|
+
markdown: string,
|
|
448
|
+
): TelegramStablePreviewSplit {
|
|
449
|
+
const normalized = normalizeMarkdownDocument(markdown);
|
|
450
|
+
if (normalized.length === 0) {
|
|
451
|
+
return { stableMarkdown: "", unstableTail: "" };
|
|
452
|
+
}
|
|
453
|
+
const lines = normalized.split("\n");
|
|
454
|
+
let index = 0;
|
|
455
|
+
let stableEndIndex = 0;
|
|
456
|
+
while (index < lines.length) {
|
|
457
|
+
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
|
458
|
+
index += 1;
|
|
459
|
+
}
|
|
460
|
+
if (index >= lines.length) break;
|
|
461
|
+
const blockStart = index;
|
|
462
|
+
const line = lines[index] ?? "";
|
|
463
|
+
const nextLine = lines[index + 1] ?? "";
|
|
464
|
+
const fence = parseMarkdownFence(line);
|
|
465
|
+
if (fence) {
|
|
466
|
+
index += 1;
|
|
467
|
+
while (
|
|
468
|
+
index < lines.length &&
|
|
469
|
+
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
470
|
+
) {
|
|
471
|
+
index += 1;
|
|
472
|
+
}
|
|
473
|
+
if (index >= lines.length) {
|
|
474
|
+
return {
|
|
475
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
476
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
index += 1;
|
|
480
|
+
stableEndIndex = index;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
484
|
+
index += 2;
|
|
485
|
+
while (index < lines.length) {
|
|
486
|
+
const tableLine = lines[index] ?? "";
|
|
487
|
+
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
index += 1;
|
|
491
|
+
}
|
|
492
|
+
if (index >= lines.length) {
|
|
493
|
+
return {
|
|
494
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
495
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
stableEndIndex = index;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (canStartIndentedCodeBlock(lines, index)) {
|
|
502
|
+
while (index < lines.length) {
|
|
503
|
+
const rawLine = lines[index] ?? "";
|
|
504
|
+
if (rawLine.trim().length === 0) {
|
|
505
|
+
index += 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!isIndentedCodeLine(rawLine)) break;
|
|
509
|
+
index += 1;
|
|
510
|
+
}
|
|
511
|
+
if (index >= lines.length) {
|
|
512
|
+
return {
|
|
513
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
514
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
stableEndIndex = index;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (/^\s*>/.test(line)) {
|
|
521
|
+
while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
|
|
522
|
+
index += 1;
|
|
523
|
+
}
|
|
524
|
+
if (index >= lines.length) {
|
|
525
|
+
return {
|
|
526
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
527
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
stableEndIndex = index;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
while (index < lines.length) {
|
|
534
|
+
const current = lines[index] ?? "";
|
|
535
|
+
const following = lines[index + 1] ?? "";
|
|
536
|
+
if (current.trim().length === 0) break;
|
|
537
|
+
if (
|
|
538
|
+
index !== blockStart &&
|
|
539
|
+
(isFencedCodeStart(current) ||
|
|
540
|
+
canStartIndentedCodeBlock(lines, index) ||
|
|
541
|
+
/^\s*>/.test(current) ||
|
|
542
|
+
(current.includes("|") && isMarkdownTableSeparator(following)))
|
|
543
|
+
) {
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
index += 1;
|
|
547
|
+
}
|
|
548
|
+
if (index >= lines.length) {
|
|
549
|
+
return {
|
|
550
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
551
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
stableEndIndex = index;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
|
|
558
|
+
unstableTail: lines.slice(stableEndIndex).join("\n"),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function buildTelegramPreviewSnapshot(options: {
|
|
563
|
+
state: TelegramPreviewSnapshotStateLike;
|
|
564
|
+
maxMessageLength: number;
|
|
565
|
+
renderPreviewText: (markdown: string) => string;
|
|
566
|
+
renderTelegramMessage: (
|
|
567
|
+
text: string,
|
|
568
|
+
options?: { mode?: TelegramRenderMode },
|
|
569
|
+
) => TelegramRenderedChunk[];
|
|
570
|
+
}): TelegramPreviewSnapshot | undefined {
|
|
571
|
+
const sourceText = options.state.pendingText.trim();
|
|
572
|
+
if (!sourceText) return undefined;
|
|
573
|
+
const split = splitTelegramStablePreviewMarkdown(sourceText);
|
|
574
|
+
if (split.stableMarkdown.length === 0) {
|
|
575
|
+
return buildTelegramPlainPreviewSnapshot({
|
|
576
|
+
sourceText,
|
|
577
|
+
state: options.state,
|
|
578
|
+
maxMessageLength: options.maxMessageLength,
|
|
579
|
+
renderPreviewText: options.renderPreviewText,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const stableChunk = options.renderTelegramMessage(split.stableMarkdown, {
|
|
583
|
+
mode: "markdown",
|
|
584
|
+
})[0];
|
|
585
|
+
if (
|
|
586
|
+
!stableChunk ||
|
|
587
|
+
stableChunk.text.length === 0 ||
|
|
588
|
+
stableChunk.text.length > options.maxMessageLength
|
|
589
|
+
) {
|
|
590
|
+
return buildTelegramPlainPreviewSnapshot({
|
|
591
|
+
sourceText,
|
|
592
|
+
state: options.state,
|
|
593
|
+
maxMessageLength: options.maxMessageLength,
|
|
594
|
+
renderPreviewText: options.renderPreviewText,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
let previewText = stableChunk.text;
|
|
598
|
+
if (split.unstableTail.length > 0) {
|
|
599
|
+
const tail = splitLeadingMarkdownBlankLines(split.unstableTail);
|
|
600
|
+
const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
|
|
601
|
+
split.stableMarkdown,
|
|
602
|
+
)
|
|
603
|
+
? 1
|
|
604
|
+
: 0;
|
|
605
|
+
const blankLinesBeforeTail = Math.max(
|
|
606
|
+
tail.blankLines,
|
|
607
|
+
minimumBlankLinesBeforeTail,
|
|
608
|
+
);
|
|
609
|
+
const separator =
|
|
610
|
+
tail.remainingText.length > 0
|
|
611
|
+
? "\n".repeat(blankLinesBeforeTail + 1)
|
|
612
|
+
: "";
|
|
613
|
+
const tailText = escapeHtml(tail.remainingText);
|
|
614
|
+
const candidate = `${previewText}${separator}${tailText}`;
|
|
615
|
+
if (candidate.length <= options.maxMessageLength) {
|
|
616
|
+
previewText = candidate;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (
|
|
620
|
+
previewText === options.state.lastSentText &&
|
|
621
|
+
stableChunk.parseMode === options.state.lastSentParseMode &&
|
|
622
|
+
options.state.lastSentStrategy === "rich-stable-blocks"
|
|
623
|
+
) {
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
text: previewText,
|
|
628
|
+
parseMode: stableChunk.parseMode,
|
|
629
|
+
sourceText,
|
|
630
|
+
strategy: "rich-stable-blocks",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
132
634
|
export function renderMarkdownPreviewText(markdown: string): string {
|
|
133
|
-
const normalized = markdown
|
|
635
|
+
const normalized = normalizeMarkdownDocument(markdown);
|
|
134
636
|
if (normalized.length === 0) return "";
|
|
135
637
|
const output: string[] = [];
|
|
136
638
|
const lines = normalized.split("\n");
|
|
@@ -144,7 +646,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
144
646
|
continue;
|
|
145
647
|
}
|
|
146
648
|
if (line.trim().length === 0) {
|
|
147
|
-
|
|
649
|
+
output.push("");
|
|
148
650
|
continue;
|
|
149
651
|
}
|
|
150
652
|
output.push(line);
|
|
@@ -155,23 +657,28 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
155
657
|
continue;
|
|
156
658
|
}
|
|
157
659
|
if (line.trim().length === 0) {
|
|
158
|
-
|
|
660
|
+
output.push("");
|
|
159
661
|
continue;
|
|
160
662
|
}
|
|
161
663
|
if (isMarkdownTableSeparator(line)) {
|
|
162
664
|
continue;
|
|
163
665
|
}
|
|
164
|
-
const heading = line
|
|
666
|
+
const heading = matchMarkdownHeadingLine(line);
|
|
165
667
|
if (heading) {
|
|
166
|
-
output.push(stripInlineMarkdownToPlainText(heading[
|
|
668
|
+
output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
|
|
167
669
|
continue;
|
|
168
670
|
}
|
|
169
671
|
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
170
672
|
if (task) {
|
|
171
673
|
const indent = " ".repeat((task[1] ?? "").length);
|
|
172
|
-
const
|
|
674
|
+
const listMarker = task[2] ?? "-";
|
|
675
|
+
const checkboxMarker =
|
|
676
|
+
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
677
|
+
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
678
|
+
? `${listMarker} ${checkboxMarker}`
|
|
679
|
+
: checkboxMarker;
|
|
173
680
|
output.push(
|
|
174
|
-
`${indent}${
|
|
681
|
+
`${indent}${taskPrefix} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
|
|
175
682
|
);
|
|
176
683
|
continue;
|
|
177
684
|
}
|
|
@@ -230,26 +737,24 @@ function renderInlineMarkdown(text: string): string {
|
|
|
230
737
|
tokens.push(html);
|
|
231
738
|
return token;
|
|
232
739
|
};
|
|
233
|
-
let result = text
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
|
740
|
+
let result = replaceMarkdownLinkLike(text, {
|
|
741
|
+
renderInlineLink: (link, supported) => {
|
|
742
|
+
const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
|
|
743
|
+
if (!supported) {
|
|
744
|
+
return plainLabel.length > 0 ? plainLabel : link.destination;
|
|
745
|
+
}
|
|
746
|
+
const renderedLabel =
|
|
747
|
+
plainLabel.length > 0 ? plainLabel : link.destination;
|
|
748
|
+
return makeToken(
|
|
749
|
+
`<a href="${escapeHtml(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
|
|
750
|
+
);
|
|
245
751
|
},
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
|
|
752
|
+
renderAutolink: (link) => {
|
|
753
|
+
return makeToken(
|
|
754
|
+
`<a href="${escapeHtml(link.destination)}">${escapeHtml(link.destination)}</a>`,
|
|
755
|
+
);
|
|
251
756
|
},
|
|
252
|
-
);
|
|
757
|
+
});
|
|
253
758
|
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
254
759
|
return makeToken(`<code>${escapeHtml(code)}</code>`);
|
|
255
760
|
});
|
|
@@ -275,13 +780,6 @@ function renderInlineMarkdown(text: string): string {
|
|
|
275
780
|
result = renderDelimitedInlineStyle(result, "_", (content) => {
|
|
276
781
|
return `<i>${content}</i>`;
|
|
277
782
|
});
|
|
278
|
-
result = result.replace(
|
|
279
|
-
/(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g,
|
|
280
|
-
(_match, prefix: string, checkbox: string) => {
|
|
281
|
-
const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]";
|
|
282
|
-
return `${prefix}<code>${normalized}</code>`;
|
|
283
|
-
},
|
|
284
|
-
);
|
|
285
783
|
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
|
286
784
|
return result.replace(
|
|
287
785
|
/\uE000(\d+)\uE001/g,
|
|
@@ -320,7 +818,7 @@ function renderMarkdownTextLines(block: string): string[] {
|
|
|
320
818
|
if (line.trim().length === 0) continue;
|
|
321
819
|
const pieces = splitPlainMarkdownLine(line);
|
|
322
820
|
for (const piece of pieces) {
|
|
323
|
-
const heading = piece
|
|
821
|
+
const heading = matchMarkdownHeadingLine(piece);
|
|
324
822
|
if (heading) {
|
|
325
823
|
rendered.push(
|
|
326
824
|
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
@@ -330,9 +828,14 @@ function renderMarkdownTextLines(block: string): string[] {
|
|
|
330
828
|
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
331
829
|
if (task) {
|
|
332
830
|
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
|
333
|
-
const
|
|
831
|
+
const listMarker = task[2] ?? "-";
|
|
832
|
+
const checkboxMarker =
|
|
833
|
+
(task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
834
|
+
const taskPrefix = isMarkdownNumberedListMarker(listMarker)
|
|
835
|
+
? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
|
|
836
|
+
: `<code>${checkboxMarker}</code>`;
|
|
334
837
|
rendered.push(
|
|
335
|
-
`${indent}
|
|
838
|
+
`${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`,
|
|
336
839
|
);
|
|
337
840
|
continue;
|
|
338
841
|
}
|
|
@@ -497,15 +1000,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
|
|
|
497
1000
|
});
|
|
498
1001
|
}
|
|
499
1002
|
|
|
1003
|
+
interface TelegramRenderedBlockWithSpacing {
|
|
1004
|
+
text: string;
|
|
1005
|
+
blankLinesBefore: number;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
500
1008
|
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
501
|
-
const normalized = markdown
|
|
1009
|
+
const normalized = normalizeMarkdownDocument(markdown);
|
|
502
1010
|
if (normalized.length === 0) return [];
|
|
503
|
-
const renderedBlocks:
|
|
1011
|
+
const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
|
|
1012
|
+
let minimumBlankLinesBeforeNextBlock = 0;
|
|
1013
|
+
const pushRenderedBlocks = (
|
|
1014
|
+
blocks: string[],
|
|
1015
|
+
blankLinesBefore: number,
|
|
1016
|
+
): void => {
|
|
1017
|
+
const effectiveBlankLinesBefore =
|
|
1018
|
+
renderedBlocks.length === 0
|
|
1019
|
+
? blankLinesBefore
|
|
1020
|
+
: Math.max(blankLinesBefore, minimumBlankLinesBeforeNextBlock);
|
|
1021
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
1022
|
+
renderedBlocks.push({
|
|
1023
|
+
text: block,
|
|
1024
|
+
blankLinesBefore: blockIndex === 0 ? effectiveBlankLinesBefore : 0,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
minimumBlankLinesBeforeNextBlock = 0;
|
|
1028
|
+
};
|
|
504
1029
|
const lines = normalized.split("\n");
|
|
505
1030
|
let index = 0;
|
|
1031
|
+
let pendingBlankLines = 0;
|
|
506
1032
|
while (index < lines.length) {
|
|
507
1033
|
const line = lines[index] ?? "";
|
|
508
1034
|
const nextLine = lines[index + 1] ?? "";
|
|
1035
|
+
if (line.trim().length === 0) {
|
|
1036
|
+
pendingBlankLines += 1;
|
|
1037
|
+
index += 1;
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
const heading = matchMarkdownHeadingLine(line);
|
|
1041
|
+
if (heading) {
|
|
1042
|
+
pushRenderedBlocks(
|
|
1043
|
+
renderMarkdownTextBlock(line),
|
|
1044
|
+
renderedBlocks.length === 0
|
|
1045
|
+
? pendingBlankLines
|
|
1046
|
+
: Math.max(pendingBlankLines, 1),
|
|
1047
|
+
);
|
|
1048
|
+
pendingBlankLines = 0;
|
|
1049
|
+
minimumBlankLinesBeforeNextBlock = 1;
|
|
1050
|
+
index += 1;
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
509
1053
|
const fence = parseMarkdownFence(line);
|
|
510
1054
|
if (fence) {
|
|
511
1055
|
index += 1;
|
|
@@ -520,16 +1064,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
520
1064
|
if (index < lines.length) {
|
|
521
1065
|
index += 1;
|
|
522
1066
|
}
|
|
523
|
-
|
|
524
|
-
|
|
1067
|
+
pushRenderedBlocks(
|
|
1068
|
+
renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
1069
|
+
pendingBlankLines,
|
|
525
1070
|
);
|
|
526
|
-
|
|
527
|
-
index += 1;
|
|
528
|
-
}
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
531
|
-
if (line.trim().length === 0) {
|
|
532
|
-
index += 1;
|
|
1071
|
+
pendingBlankLines = 0;
|
|
533
1072
|
continue;
|
|
534
1073
|
}
|
|
535
1074
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
@@ -543,7 +1082,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
543
1082
|
tableLines.push(tableLine);
|
|
544
1083
|
index += 1;
|
|
545
1084
|
}
|
|
546
|
-
|
|
1085
|
+
pushRenderedBlocks(
|
|
1086
|
+
renderMarkdownTableBlock(tableLines),
|
|
1087
|
+
pendingBlankLines,
|
|
1088
|
+
);
|
|
1089
|
+
pendingBlankLines = 0;
|
|
547
1090
|
continue;
|
|
548
1091
|
}
|
|
549
1092
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
@@ -559,7 +1102,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
559
1102
|
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
560
1103
|
index += 1;
|
|
561
1104
|
}
|
|
562
|
-
|
|
1105
|
+
pushRenderedBlocks(
|
|
1106
|
+
renderMarkdownCodeBlock(codeLines.join("\n")),
|
|
1107
|
+
pendingBlankLines,
|
|
1108
|
+
);
|
|
1109
|
+
pendingBlankLines = 0;
|
|
563
1110
|
continue;
|
|
564
1111
|
}
|
|
565
1112
|
if (/^\s*>/.test(line)) {
|
|
@@ -568,7 +1115,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
568
1115
|
quoteLines.push(lines[index] ?? "");
|
|
569
1116
|
index += 1;
|
|
570
1117
|
}
|
|
571
|
-
|
|
1118
|
+
pushRenderedBlocks(
|
|
1119
|
+
renderMarkdownQuoteBlock(quoteLines),
|
|
1120
|
+
pendingBlankLines,
|
|
1121
|
+
);
|
|
1122
|
+
pendingBlankLines = 0;
|
|
572
1123
|
continue;
|
|
573
1124
|
}
|
|
574
1125
|
const textLines: string[] = [];
|
|
@@ -586,12 +1137,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
586
1137
|
textLines.push(current);
|
|
587
1138
|
index += 1;
|
|
588
1139
|
}
|
|
589
|
-
|
|
1140
|
+
pushRenderedBlocks(
|
|
1141
|
+
renderMarkdownTextBlock(textLines.join("\n")),
|
|
1142
|
+
pendingBlankLines,
|
|
1143
|
+
);
|
|
1144
|
+
pendingBlankLines = 0;
|
|
590
1145
|
}
|
|
591
1146
|
const chunks: string[] = [];
|
|
592
1147
|
let current = "";
|
|
593
1148
|
for (const block of renderedBlocks) {
|
|
594
|
-
const
|
|
1149
|
+
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1150
|
+
const candidate =
|
|
1151
|
+
current.length === 0 ? block.text : `${current}${separator}${block.text}`;
|
|
595
1152
|
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
596
1153
|
current = candidate;
|
|
597
1154
|
continue;
|
|
@@ -600,12 +1157,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
600
1157
|
chunks.push(current);
|
|
601
1158
|
current = "";
|
|
602
1159
|
}
|
|
603
|
-
if (block.length <= MAX_MESSAGE_LENGTH) {
|
|
604
|
-
current = block;
|
|
1160
|
+
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1161
|
+
current = block.text;
|
|
605
1162
|
continue;
|
|
606
1163
|
}
|
|
607
|
-
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
|
|
608
|
-
chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
1164
|
+
for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
|
|
1165
|
+
chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
609
1166
|
}
|
|
610
1167
|
}
|
|
611
1168
|
if (current.length > 0) {
|