@llblab/pi-telegram 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +90 -0
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +17 -0
- package/README.md +202 -0
- package/docs/README.md +9 -0
- package/docs/architecture.md +148 -0
- package/index.ts +1968 -0
- package/lib/api.ts +222 -0
- package/lib/attachments.ts +98 -0
- package/lib/media.ts +234 -0
- package/lib/menu.ts +951 -0
- package/lib/model-switch.ts +62 -0
- package/lib/polling.ts +122 -0
- package/lib/queue.ts +534 -0
- package/lib/registration.ts +163 -0
- package/lib/rendering.ts +697 -0
- package/lib/replies.ts +313 -0
- package/lib/setup.ts +41 -0
- package/lib/status.ts +109 -0
- package/lib/turns.ts +144 -0
- package/lib/updates.ts +397 -0
- package/package.json +40 -0
- package/screenshot.png +0 -0
- package/tests/api.test.ts +89 -0
- package/tests/attachments.test.ts +132 -0
- package/tests/config.test.ts +80 -0
- package/tests/media.test.ts +77 -0
- package/tests/menu.test.ts +645 -0
- package/tests/polling.test.ts +129 -0
- package/tests/queue.test.ts +2982 -0
- package/tests/registration.test.ts +268 -0
- package/tests/rendering.test.ts +308 -0
- package/tests/replies.test.ts +362 -0
- package/tests/turns.test.ts +132 -0
- package/tests/updates.test.ts +366 -0
package/lib/rendering.ts
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram preview and markdown rendering helpers
|
|
3
|
+
* Converts assistant output into Telegram-safe plain text and HTML chunks with chunk-boundary handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const MAX_MESSAGE_LENGTH = 4096;
|
|
7
|
+
|
|
8
|
+
// --- Escaping ---
|
|
9
|
+
|
|
10
|
+
function escapeHtml(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- Plain Preview Rendering ---
|
|
18
|
+
|
|
19
|
+
function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
|
|
20
|
+
if (line.length <= maxLength) return [line];
|
|
21
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
22
|
+
if (words.length === 0) return [line];
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
for (const word of words) {
|
|
26
|
+
const candidate = current.length === 0 ? word : `${current} ${word}`;
|
|
27
|
+
if (candidate.length <= maxLength) {
|
|
28
|
+
current = candidate;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (current.length > 0) {
|
|
32
|
+
parts.push(current);
|
|
33
|
+
current = "";
|
|
34
|
+
}
|
|
35
|
+
if (word.length <= maxLength) {
|
|
36
|
+
current = word;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < word.length; i += maxLength) {
|
|
40
|
+
parts.push(word.slice(i, i + maxLength));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current.length > 0) {
|
|
44
|
+
parts.push(current);
|
|
45
|
+
}
|
|
46
|
+
return parts.length > 0 ? parts : [line];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripInlineMarkdownToPlainText(text: string): string {
|
|
50
|
+
let result = text;
|
|
51
|
+
result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
|
|
52
|
+
result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
|
|
53
|
+
result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1");
|
|
54
|
+
result = result.replace(/`([^`\n]+)`/g, "$1");
|
|
55
|
+
result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
|
|
56
|
+
result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
|
|
57
|
+
result = result.replace(/(\*|_)(.+?)\1/g, "$2");
|
|
58
|
+
result = result.replace(/~~(.+?)~~/g, "$1");
|
|
59
|
+
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isMarkdownTableSeparator(line: string): boolean {
|
|
64
|
+
return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseMarkdownFence(
|
|
68
|
+
line: string,
|
|
69
|
+
): { marker: "`" | "~"; length: number; info?: string } | undefined {
|
|
70
|
+
const match = line.match(/^\s*([`~]{3,})(.*)$/);
|
|
71
|
+
if (!match) return undefined;
|
|
72
|
+
const fence = match[1] ?? "";
|
|
73
|
+
const marker = fence[0];
|
|
74
|
+
if ((marker !== "`" && marker !== "~") || /[^`~]/.test(fence)) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
if (!fence.split("").every((char) => char === marker)) return undefined;
|
|
78
|
+
return {
|
|
79
|
+
marker,
|
|
80
|
+
length: fence.length,
|
|
81
|
+
info: (match[2] ?? "").trim() || undefined,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isFencedCodeStart(line: string): boolean {
|
|
86
|
+
return parseMarkdownFence(line) !== undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isMatchingMarkdownFence(
|
|
90
|
+
line: string,
|
|
91
|
+
fence: { marker: "`" | "~"; length: number },
|
|
92
|
+
): boolean {
|
|
93
|
+
const match = line.match(/^\s*([`~]{3,})\s*$/);
|
|
94
|
+
if (!match) return false;
|
|
95
|
+
const candidate = match[1] ?? "";
|
|
96
|
+
return (
|
|
97
|
+
candidate.length >= fence.length &&
|
|
98
|
+
candidate[0] === fence.marker &&
|
|
99
|
+
candidate.split("").every((char) => char === fence.marker)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isIndentedCodeLine(line: string): boolean {
|
|
104
|
+
return /^(?:\t| {4,})/.test(line);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isIndentedMarkdownStructureLine(line: string): boolean {
|
|
108
|
+
const trimmed = line.trimStart();
|
|
109
|
+
return (
|
|
110
|
+
/^(?:[-*+]|\d+\.)\s+\[([ xX])\]\s+/.test(trimmed) ||
|
|
111
|
+
/^(?:[-*+]|\d+\.)\s+/.test(trimmed) ||
|
|
112
|
+
/^>\s?/.test(trimmed) ||
|
|
113
|
+
/^#{1,6}\s+/.test(trimmed) ||
|
|
114
|
+
parseMarkdownFence(trimmed) !== undefined
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function canStartIndentedCodeBlock(lines: string[], index: number): boolean {
|
|
119
|
+
const line = lines[index] ?? "";
|
|
120
|
+
if (!isIndentedCodeLine(line)) return false;
|
|
121
|
+
if (isIndentedMarkdownStructureLine(line)) return false;
|
|
122
|
+
if (index === 0) return true;
|
|
123
|
+
return (lines[index - 1] ?? "").trim().length === 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stripIndentedCodePrefix(line: string): string {
|
|
127
|
+
if (line.startsWith("\t")) return line.slice(1);
|
|
128
|
+
if (line.startsWith(" ")) return line.slice(4);
|
|
129
|
+
return line;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function renderMarkdownPreviewText(markdown: string): string {
|
|
133
|
+
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
|
134
|
+
if (normalized.length === 0) return "";
|
|
135
|
+
const output: string[] = [];
|
|
136
|
+
const lines = normalized.split("\n");
|
|
137
|
+
let activeFence: { marker: "`" | "~"; length: number } | undefined;
|
|
138
|
+
for (const rawLine of lines) {
|
|
139
|
+
const line = rawLine ?? "";
|
|
140
|
+
const fence = parseMarkdownFence(line);
|
|
141
|
+
if (activeFence) {
|
|
142
|
+
if (fence && isMatchingMarkdownFence(line, activeFence)) {
|
|
143
|
+
activeFence = undefined;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (line.trim().length === 0) {
|
|
147
|
+
if (output.at(-1) !== "") output.push("");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
output.push(line);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (fence) {
|
|
154
|
+
activeFence = { marker: fence.marker, length: fence.length };
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (line.trim().length === 0) {
|
|
158
|
+
if (output.at(-1) !== "") output.push("");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (isMarkdownTableSeparator(line)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
|
|
165
|
+
if (heading) {
|
|
166
|
+
output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
170
|
+
if (task) {
|
|
171
|
+
const indent = " ".repeat((task[1] ?? "").length);
|
|
172
|
+
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
173
|
+
output.push(
|
|
174
|
+
`${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const bullet = line.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
179
|
+
if (bullet) {
|
|
180
|
+
output.push(
|
|
181
|
+
`${" ".repeat((bullet[1] ?? "").length)}- ${stripInlineMarkdownToPlainText(bullet[2] ?? "")}`,
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const numbered = line.match(/^(\s*\d+\.)\s+(.+)$/);
|
|
186
|
+
if (numbered) {
|
|
187
|
+
output.push(
|
|
188
|
+
`${numbered[1]} ${stripInlineMarkdownToPlainText(numbered[2] ?? "")}`,
|
|
189
|
+
);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const quote = line.match(/^\s*>\s?(.+)$/);
|
|
193
|
+
if (quote) {
|
|
194
|
+
output.push(`> ${stripInlineMarkdownToPlainText(quote[1] ?? "")}`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (/^\s*([-*_]\s*){3,}\s*$/.test(line)) {
|
|
198
|
+
output.push("────────");
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
output.push(stripInlineMarkdownToPlainText(line));
|
|
202
|
+
}
|
|
203
|
+
return output.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Rich Markdown Rendering ---
|
|
207
|
+
|
|
208
|
+
function renderDelimitedInlineStyle(
|
|
209
|
+
text: string,
|
|
210
|
+
delimiter: string,
|
|
211
|
+
render: (content: string) => string,
|
|
212
|
+
): string {
|
|
213
|
+
const escapedDelimiter = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
214
|
+
const pattern = new RegExp(
|
|
215
|
+
`(^|[^\\p{L}\\p{N}\\\\])(${escapedDelimiter})(?=\\S)(.+?)(?<=\\S)\\2(?=[^\\p{L}\\p{N}]|$)`,
|
|
216
|
+
"gu",
|
|
217
|
+
);
|
|
218
|
+
return text.replace(
|
|
219
|
+
pattern,
|
|
220
|
+
(_match, prefix: string, _wrapped: string, content: string) => {
|
|
221
|
+
return `${prefix}${render(content)}`;
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderInlineMarkdown(text: string): string {
|
|
227
|
+
const tokens: string[] = [];
|
|
228
|
+
const makeToken = (html: string): string => {
|
|
229
|
+
const token = `\uE000${tokens.length}\uE001`;
|
|
230
|
+
tokens.push(html);
|
|
231
|
+
return token;
|
|
232
|
+
};
|
|
233
|
+
let result = text;
|
|
234
|
+
result = result.replace(
|
|
235
|
+
/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
236
|
+
(_match, alt: string, url: string) => {
|
|
237
|
+
const label = alt.trim().length > 0 ? alt : url;
|
|
238
|
+
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
result = result.replace(
|
|
242
|
+
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
243
|
+
(_match, label: string, url: string) => {
|
|
244
|
+
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
result = result.replace(
|
|
248
|
+
/<((?:https?:\/\/|mailto:)[^>]+)>/g,
|
|
249
|
+
(_match, url: string) => {
|
|
250
|
+
return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
|
|
254
|
+
return makeToken(`<code>${escapeHtml(code)}</code>`);
|
|
255
|
+
});
|
|
256
|
+
result = escapeHtml(result);
|
|
257
|
+
result = renderDelimitedInlineStyle(result, "***", (content) => {
|
|
258
|
+
return `<b><i>${content}</i></b>`;
|
|
259
|
+
});
|
|
260
|
+
result = renderDelimitedInlineStyle(result, "___", (content) => {
|
|
261
|
+
return `<b><i>${content}</i></b>`;
|
|
262
|
+
});
|
|
263
|
+
result = renderDelimitedInlineStyle(result, "~~", (content) => {
|
|
264
|
+
return `<s>${content}</s>`;
|
|
265
|
+
});
|
|
266
|
+
result = renderDelimitedInlineStyle(result, "**", (content) => {
|
|
267
|
+
return `<b>${content}</b>`;
|
|
268
|
+
});
|
|
269
|
+
result = renderDelimitedInlineStyle(result, "__", (content) => {
|
|
270
|
+
return `<b>${content}</b>`;
|
|
271
|
+
});
|
|
272
|
+
result = renderDelimitedInlineStyle(result, "*", (content) => {
|
|
273
|
+
return `<i>${content}</i>`;
|
|
274
|
+
});
|
|
275
|
+
result = renderDelimitedInlineStyle(result, "_", (content) => {
|
|
276
|
+
return `<i>${content}</i>`;
|
|
277
|
+
});
|
|
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
|
+
result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
|
|
286
|
+
return result.replace(
|
|
287
|
+
/\uE000(\d+)\uE001/g,
|
|
288
|
+
(_match, index: string) => tokens[Number(index)] ?? "",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function buildListIndent(level: number): string {
|
|
293
|
+
return "\u00A0".repeat(Math.max(0, level) * 2);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseMarkdownTableRow(line: string): string[] {
|
|
297
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
298
|
+
return trimmed
|
|
299
|
+
.split("|")
|
|
300
|
+
.map((cell) => stripInlineMarkdownToPlainText(cell.trim()));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseMarkdownQuoteLine(
|
|
304
|
+
line: string,
|
|
305
|
+
): { depth: number; content: string } | undefined {
|
|
306
|
+
const match = line.match(/^\s*((?:>\s*)+)(.*)$/);
|
|
307
|
+
if (!match) return undefined;
|
|
308
|
+
const markers = match[1] ?? "";
|
|
309
|
+
const depth = (markers.match(/>/g) ?? []).length;
|
|
310
|
+
return {
|
|
311
|
+
depth,
|
|
312
|
+
content: match[2] ?? "",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderMarkdownTextLines(block: string): string[] {
|
|
317
|
+
const rendered: string[] = [];
|
|
318
|
+
const lines = block.split("\n");
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
if (line.trim().length === 0) continue;
|
|
321
|
+
const pieces = splitPlainMarkdownLine(line);
|
|
322
|
+
for (const piece of pieces) {
|
|
323
|
+
const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
|
|
324
|
+
if (heading) {
|
|
325
|
+
rendered.push(
|
|
326
|
+
`${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
|
|
327
|
+
);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
|
|
331
|
+
if (task) {
|
|
332
|
+
const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
|
|
333
|
+
const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
334
|
+
rendered.push(
|
|
335
|
+
`${indent}<code>${marker}</code> ${renderInlineMarkdown(task[4] ?? "")}`,
|
|
336
|
+
);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
|
|
340
|
+
if (bullet) {
|
|
341
|
+
const indent = buildListIndent(
|
|
342
|
+
Math.floor((bullet[1] ?? "").length / 2),
|
|
343
|
+
);
|
|
344
|
+
rendered.push(
|
|
345
|
+
`${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`,
|
|
346
|
+
);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
350
|
+
if (numbered) {
|
|
351
|
+
const indent = buildListIndent(
|
|
352
|
+
Math.floor((numbered[1] ?? "").length / 2),
|
|
353
|
+
);
|
|
354
|
+
rendered.push(
|
|
355
|
+
`${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
|
|
356
|
+
);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const quote = piece.match(/^>\s?(.+)$/);
|
|
360
|
+
if (quote) {
|
|
361
|
+
rendered.push(
|
|
362
|
+
`<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
|
|
363
|
+
);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const trimmed = piece.trim();
|
|
367
|
+
if (/^([-*_]\s*){3,}$/.test(trimmed)) {
|
|
368
|
+
rendered.push("────────────");
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
rendered.push(renderInlineMarkdown(piece));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return rendered;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function renderMarkdownCodeBlock(code: string, language?: string): string[] {
|
|
378
|
+
const open = language
|
|
379
|
+
? `<pre><code class="language-${escapeHtml(language)}">`
|
|
380
|
+
: "<pre><code>";
|
|
381
|
+
const close = "</code></pre>";
|
|
382
|
+
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
|
383
|
+
const chunks: string[] = [];
|
|
384
|
+
let current = "";
|
|
385
|
+
const pushCurrent = (): void => {
|
|
386
|
+
if (current.length === 0) return;
|
|
387
|
+
chunks.push(`${open}${current}${close}`);
|
|
388
|
+
current = "";
|
|
389
|
+
};
|
|
390
|
+
const appendEscapedLine = (escapedLine: string): void => {
|
|
391
|
+
if (escapedLine.length <= maxContentLength) {
|
|
392
|
+
const candidate =
|
|
393
|
+
current.length === 0 ? escapedLine : `${current}\n${escapedLine}`;
|
|
394
|
+
if (candidate.length <= maxContentLength) {
|
|
395
|
+
current = candidate;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
pushCurrent();
|
|
399
|
+
current = escapedLine;
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
pushCurrent();
|
|
403
|
+
for (let i = 0; i < escapedLine.length; i += maxContentLength) {
|
|
404
|
+
chunks.push(
|
|
405
|
+
`${open}${escapedLine.slice(i, i + maxContentLength)}${close}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
for (const line of code.split("\n")) {
|
|
410
|
+
appendEscapedLine(escapeHtml(line));
|
|
411
|
+
}
|
|
412
|
+
pushCurrent();
|
|
413
|
+
return chunks.length > 0 ? chunks : [`${open}${close}`];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function renderMarkdownTableBlock(lines: string[]): string[] {
|
|
417
|
+
const rows = lines.map(parseMarkdownTableRow);
|
|
418
|
+
const columnCount = Math.max(...rows.map((row) => row.length), 0);
|
|
419
|
+
const normalizedRows = rows.map((row) => {
|
|
420
|
+
const next = [...row];
|
|
421
|
+
while (next.length < columnCount) {
|
|
422
|
+
next.push("");
|
|
423
|
+
}
|
|
424
|
+
return next;
|
|
425
|
+
});
|
|
426
|
+
const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
427
|
+
return Math.max(
|
|
428
|
+
3,
|
|
429
|
+
...normalizedRows.map((row) => (row[columnIndex] ?? "").length),
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
const formatRow = (row: string[]): string => {
|
|
433
|
+
return row
|
|
434
|
+
.map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3))
|
|
435
|
+
.join(" | ");
|
|
436
|
+
};
|
|
437
|
+
const separator = widths.map((width) => "-".repeat(width)).join(" | ");
|
|
438
|
+
const [header, ...body] = normalizedRows;
|
|
439
|
+
const tableLines = [
|
|
440
|
+
formatRow(header ?? []),
|
|
441
|
+
separator,
|
|
442
|
+
...body.map(formatRow),
|
|
443
|
+
];
|
|
444
|
+
return renderMarkdownCodeBlock(tableLines.join("\n"), "markdown");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function chunkRenderedHtmlLines(
|
|
448
|
+
lines: string[],
|
|
449
|
+
wrapper?: { open: string; close: string },
|
|
450
|
+
): string[] {
|
|
451
|
+
if (lines.length === 0) return [];
|
|
452
|
+
const open = wrapper?.open ?? "";
|
|
453
|
+
const close = wrapper?.close ?? "";
|
|
454
|
+
const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
|
|
455
|
+
const chunks: string[] = [];
|
|
456
|
+
let current = "";
|
|
457
|
+
const pushCurrent = (): void => {
|
|
458
|
+
if (current.length === 0) return;
|
|
459
|
+
chunks.push(`${open}${current}${close}`);
|
|
460
|
+
current = "";
|
|
461
|
+
};
|
|
462
|
+
for (const line of lines) {
|
|
463
|
+
const candidate = current.length === 0 ? line : `${current}\n${line}`;
|
|
464
|
+
if (candidate.length <= maxContentLength) {
|
|
465
|
+
current = candidate;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
pushCurrent();
|
|
469
|
+
if (line.length <= maxContentLength) {
|
|
470
|
+
current = line;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
for (let i = 0; i < line.length; i += maxContentLength) {
|
|
474
|
+
chunks.push(`${open}${line.slice(i, i + maxContentLength)}${close}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
pushCurrent();
|
|
478
|
+
return chunks;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function renderMarkdownTextBlock(block: string): string[] {
|
|
482
|
+
return chunkRenderedHtmlLines(renderMarkdownTextLines(block));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderMarkdownQuoteBlock(lines: string[]): string[] {
|
|
486
|
+
const inner = lines
|
|
487
|
+
.map((line) => {
|
|
488
|
+
const parsed = parseMarkdownQuoteLine(line);
|
|
489
|
+
if (!parsed) return line;
|
|
490
|
+
const nestedIndent = "\u00A0".repeat(Math.max(0, parsed.depth - 1) * 2);
|
|
491
|
+
return `${nestedIndent}${parsed.content}`;
|
|
492
|
+
})
|
|
493
|
+
.join("\n");
|
|
494
|
+
return chunkRenderedHtmlLines(renderMarkdownTextLines(inner), {
|
|
495
|
+
open: "<blockquote>",
|
|
496
|
+
close: "</blockquote>",
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
|
|
501
|
+
const normalized = markdown.replace(/\r\n/g, "\n").trim();
|
|
502
|
+
if (normalized.length === 0) return [];
|
|
503
|
+
const renderedBlocks: string[] = [];
|
|
504
|
+
const lines = normalized.split("\n");
|
|
505
|
+
let index = 0;
|
|
506
|
+
while (index < lines.length) {
|
|
507
|
+
const line = lines[index] ?? "";
|
|
508
|
+
const nextLine = lines[index + 1] ?? "";
|
|
509
|
+
const fence = parseMarkdownFence(line);
|
|
510
|
+
if (fence) {
|
|
511
|
+
index += 1;
|
|
512
|
+
const codeLines: string[] = [];
|
|
513
|
+
while (
|
|
514
|
+
index < lines.length &&
|
|
515
|
+
!isMatchingMarkdownFence(lines[index] ?? "", fence)
|
|
516
|
+
) {
|
|
517
|
+
codeLines.push(lines[index] ?? "");
|
|
518
|
+
index += 1;
|
|
519
|
+
}
|
|
520
|
+
if (index < lines.length) {
|
|
521
|
+
index += 1;
|
|
522
|
+
}
|
|
523
|
+
renderedBlocks.push(
|
|
524
|
+
...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
|
|
525
|
+
);
|
|
526
|
+
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
|
527
|
+
index += 1;
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (line.trim().length === 0) {
|
|
532
|
+
index += 1;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
|
|
536
|
+
const tableLines: string[] = [line];
|
|
537
|
+
index += 2;
|
|
538
|
+
while (index < lines.length) {
|
|
539
|
+
const tableLine = lines[index] ?? "";
|
|
540
|
+
if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
tableLines.push(tableLine);
|
|
544
|
+
index += 1;
|
|
545
|
+
}
|
|
546
|
+
renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (canStartIndentedCodeBlock(lines, index)) {
|
|
550
|
+
const codeLines: string[] = [];
|
|
551
|
+
while (index < lines.length) {
|
|
552
|
+
const rawLine = lines[index] ?? "";
|
|
553
|
+
if (rawLine.trim().length === 0) {
|
|
554
|
+
codeLines.push("");
|
|
555
|
+
index += 1;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!isIndentedCodeLine(rawLine)) break;
|
|
559
|
+
codeLines.push(stripIndentedCodePrefix(rawLine));
|
|
560
|
+
index += 1;
|
|
561
|
+
}
|
|
562
|
+
renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (/^\s*>/.test(line)) {
|
|
566
|
+
const quoteLines: string[] = [];
|
|
567
|
+
while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
|
|
568
|
+
quoteLines.push(lines[index] ?? "");
|
|
569
|
+
index += 1;
|
|
570
|
+
}
|
|
571
|
+
renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const textLines: string[] = [];
|
|
575
|
+
while (index < lines.length) {
|
|
576
|
+
const current = lines[index] ?? "";
|
|
577
|
+
const following = lines[index + 1] ?? "";
|
|
578
|
+
if (current.trim().length === 0) break;
|
|
579
|
+
if (
|
|
580
|
+
isFencedCodeStart(current) ||
|
|
581
|
+
canStartIndentedCodeBlock(lines, index) ||
|
|
582
|
+
/^\s*>/.test(current)
|
|
583
|
+
)
|
|
584
|
+
break;
|
|
585
|
+
if (current.includes("|") && isMarkdownTableSeparator(following)) break;
|
|
586
|
+
textLines.push(current);
|
|
587
|
+
index += 1;
|
|
588
|
+
}
|
|
589
|
+
renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
|
|
590
|
+
}
|
|
591
|
+
const chunks: string[] = [];
|
|
592
|
+
let current = "";
|
|
593
|
+
for (const block of renderedBlocks) {
|
|
594
|
+
const candidate = current.length === 0 ? block : `${current}\n\n${block}`;
|
|
595
|
+
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
596
|
+
current = candidate;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (current.length > 0) {
|
|
600
|
+
chunks.push(current);
|
|
601
|
+
current = "";
|
|
602
|
+
}
|
|
603
|
+
if (block.length <= MAX_MESSAGE_LENGTH) {
|
|
604
|
+
current = block;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
|
|
608
|
+
chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (current.length > 0) {
|
|
612
|
+
chunks.push(current);
|
|
613
|
+
}
|
|
614
|
+
return chunks;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- Unified Telegram Rendering ---
|
|
618
|
+
|
|
619
|
+
export type TelegramRenderMode = "plain" | "markdown" | "html";
|
|
620
|
+
|
|
621
|
+
export interface TelegramRenderedChunk {
|
|
622
|
+
text: string;
|
|
623
|
+
parseMode?: "HTML";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function chunkParagraphs(text: string): string[] {
|
|
627
|
+
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
|
|
628
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
629
|
+
const paragraphs = normalized.split(/\n\n+/);
|
|
630
|
+
const chunks: string[] = [];
|
|
631
|
+
let current = "";
|
|
632
|
+
const flushCurrent = (): void => {
|
|
633
|
+
if (current.trim().length > 0) chunks.push(current);
|
|
634
|
+
current = "";
|
|
635
|
+
};
|
|
636
|
+
const splitLongBlock = (block: string): string[] => {
|
|
637
|
+
if (block.length <= MAX_MESSAGE_LENGTH) return [block];
|
|
638
|
+
const lines = block.split("\n");
|
|
639
|
+
const lineChunks: string[] = [];
|
|
640
|
+
let lineCurrent = "";
|
|
641
|
+
for (const line of lines) {
|
|
642
|
+
const candidate =
|
|
643
|
+
lineCurrent.length === 0 ? line : `${lineCurrent}\n${line}`;
|
|
644
|
+
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
645
|
+
lineCurrent = candidate;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (lineCurrent.length > 0) {
|
|
649
|
+
lineChunks.push(lineCurrent);
|
|
650
|
+
lineCurrent = "";
|
|
651
|
+
}
|
|
652
|
+
if (line.length <= MAX_MESSAGE_LENGTH) {
|
|
653
|
+
lineCurrent = line;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
for (let i = 0; i < line.length; i += MAX_MESSAGE_LENGTH) {
|
|
657
|
+
lineChunks.push(line.slice(i, i + MAX_MESSAGE_LENGTH));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (lineCurrent.length > 0) {
|
|
661
|
+
lineChunks.push(lineCurrent);
|
|
662
|
+
}
|
|
663
|
+
return lineChunks;
|
|
664
|
+
};
|
|
665
|
+
for (const paragraph of paragraphs) {
|
|
666
|
+
if (paragraph.length === 0) continue;
|
|
667
|
+
const parts = splitLongBlock(paragraph);
|
|
668
|
+
for (const part of parts) {
|
|
669
|
+
const candidate = current.length === 0 ? part : `${current}\n\n${part}`;
|
|
670
|
+
if (candidate.length <= MAX_MESSAGE_LENGTH) {
|
|
671
|
+
current = candidate;
|
|
672
|
+
} else {
|
|
673
|
+
flushCurrent();
|
|
674
|
+
current = part;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
flushCurrent();
|
|
679
|
+
return chunks;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function renderTelegramMessage(
|
|
683
|
+
text: string,
|
|
684
|
+
options?: { mode?: TelegramRenderMode },
|
|
685
|
+
): TelegramRenderedChunk[] {
|
|
686
|
+
const mode = options?.mode ?? "plain";
|
|
687
|
+
if (mode === "plain") {
|
|
688
|
+
return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
|
|
689
|
+
}
|
|
690
|
+
if (mode === "html") {
|
|
691
|
+
return [{ text, parseMode: "HTML" }];
|
|
692
|
+
}
|
|
693
|
+
return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
|
|
694
|
+
text: chunk,
|
|
695
|
+
parseMode: "HTML",
|
|
696
|
+
}));
|
|
697
|
+
}
|