pi-telegram-plus 0.0.2 → 0.0.3
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/README.md +100 -232
- package/index.ts +17 -5
- package/lib/attachments.ts +7 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +7 -4
- package/lib/config.ts +6 -3
- package/lib/controller.ts +53 -14
- package/lib/custom-dialogs.ts +668 -0
- package/lib/heartbeat.ts +5 -1
- package/lib/logger.ts +304 -0
- package/lib/markdown.ts +267 -61
- package/lib/menu-commands.ts +4 -1
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +141 -14
- package/lib/telegram-api.ts +83 -28
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
package/lib/text-split.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Split text for Telegram's 4096-byte message limit.
|
|
3
3
|
* Handles UTF-8 boundaries safely and prefers word/newline breaks.
|
|
4
|
+
*
|
|
5
|
+
* `splitTelegramText` is the legacy byte-level splitter (kept for the
|
|
6
|
+
* single-chunk `[0]` path used by edit/buttons). `splitTelegramHtml` is the
|
|
7
|
+
* semantic splitter used for multi-message sends: it cuts at block boundaries
|
|
8
|
+
* so every chunk is independently valid Telegram HTML (no unbalanced
|
|
9
|
+
* <pre>/<blockquote> that would force a plain-text fallback).
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
12
|
const TELEGRAM_TEXT_LIMIT = 4096;
|
|
@@ -56,4 +62,213 @@ export function splitTelegramText(text: string): string[] {
|
|
|
56
62
|
}
|
|
57
63
|
if (rest) chunks.push(rest);
|
|
58
64
|
return chunks;
|
|
59
|
-
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Semantic HTML splitter ───────────────────────────────────────────────────
|
|
68
|
+
// Protects atomic blocks (<pre>, <blockquote>) so a cut never lands inside
|
|
69
|
+
// one and produces unbalanced tags. Oversized atomic blocks are split along
|
|
70
|
+
// their internal line boundaries into multiple complete blocks.
|
|
71
|
+
|
|
72
|
+
/** Split rendered Telegram HTML into <= maxBytes chunks, each valid HTML. */
|
|
73
|
+
export function splitTelegramHtml(html: string, maxBytes: number = SAFE_TEXT_CHUNK): string[] {
|
|
74
|
+
if (byteLength(html) <= maxBytes) return [html];
|
|
75
|
+
const blocks = splitHtmlBlocks(html);
|
|
76
|
+
const chunks: string[] = [];
|
|
77
|
+
let cur = "";
|
|
78
|
+
const flush = () => {
|
|
79
|
+
const t = cur.trim();
|
|
80
|
+
if (t) chunks.push(t);
|
|
81
|
+
cur = "";
|
|
82
|
+
};
|
|
83
|
+
for (const block of blocks) {
|
|
84
|
+
if (byteLength(block) > maxBytes) {
|
|
85
|
+
flush();
|
|
86
|
+
for (const piece of splitOversizedBlock(block, maxBytes)) chunks.push(piece);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (byteLength(cur) + 2 + byteLength(block) > maxBytes) flush();
|
|
90
|
+
cur = cur ? `${cur}\n\n${block}` : block;
|
|
91
|
+
}
|
|
92
|
+
flush();
|
|
93
|
+
return chunks.length ? chunks : [html];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Tokenize HTML into top-level blocks (atomic tags stay intact). */
|
|
97
|
+
function splitHtmlBlocks(html: string): string[] {
|
|
98
|
+
const atoms: string[] = [];
|
|
99
|
+
const placeholder = (atom: string) => {
|
|
100
|
+
atoms.push(atom);
|
|
101
|
+
return `\x00ATOM${atoms.length - 1}\x00`;
|
|
102
|
+
};
|
|
103
|
+
// Wrap each atomic block in blank lines so \n\n splitting isolates it.
|
|
104
|
+
// <pre> never contains a literal </pre> (code content is HTML-escaped),
|
|
105
|
+
// and our blockquotes don't nest, so non-greedy match is safe.
|
|
106
|
+
const protectedHtml = html
|
|
107
|
+
.replace(/<pre>[\s\S]*?<\/pre>/g, (m) => `\n\n${placeholder(m)}\n\n`)
|
|
108
|
+
.replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/g, (m) => `\n\n${placeholder(m)}\n\n`);
|
|
109
|
+
const blocks: string[] = [];
|
|
110
|
+
for (const part of protectedHtml.split(/\n\n+/)) {
|
|
111
|
+
const t = part.trim();
|
|
112
|
+
if (!t) continue;
|
|
113
|
+
blocks.push(t.replace(/\x00ATOM(\d+)\x00/g, (_m, idx) => atoms[Number(idx)] ?? ""));
|
|
114
|
+
}
|
|
115
|
+
return blocks;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Split a single oversized block along its internal structure. */
|
|
119
|
+
function splitOversizedBlock(block: string, maxBytes: number): string[] {
|
|
120
|
+
// <pre>...</pre> (code or box-table): split content by lines into multiple
|
|
121
|
+
// complete <pre> chunks.
|
|
122
|
+
const preMatch = /^(<pre>)(<code[^>]*>)?([\s\S]*)(<\/code>)?(<\/pre>)$/.exec(block);
|
|
123
|
+
if (preMatch) {
|
|
124
|
+
const [, preOpen, codeOpen, content, codeClose, preClose] = preMatch;
|
|
125
|
+
const open = (preOpen ?? "") + (codeOpen ?? "");
|
|
126
|
+
const close = (codeClose ?? "") + (preClose ?? "");
|
|
127
|
+
const lines = content.replace(/\n$/, "").split("\n");
|
|
128
|
+
// Box-drawing table: content starts with the top border ┌ and ends with the
|
|
129
|
+
// bottom border └. Splitting mid-table would drop the header row in
|
|
130
|
+
// continuation chunks (inconsistent), so repeat top border + header row +
|
|
131
|
+
// separator at the start of every chunk and the bottom border at the end —
|
|
132
|
+
// each chunk renders as a complete, consistent mini-table.
|
|
133
|
+
if (lines[0]?.startsWith("┌") && lines[lines.length - 1]?.startsWith("└")) {
|
|
134
|
+
return splitBoxPreTable(lines, maxBytes, open, close);
|
|
135
|
+
}
|
|
136
|
+
return packLines(lines, maxBytes, open, close);
|
|
137
|
+
}
|
|
138
|
+
// <blockquote...>...</blockquote>: split inner content, re-wrap each piece.
|
|
139
|
+
const bqMatch = /^(<blockquote[^>]*>)([\s\S]*)(<\/blockquote>)$/.exec(block);
|
|
140
|
+
if (bqMatch) {
|
|
141
|
+
const [, open, inner, close] = bqMatch;
|
|
142
|
+
const budget = maxBytes - byteLength(open) - byteLength(close);
|
|
143
|
+
return packLines(inner.replace(/\n$/, "").split("\n"), Math.max(50, budget), open, close);
|
|
144
|
+
}
|
|
145
|
+
// Card table (has a "──" header/row separator): repeat the header in each chunk.
|
|
146
|
+
const lines = block.split("\n");
|
|
147
|
+
const sepIdx = lines.indexOf("──");
|
|
148
|
+
if (sepIdx > 0) {
|
|
149
|
+
const header = lines.slice(0, sepIdx + 1).join("\n");
|
|
150
|
+
const rows = lines.slice(sepIdx + 1);
|
|
151
|
+
const headerOverhead = byteLength(header) + 1;
|
|
152
|
+
const pieces = packLines(rows, Math.max(50, maxBytes - headerOverhead), "", "");
|
|
153
|
+
return pieces.map((p) => `${header}\n${p}`);
|
|
154
|
+
}
|
|
155
|
+
// Transposed record (every line is `<b>label</b>: value`): keep each chunk's
|
|
156
|
+
// labels so a huge cell value is never separated from its column context.
|
|
157
|
+
if (lines.length > 0 && lines.every((l) => /^<b>.*<\/b>:/.test(l))) {
|
|
158
|
+
return splitTransposeRecord(lines, maxBytes);
|
|
159
|
+
}
|
|
160
|
+
// Plain paragraph / list: byte-split at newline/space boundaries.
|
|
161
|
+
return splitPlainBytes(block, maxBytes);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Split a transposed `label: value` record, repeating a label across a byte-
|
|
165
|
+
* split huge value so every chunk keeps its column context (same style). */
|
|
166
|
+
function splitTransposeRecord(lines: string[], maxBytes: number): string[] {
|
|
167
|
+
const chunks: string[] = [];
|
|
168
|
+
let cur = "";
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (byteLength(line) <= maxBytes) {
|
|
171
|
+
const candidate = cur ? `${cur}\n${line}` : line;
|
|
172
|
+
if (byteLength(candidate) > maxBytes && cur) { chunks.push(cur); cur = line; }
|
|
173
|
+
else cur = candidate;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Huge value line: byte-split the value, repeating the `label:` prefix.
|
|
177
|
+
if (cur) { chunks.push(cur); cur = ""; }
|
|
178
|
+
const m = /^(<b>.*?<\/b>:)\s*/.exec(line);
|
|
179
|
+
const label = m ? m[1] : "";
|
|
180
|
+
const value = m ? line.slice(m[0].length) : line;
|
|
181
|
+
const valueBudget = Math.max(50, maxBytes - byteLength(label) - 1);
|
|
182
|
+
for (const piece of splitPlainBytes(value, valueBudget)) chunks.push(`${label} ${piece}`);
|
|
183
|
+
}
|
|
184
|
+
if (cur) chunks.push(cur);
|
|
185
|
+
return chunks.length ? chunks : [lines.join("\n")];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Pack string lines into chunks `open + lines + close`, each <= maxBytes. */
|
|
189
|
+
function packLines(lines: string[], maxBytes: number, open: string, close: string): string[] {
|
|
190
|
+
const overhead = byteLength(open) + byteLength(close);
|
|
191
|
+
const budget = Math.max(50, maxBytes - overhead);
|
|
192
|
+
const chunks: string[] = [];
|
|
193
|
+
let cur = "";
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
if (cur) {
|
|
196
|
+
const candidate = `${cur}\n${line}`;
|
|
197
|
+
if (byteLength(candidate) > budget) {
|
|
198
|
+
chunks.push(`${open}${cur}${close}`);
|
|
199
|
+
cur = "";
|
|
200
|
+
} else {
|
|
201
|
+
cur = candidate;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// cur is empty: start a new chunk with `line`. If the line alone exceeds
|
|
206
|
+
// budget, byte-split it as a last-resort guard so no chunk ever overflows
|
|
207
|
+
// maxBytes and triggers a per-chunk plain-text fallback (which would mix
|
|
208
|
+
// styles within the same table).
|
|
209
|
+
if (byteLength(line) > budget) {
|
|
210
|
+
for (const piece of splitPlainBytes(line, budget)) chunks.push(`${open}${piece}${close}`);
|
|
211
|
+
} else {
|
|
212
|
+
cur = line;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (cur) chunks.push(`${open}${cur}${close}`);
|
|
216
|
+
return chunks.length ? chunks : [`${open}${lines.join("\n")}${close}`];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Split an oversized box-drawing table (inside <pre>) so every chunk is a
|
|
221
|
+
* complete mini-table: top border + header row + separator + N data rows +
|
|
222
|
+
* bottom border. This keeps the same style and header context in every chunk
|
|
223
|
+
* (a naive line-split would drop the header row in continuation chunks).
|
|
224
|
+
*/
|
|
225
|
+
function splitBoxPreTable(lines: string[], maxBytes: number, open: string, close: string): string[] {
|
|
226
|
+
const topBorder = lines[0];
|
|
227
|
+
const headerRow = lines[1];
|
|
228
|
+
const separator = lines[2];
|
|
229
|
+
const bottomBorder = lines[lines.length - 1];
|
|
230
|
+
const dataRows = lines.slice(3, -1);
|
|
231
|
+
const header = [topBorder, headerRow, separator].join("\n");
|
|
232
|
+
const overhead = byteLength(open) + byteLength(close) + byteLength(header) + 1 + byteLength(bottomBorder) + 1;
|
|
233
|
+
const budget = Math.max(50, maxBytes - overhead);
|
|
234
|
+
const chunks: string[] = [];
|
|
235
|
+
let cur: string[] = [];
|
|
236
|
+
let curBytes = 0;
|
|
237
|
+
const flush = () => {
|
|
238
|
+
if (cur.length) {
|
|
239
|
+
chunks.push(`${open}${header}\n${cur.join("\n")}\n${bottomBorder}${close}`);
|
|
240
|
+
cur = []; curBytes = 0;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
for (const row of dataRows) {
|
|
244
|
+
const rowBytes = byteLength(row) + 1;
|
|
245
|
+
if (curBytes + rowBytes > budget && cur.length) flush();
|
|
246
|
+
// Single row alone exceeds budget: byte-split it so no chunk overflows.
|
|
247
|
+
if (byteLength(row) > budget) {
|
|
248
|
+
flush();
|
|
249
|
+
for (const piece of splitPlainBytes(row, budget)) {
|
|
250
|
+
chunks.push(`${open}${header}\n${piece}\n${bottomBorder}${close}`);
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
cur.push(row); curBytes += rowBytes;
|
|
255
|
+
}
|
|
256
|
+
flush();
|
|
257
|
+
return chunks.length ? chunks : [`${open}${header}\n${dataRows.join("\n")}\n${bottomBorder}${close}`];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Byte-aware split at newline/space; fallback for plain text. */
|
|
261
|
+
function splitPlainBytes(text: string, maxBytes: number): string[] {
|
|
262
|
+
if (byteLength(text) <= maxBytes) return [text];
|
|
263
|
+
const chunks: string[] = [];
|
|
264
|
+
let rest = text;
|
|
265
|
+
while (byteLength(rest) > maxBytes) {
|
|
266
|
+
const { head } = takeUtf8Prefix(rest, maxBytes);
|
|
267
|
+
let cut = Math.max(head.lastIndexOf("\n"), head.lastIndexOf(" "));
|
|
268
|
+
if (cut <= 0) cut = head.length;
|
|
269
|
+
chunks.push(rest.slice(0, cut));
|
|
270
|
+
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
271
|
+
}
|
|
272
|
+
if (rest) chunks.push(rest);
|
|
273
|
+
return chunks;
|
|
274
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "pi-telegram-plus",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"description": "Full Telegram control of pi coding agent — commands, menus, interactive UI, model management, and more",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|