@llblab/pi-telegram 0.2.8 → 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 +4 -0
- package/README.md +6 -6
- package/docs/architecture.md +11 -5
- package/index.ts +13 -10
- package/lib/preview.ts +212 -0
- package/lib/rendering.ts +583 -46
- package/lib/replies.ts +2 -181
- 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 +124 -2
- package/tests/replies.test.ts +2 -222
- package/tests/updates.test.ts +10 -4
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");
|
|
@@ -146,6 +355,282 @@ function isMarkdownNumberedListMarker(marker: string): boolean {
|
|
|
146
355
|
return /^\d+\.$/.test(marker);
|
|
147
356
|
}
|
|
148
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
|
+
|
|
149
634
|
export function renderMarkdownPreviewText(markdown: string): string {
|
|
150
635
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
151
636
|
if (normalized.length === 0) return "";
|
|
@@ -161,7 +646,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
161
646
|
continue;
|
|
162
647
|
}
|
|
163
648
|
if (line.trim().length === 0) {
|
|
164
|
-
|
|
649
|
+
output.push("");
|
|
165
650
|
continue;
|
|
166
651
|
}
|
|
167
652
|
output.push(line);
|
|
@@ -172,15 +657,15 @@ export function renderMarkdownPreviewText(markdown: string): string {
|
|
|
172
657
|
continue;
|
|
173
658
|
}
|
|
174
659
|
if (line.trim().length === 0) {
|
|
175
|
-
|
|
660
|
+
output.push("");
|
|
176
661
|
continue;
|
|
177
662
|
}
|
|
178
663
|
if (isMarkdownTableSeparator(line)) {
|
|
179
664
|
continue;
|
|
180
665
|
}
|
|
181
|
-
const heading = line
|
|
666
|
+
const heading = matchMarkdownHeadingLine(line);
|
|
182
667
|
if (heading) {
|
|
183
|
-
output.push(stripInlineMarkdownToPlainText(heading[
|
|
668
|
+
output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
|
|
184
669
|
continue;
|
|
185
670
|
}
|
|
186
671
|
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
@@ -252,26 +737,24 @@ function renderInlineMarkdown(text: string): string {
|
|
|
252
737
|
tokens.push(html);
|
|
253
738
|
return token;
|
|
254
739
|
};
|
|
255
|
-
let result = text
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
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
|
+
);
|
|
267
751
|
},
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
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
|
+
);
|
|
273
756
|
},
|
|
274
|
-
);
|
|
757
|
+
});
|
|
275
758
|
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
276
759
|
return makeToken(`<code>${escapeHtml(code)}</code>`);
|
|
277
760
|
});
|
|
@@ -335,7 +818,7 @@ function renderMarkdownTextLines(block: string): string[] {
|
|
|
335
818
|
if (line.trim().length === 0) continue;
|
|
336
819
|
const pieces = splitPlainMarkdownLine(line);
|
|
337
820
|
for (const piece of pieces) {
|
|
338
|
-
const heading = piece
|
|
821
|
+
const heading = matchMarkdownHeadingLine(piece);
|
|
339
822
|
if (heading) {
|
|
340
823
|
rendered.push(
|
|
341
824
|
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
@@ -517,15 +1000,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
|
|
|
517
1000
|
});
|
|
518
1001
|
}
|
|
519
1002
|
|
|
1003
|
+
interface TelegramRenderedBlockWithSpacing {
|
|
1004
|
+
text: string;
|
|
1005
|
+
blankLinesBefore: number;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
520
1008
|
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
521
1009
|
const normalized = normalizeMarkdownDocument(markdown);
|
|
522
1010
|
if (normalized.length === 0) return [];
|
|
523
|
-
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
|
+
};
|
|
524
1029
|
const lines = normalized.split("\n");
|
|
525
1030
|
let index = 0;
|
|
1031
|
+
let pendingBlankLines = 0;
|
|
526
1032
|
while (index < lines.length) {
|
|
527
1033
|
const line = lines[index] ?? "";
|
|
528
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
|
+
}
|
|
529
1053
|
const fence = parseMarkdownFence(line);
|
|
530
1054
|
if (fence) {
|
|
531
1055
|
index += 1;
|
|
@@ -540,16 +1064,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
540
1064
|
if (index < lines.length) {
|
|
541
1065
|
index += 1;
|
|
542
1066
|
}
|
|
543
|
-
|
|
544
|
-
|
|
1067
|
+
pushRenderedBlocks(
|
|
1068
|
+
renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
1069
|
+
pendingBlankLines,
|
|
545
1070
|
);
|
|
546
|
-
|
|
547
|
-
index += 1;
|
|
548
|
-
}
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
if (line.trim().length === 0) {
|
|
552
|
-
index += 1;
|
|
1071
|
+
pendingBlankLines = 0;
|
|
553
1072
|
continue;
|
|
554
1073
|
}
|
|
555
1074
|
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
@@ -563,7 +1082,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
563
1082
|
tableLines.push(tableLine);
|
|
564
1083
|
index += 1;
|
|
565
1084
|
}
|
|
566
|
-
|
|
1085
|
+
pushRenderedBlocks(
|
|
1086
|
+
renderMarkdownTableBlock(tableLines),
|
|
1087
|
+
pendingBlankLines,
|
|
1088
|
+
);
|
|
1089
|
+
pendingBlankLines = 0;
|
|
567
1090
|
continue;
|
|
568
1091
|
}
|
|
569
1092
|
if (canStartIndentedCodeBlock(lines, index)) {
|
|
@@ -579,7 +1102,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
579
1102
|
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
580
1103
|
index += 1;
|
|
581
1104
|
}
|
|
582
|
-
|
|
1105
|
+
pushRenderedBlocks(
|
|
1106
|
+
renderMarkdownCodeBlock(codeLines.join("\n")),
|
|
1107
|
+
pendingBlankLines,
|
|
1108
|
+
);
|
|
1109
|
+
pendingBlankLines = 0;
|
|
583
1110
|
continue;
|
|
584
1111
|
}
|
|
585
1112
|
if (/^\s*>/.test(line)) {
|
|
@@ -588,7 +1115,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
588
1115
|
quoteLines.push(lines[index] ?? "");
|
|
589
1116
|
index += 1;
|
|
590
1117
|
}
|
|
591
|
-
|
|
1118
|
+
pushRenderedBlocks(
|
|
1119
|
+
renderMarkdownQuoteBlock(quoteLines),
|
|
1120
|
+
pendingBlankLines,
|
|
1121
|
+
);
|
|
1122
|
+
pendingBlankLines = 0;
|
|
592
1123
|
continue;
|
|
593
1124
|
}
|
|
594
1125
|
const textLines: string[] = [];
|
|
@@ -606,12 +1137,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
606
1137
|
textLines.push(current);
|
|
607
1138
|
index += 1;
|
|
608
1139
|
}
|
|
609
|
-
|
|
1140
|
+
pushRenderedBlocks(
|
|
1141
|
+
renderMarkdownTextBlock(textLines.join("\n")),
|
|
1142
|
+
pendingBlankLines,
|
|
1143
|
+
);
|
|
1144
|
+
pendingBlankLines = 0;
|
|
610
1145
|
}
|
|
611
1146
|
const chunks: string[] = [];
|
|
612
1147
|
let current = "";
|
|
613
1148
|
for (const block of renderedBlocks) {
|
|
614
|
-
const
|
|
1149
|
+
const separator = "\n".repeat(block.blankLinesBefore + 1);
|
|
1150
|
+
const candidate =
|
|
1151
|
+
current.length === 0 ? block.text : `${current}${separator}${block.text}`;
|
|
615
1152
|
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
616
1153
|
current = candidate;
|
|
617
1154
|
continue;
|
|
@@ -620,12 +1157,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
|
620
1157
|
chunks.push(current);
|
|
621
1158
|
current = "";
|
|
622
1159
|
}
|
|
623
|
-
if (block.length <= MAX_MESSAGE_LENGTH) {
|
|
624
|
-
current = block;
|
|
1160
|
+
if (block.text.length <= MAX_MESSAGE_LENGTH) {
|
|
1161
|
+
current = block.text;
|
|
625
1162
|
continue;
|
|
626
1163
|
}
|
|
627
|
-
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
|
|
628
|
-
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));
|
|
629
1166
|
}
|
|
630
1167
|
}
|
|
631
1168
|
if (current.length > 0) {
|