pi-telegram-plus 0.0.1 → 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 +102 -229
- package/index.ts +30 -11
- package/lib/attachments.ts +7 -3
- package/lib/commands/info.ts +109 -3
- package/lib/commands/register.ts +15 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +140 -55
- package/lib/config.ts +19 -4
- 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 +392 -23
- package/lib/menu-commands.ts +10 -6
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +146 -9
- package/lib/telegram-api.ts +129 -54
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
package/lib/markdown.ts
CHANGED
|
@@ -11,7 +11,14 @@ interface TelegramRendererContext {
|
|
|
11
11
|
|
|
12
12
|
function inlineFromTokens(this: TelegramRendererContext, tokens?: unknown[], fallback = ""): string {
|
|
13
13
|
if (Array.isArray(tokens) && tokens.length > 0) {
|
|
14
|
-
|
|
14
|
+
// Temporarily disable \\n appending since this is inline context
|
|
15
|
+
const prev = _textAppendNewline;
|
|
16
|
+
_textAppendNewline = false;
|
|
17
|
+
try {
|
|
18
|
+
return this.parser.parseInline(tokens as unknown[]);
|
|
19
|
+
} finally {
|
|
20
|
+
_textAppendNewline = prev;
|
|
21
|
+
}
|
|
15
22
|
}
|
|
16
23
|
return escapeHtml(fallback);
|
|
17
24
|
}
|
|
@@ -23,13 +30,258 @@ function blockFromTokens(this: TelegramRendererContext, tokens?: unknown[], fall
|
|
|
23
30
|
return escapeHtml(fallback);
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Calculate visible width of text in a monospace environment.
|
|
35
|
+
* ASCII = 1, CJK / fullwidth / emoji = 2.
|
|
36
|
+
*/
|
|
37
|
+
function visibleWidth(text: string): number {
|
|
38
|
+
let width = 0;
|
|
39
|
+
for (const char of text) {
|
|
40
|
+
const code = char.codePointAt(0)!;
|
|
41
|
+
if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue;
|
|
42
|
+
if (
|
|
43
|
+
(code >= 0x2e80 && code <= 0x9fff) || // CJK, Yi, Hangul syllables
|
|
44
|
+
(code >= 0xac00 && code <= 0xd7af) || // Hangul
|
|
45
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK compatibility
|
|
46
|
+
(code >= 0xfe30 && code <= 0xfe6f) || // CJK compat forms
|
|
47
|
+
(code >= 0xff01 && code <= 0xff60) || // fullwidth forms
|
|
48
|
+
(code >= 0xffe0 && code <= 0xffe6) || // fullwidth signs
|
|
49
|
+
(code >= 0x1f000 && code <= 0x1f9ff) || // emoji
|
|
50
|
+
code >= 0x20000 // CJK extension B
|
|
51
|
+
) {
|
|
52
|
+
width += 2;
|
|
53
|
+
} else {
|
|
54
|
+
width += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return width;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Strip HTML tags for visible width calculation. */
|
|
61
|
+
function stripHtmlTags(html: string): string {
|
|
62
|
+
return html.replace(/<[^>]+>/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pad cell text (which may contain HTML tags) to the given visible width.
|
|
67
|
+
* Optionally wraps the whole cell in <b> for header styling.
|
|
68
|
+
*/
|
|
69
|
+
function padCell(text: string, width: number, bold = false): string {
|
|
70
|
+
const currentWidth = visibleWidth(stripHtmlTags(text));
|
|
71
|
+
const padding = Math.max(0, width - currentWidth);
|
|
72
|
+
// Only wrap in <b> if the text doesn't already start with <b>
|
|
73
|
+
const cell = bold && !text.startsWith("<b>") ? `<b>${text}</b>` : text;
|
|
74
|
+
return cell + " ".repeat(padding);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True if text contains only printable ASCII (width is unambiguous = 1/char). */
|
|
78
|
+
function isAscii(text: string): boolean {
|
|
79
|
+
return /^[\x20-\x7e]*$/.test(text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Collapse newlines inside a table cell so each row renders as one line. */
|
|
83
|
+
function sanitizeCellHtml(html: string): string {
|
|
84
|
+
return html.replace(/\n+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Wrap inline HTML in <b> unless it already starts with one (avoids <b><b>). */
|
|
88
|
+
function wrapBold(html: string): string {
|
|
89
|
+
return html && !html.startsWith("<b>") ? `<b>${html}</b>` : html;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Table strategies (mobile-first) ─────────────────────────────────────────
|
|
93
|
+
// Telegram has no <table>; these three layouts trade off density vs. mobile
|
|
94
|
+
// readability. See PR-1c plan: card (default) / box (narrow ASCII) / transpose (wide).
|
|
95
|
+
|
|
96
|
+
/** Box-drawing grid in <pre> — plain text only (no inline tags inside <pre>). */
|
|
97
|
+
function renderBoxTable(headerText: string[], rowsText: string[][], colWidths: number[]): string {
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
lines.push(`┌─${colWidths.map((w) => "─".repeat(w)).join("─┬─")}─┐`);
|
|
100
|
+
lines.push(`│ ${headerText.map((h, i) => padCell(h, colWidths[i])).join(" │ ")} │`);
|
|
101
|
+
lines.push(`├─${colWidths.map((w) => "─".repeat(w)).join("─┼─")}─┤`);
|
|
102
|
+
for (const row of rowsText) {
|
|
103
|
+
const cells = colWidths.map((_, i) => padCell(i < row.length ? row[i] : "", colWidths[i]));
|
|
104
|
+
lines.push(`│ ${cells.join(" │ ")} │`);
|
|
105
|
+
}
|
|
106
|
+
lines.push(`└─${colWidths.map((w) => "─".repeat(w)).join("─┴─")}─┘`);
|
|
107
|
+
return `<pre>${lines.join("\n")}</pre>\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Card-style: bold header row, bold first column (primary key), 2-space sep. */
|
|
111
|
+
function renderCardTable(headerHtml: string[], rowsHtml: string[][]): string {
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
lines.push(headerHtml.map((h) => wrapBold(h)).join(" "));
|
|
114
|
+
lines.push("──");
|
|
115
|
+
for (const row of rowsHtml) {
|
|
116
|
+
const cells = headerHtml.map((_, i) => (i < row.length ? row[i] : ""));
|
|
117
|
+
const first = cells[0] ? wrapBold(cells[0]) : "";
|
|
118
|
+
const rest = cells.slice(1).filter((c) => c.length > 0).join(" ");
|
|
119
|
+
lines.push([first, rest].filter(Boolean).join(" "));
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n") + "\n\n";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Transposed: each row becomes a block (first col = bold title, rest = H: v). */
|
|
125
|
+
function renderTransposedTable(headerHtml: string[], rowsHtml: string[][]): string {
|
|
126
|
+
// Each data row becomes a block of `label: value` lines (all columns
|
|
127
|
+
// included, labels bolded), blocks separated by a blank line. No orphaned
|
|
128
|
+
// title / no indent / no heavy separator — the most mobile-readable layout
|
|
129
|
+
// for wide tables (many columns, few rows).
|
|
130
|
+
const blocks: string[] = [];
|
|
131
|
+
for (const row of rowsHtml) {
|
|
132
|
+
const cells = headerHtml.map((_, i) => (i < row.length ? row[i] : ""));
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
for (let i = 0; i < headerHtml.length; i++) {
|
|
135
|
+
const label = wrapBold(headerHtml[i]) || `col${i + 1}`;
|
|
136
|
+
lines.push(`${label}: ${cells[i]}`);
|
|
137
|
+
}
|
|
138
|
+
blocks.push(lines.join("\n"));
|
|
139
|
+
}
|
|
140
|
+
return blocks.join("\n\n") + "\n\n";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Pseudo-table protection ──────────────────────────────────────────────────
|
|
144
|
+
// Detects aligned pipe text that looks like a markdown table but has no
|
|
145
|
+
// `| --- |` separator row (e.g. raw `ls`/SQL/CSV-style output an agent pasted
|
|
146
|
+
// as prose). marked would tokenize such text as a paragraph and the pipes /
|
|
147
|
+
// column alignment would be lost in Telegram's proportional font. We wrap the
|
|
148
|
+
// run in a fenced code block so it renders as <pre><code> (monospace, aligned).
|
|
149
|
+
|
|
150
|
+
function isTableSeparator(line: string): boolean {
|
|
151
|
+
// A markdown table separator row: only spaces, dashes, colons, pipes; must
|
|
152
|
+
// contain at least one dash.
|
|
153
|
+
return /^\s*\|?[\s:|-]*\|?\s*$/.test(line) && line.includes("-");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A markdown table row/header line: starts with a pipe (our agent always emits `| ... |`). */
|
|
157
|
+
function isTableRow(line: string): boolean {
|
|
158
|
+
return /^\s*\|/.test(line);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isPseudoTableRow(line: string): boolean {
|
|
162
|
+
if (!line.includes("|")) return false;
|
|
163
|
+
const pipeCount = (line.match(/\|/g) || []).length;
|
|
164
|
+
if (pipeCount < 2) return false;
|
|
165
|
+
// Must have some real content (not a separator-only line).
|
|
166
|
+
return line.replace(/[\s|:-]/g, "").length > 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Count consecutive pseudo-table rows at `start`; 0 if it's a real table. */
|
|
170
|
+
function pseudoTableRun(lines: string[], start: number, inRealTable: Uint8Array): number {
|
|
171
|
+
if (inRealTable[start]) return 0;
|
|
172
|
+
if (!isPseudoTableRow(lines[start])) return 0;
|
|
173
|
+
// If the next line is a markdown table separator, this is a real table —
|
|
174
|
+
// leave it for marked's table tokenizer.
|
|
175
|
+
if (start + 1 < lines.length && isTableSeparator(lines[start + 1])) return 0;
|
|
176
|
+
let n = 1;
|
|
177
|
+
while (start + n < lines.length && inRealTable[start + n] === 0 && isPseudoTableRow(lines[start + n])) n++;
|
|
178
|
+
return n >= 2 ? n : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark every line that belongs to a real markdown table (header + separator +
|
|
183
|
+
* its consecutive data rows). These must be left for marked's table tokenizer;
|
|
184
|
+
* otherwise the data rows after the first would be mis-detected as a
|
|
185
|
+
* pseudo-table (their preceding line is a data row, not a separator).
|
|
186
|
+
*/
|
|
187
|
+
function markRealTableLines(lines: string[]): Uint8Array {
|
|
188
|
+
const flags = new Uint8Array(lines.length);
|
|
189
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
190
|
+
if (isTableRow(lines[i]) && isTableSeparator(lines[i + 1])) {
|
|
191
|
+
flags[i] = 1; // header
|
|
192
|
+
flags[i + 1] = 1; // separator
|
|
193
|
+
let j = i + 2;
|
|
194
|
+
while (j < lines.length && isTableRow(lines[j])) { flags[j] = 1; j++; }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return flags;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wrap pseudo-table runs (outside code fences) in fenced code blocks.
|
|
202
|
+
* Code-fence state is tracked so content already inside ``` is untouched.
|
|
203
|
+
*/
|
|
204
|
+
function protectPseudoTables(md: string): string {
|
|
205
|
+
const lines = md.split("\n");
|
|
206
|
+
const inRealTable = markRealTableLines(lines);
|
|
207
|
+
const out: string[] = [];
|
|
208
|
+
let inFence = false;
|
|
209
|
+
let fenceChar = "";
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < lines.length) {
|
|
212
|
+
const line = lines[i];
|
|
213
|
+
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
214
|
+
if (fence) {
|
|
215
|
+
const ch = fence[1][0];
|
|
216
|
+
if (!inFence) { inFence = true; fenceChar = ch; }
|
|
217
|
+
else if (ch === fenceChar) { inFence = false; fenceChar = ""; }
|
|
218
|
+
out.push(line);
|
|
219
|
+
i++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (inFence) { out.push(line); i++; continue; }
|
|
223
|
+
const run = pseudoTableRun(lines, i, inRealTable);
|
|
224
|
+
if (run > 1) {
|
|
225
|
+
out.push("```");
|
|
226
|
+
for (let j = i; j < i + run; j++) out.push(lines[j]);
|
|
227
|
+
out.push("```");
|
|
228
|
+
i += run;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out.push(line);
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
return out.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Track list nesting depth for proper indentation.
|
|
239
|
+
* Resets per top-level markdownToTelegramHtml call.
|
|
240
|
+
*/
|
|
241
|
+
let _listDepth = 0;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Flag: when true, the text renderer appends \\n after content.
|
|
245
|
+
* True during block-level parse (parser.parse), false during inline
|
|
246
|
+
* (parser.parseInline) so inline text tokens don't get spurious newlines.
|
|
247
|
+
*/
|
|
248
|
+
let _textAppendNewline = true;
|
|
249
|
+
|
|
26
250
|
const renderer = {
|
|
27
|
-
|
|
28
|
-
|
|
251
|
+
text(this: TelegramRendererContext, token: Tokens.Text | Tokens.Escape): string {
|
|
252
|
+
if ('tokens' in token && token.tokens && token.tokens.length > 0) {
|
|
253
|
+
// Text with nested inline tokens — parse inline (disable \\n for nested),
|
|
254
|
+
// then add \\n only if we are at block level
|
|
255
|
+
const prev = _textAppendNewline;
|
|
256
|
+
_textAppendNewline = false;
|
|
257
|
+
try {
|
|
258
|
+
return this.parser.parseInline(token.tokens as unknown[]) + (prev ? '\n' : '');
|
|
259
|
+
} finally {
|
|
260
|
+
_textAppendNewline = prev;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Plain text / escape token
|
|
264
|
+
return escapeHtml(token.text) + (_textAppendNewline ? '\n' : '');
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
checkbox(this: TelegramRendererContext): string {
|
|
268
|
+
// Suppress default <input> rendering; custom [x]/[ ] marker is added by list renderer
|
|
269
|
+
return "";
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
heading(this: TelegramRendererContext, token: Tokens.Heading): string {
|
|
274
|
+
const content = inlineFromTokens.call(this, token.tokens);
|
|
275
|
+
// Telegram has a single font/size, so hierarchy is conveyed by bold + a
|
|
276
|
+
// blank line. Drop the pi-tui `###` prefix (noise on mobile) and <u>
|
|
277
|
+
// (which reads like a link). Uniform <b> + trailing blank line for all depths.
|
|
278
|
+
return `<b>${content}</b>\n\n`;
|
|
29
279
|
},
|
|
30
280
|
|
|
31
281
|
paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
|
|
32
|
-
|
|
282
|
+
// Double newline after each paragraph so mobile readers get visual
|
|
283
|
+
// breathing room between paragraphs (single \n reads as run-together).
|
|
284
|
+
return `${inlineFromTokens.call(this, tokens)}\n\n`;
|
|
33
285
|
},
|
|
34
286
|
|
|
35
287
|
strong(this: TelegramRendererContext, { tokens }: Tokens.Strong): string {
|
|
@@ -49,41 +301,150 @@ const renderer = {
|
|
|
49
301
|
},
|
|
50
302
|
|
|
51
303
|
code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
|
|
52
|
-
|
|
53
|
-
|
|
304
|
+
// Drop the literal ``` fences (visual noise + wasted bytes) and emit the
|
|
305
|
+
// language as a Telegram-recognized `class="language-xxx"` attribute.
|
|
306
|
+
// Telegram validates the class value against [a-zA-Z][a-zA-Z0-9_-]*.
|
|
307
|
+
const codeContent = escapeHtml(text.replace(/\n+$/, ""));
|
|
308
|
+
const safeLang = (lang || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
309
|
+
const langAttr = safeLang ? ` class="language-${safeLang}"` : "";
|
|
310
|
+
return `<pre><code${langAttr}>${codeContent}\n</code></pre>\n`;
|
|
54
311
|
},
|
|
55
312
|
|
|
56
313
|
table(this: TelegramRendererContext, token: Tokens.Table): string {
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
314
|
+
const numCols = token.header.length;
|
|
315
|
+
if (numCols === 0) return "";
|
|
316
|
+
|
|
317
|
+
// Pre-render every cell to inline HTML (card / transpose strategies).
|
|
318
|
+
// Multi-line cell content is collapsed to a single line so each table
|
|
319
|
+
// row stays one visual line that wraps naturally on mobile.
|
|
320
|
+
const headerHtml = token.header.map((c) =>
|
|
321
|
+
sanitizeCellHtml(inlineFromTokens.call(this, c.tokens, c.text)));
|
|
322
|
+
const rowsHtml = token.rows.map((row) =>
|
|
323
|
+
row.map((c) => sanitizeCellHtml(inlineFromTokens.call(this, c.tokens, c.text))),
|
|
324
|
+
);
|
|
325
|
+
// Plain escaped text for box-drawing (Telegram rejects <b>/<code> nested
|
|
326
|
+
// inside <pre>, so box cells must be tag-free).
|
|
327
|
+
const headerText = token.header.map((c) => escapeHtml(c.text));
|
|
328
|
+
const rowsText = token.rows.map((row) => row.map((c) => escapeHtml(c.text)));
|
|
329
|
+
|
|
330
|
+
// Natural column widths for strategy selection + box layout.
|
|
331
|
+
const colWidths: number[] = [];
|
|
332
|
+
let allAscii = true;
|
|
333
|
+
for (let i = 0; i < numCols; i++) {
|
|
334
|
+
let maxW = Math.max(1, visibleWidth(stripHtmlTags(headerHtml[i])));
|
|
335
|
+
for (const row of rowsHtml) {
|
|
336
|
+
if (i < row.length) {
|
|
337
|
+
const w = visibleWidth(stripHtmlTags(row[i]));
|
|
338
|
+
if (w > maxW) maxW = w;
|
|
339
|
+
if (!isAscii(stripHtmlTags(row[i]))) allAscii = false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!isAscii(stripHtmlTags(headerHtml[i]))) allAscii = false;
|
|
343
|
+
colWidths.push(maxW);
|
|
61
344
|
}
|
|
62
|
-
|
|
63
|
-
|
|
345
|
+
const naturalWidth = colWidths.reduce((a, b) => a + b, 0) + 3 * numCols + 1;
|
|
346
|
+
|
|
347
|
+
// T4: narrow 2-column all-ASCII table that fits a mobile screen -> box-drawing.
|
|
348
|
+
// ASCII width is unambiguous (1 char = 1 cell) in Telegram's monospace font,
|
|
349
|
+
// so the <pre> grid aligns reliably. Bound by total natural width (~40
|
|
350
|
+
// chars fits an iPhone portrait <pre>) so normal command/desc tables qualify;
|
|
351
|
+
// longer cells fall through to card which wraps naturally.
|
|
352
|
+
if (numCols === 2 && allAscii && naturalWidth <= 40) {
|
|
353
|
+
return renderBoxTable(headerText, rowsText, colWidths);
|
|
64
354
|
}
|
|
65
|
-
|
|
355
|
+
// T5: wide table -> transpose to vertical cards (one block per row), so a
|
|
356
|
+
// many-column table never forces horizontal scroll on mobile.
|
|
357
|
+
if (numCols > 4 || naturalWidth > 60) {
|
|
358
|
+
return renderTransposedTable(headerHtml, rowsHtml);
|
|
359
|
+
}
|
|
360
|
+
// T1 default: card-style - bold header + bold first column (primary key),
|
|
361
|
+
// 2-space column separator, wraps naturally, never overflows.
|
|
362
|
+
return renderCardTable(headerHtml, rowsHtml);
|
|
66
363
|
},
|
|
67
364
|
|
|
68
365
|
list(this: TelegramRendererContext, token: Tokens.List): string {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
366
|
+
_listDepth++;
|
|
367
|
+
try {
|
|
368
|
+
const start = typeof token.start === "number" ? token.start : 1;
|
|
369
|
+
// Cap indent growth at depth 2: deeper levels add no base indent of
|
|
370
|
+
// their own and switch to a `› ` bullet (unordered), so deep nesting
|
|
371
|
+
// grows only ~2 cols/level (parent continuation indent) instead of 4.
|
|
372
|
+
// Ordered lists keep their numbering at every depth.
|
|
373
|
+
const baseIndent = _listDepth <= 2
|
|
374
|
+
? " ".repeat(Math.max(0, _listDepth - 1))
|
|
375
|
+
: "";
|
|
376
|
+
const lines = token.items.map((item, index) => {
|
|
377
|
+
const bullet = token.ordered
|
|
378
|
+
? `${start + index}. `
|
|
379
|
+
: (_listDepth >= 3 ? "› " : "- ");
|
|
380
|
+
const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
|
|
381
|
+
const marker = bullet + taskMarker;
|
|
382
|
+
const content = blockFromTokens.call(this, item.tokens, item.text);
|
|
383
|
+
const contentLines = content.trimEnd().split("\n");
|
|
384
|
+
const contIndent = " ".repeat(visibleWidth(marker));
|
|
385
|
+
return contentLines.map((line, i) => {
|
|
386
|
+
const prefix = i === 0 ? baseIndent + marker : baseIndent + contIndent;
|
|
387
|
+
return prefix + line;
|
|
388
|
+
}).join("\n");
|
|
389
|
+
});
|
|
390
|
+
return lines.join("\n") + "\n";
|
|
391
|
+
} finally {
|
|
392
|
+
_listDepth--;
|
|
393
|
+
}
|
|
75
394
|
},
|
|
76
395
|
|
|
77
396
|
listitem(this: TelegramRendererContext, token: Tokens.ListItem): string {
|
|
397
|
+
// Used when a list item contains block-level children (e.g. nested lists).
|
|
398
|
+
// blockFromTokens will re-enter `list` for nested lists via parser.parse().
|
|
78
399
|
return blockFromTokens.call(this, token.tokens, token.text);
|
|
79
400
|
},
|
|
80
401
|
|
|
81
402
|
blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
|
|
82
|
-
|
|
403
|
+
const content = this.parser.parse(tokens as unknown[]).trim();
|
|
404
|
+
// Only wrap in <i> if content doesn't start with a block-level / inline-format
|
|
405
|
+
// tag. Wrapping `<b>`/`<pre>`/`<blockquote>` in `<i>` produces nesting that
|
|
406
|
+
// Telegram rejects (bad request), which forces a plain-text fallback for the
|
|
407
|
+
// ENTIRE message chunk — silently dropping all formatting.
|
|
408
|
+
const shouldItalic = !/^<blockquote|^<pre|^<ul|^<ol|^<table|^<b|^<i|^<a/i.test(content);
|
|
409
|
+
const wrapped = shouldItalic ? `<i>${content}</i>` : content;
|
|
410
|
+
return `<blockquote>${wrapped}</blockquote>\n`;
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
hr(this: TelegramRendererContext): string {
|
|
414
|
+
// Slim separator: 3 fullwidth dashes, no <pre> (avoids the heavy
|
|
415
|
+
// monospace block that eats a full line on mobile).
|
|
416
|
+
return "━━━\n\n";
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
image(this: TelegramRendererContext, { href, text, title }: Tokens.Image): string {
|
|
420
|
+
const alt = text || "image";
|
|
421
|
+
if (href) return `🖼 <a href="${escapeAttr(href)}">${escapeHtml(alt)}</a>`;
|
|
422
|
+
if (title) return `🖼 ${escapeHtml(alt)}: ${escapeHtml(title)}`;
|
|
423
|
+
return `🖼 ${escapeHtml(alt)}`;
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// ── Inline / passthrough tokens previously left to marked's default ──
|
|
427
|
+
// marked's default renderers emit attributes/constructs Telegram's HTML
|
|
428
|
+
// parser rejects (`title=` on <a>, `<br>`, raw HTML, unescaped `&` in href),
|
|
429
|
+
// which causes the whole message chunk to fall back to plain text.
|
|
430
|
+
// Override each so the output is always valid Telegram HTML.
|
|
431
|
+
|
|
432
|
+
link(this: TelegramRendererContext, { href, tokens }: Tokens.Link): string {
|
|
433
|
+
const safeHref = escapeAttr(href);
|
|
434
|
+
if (!safeHref) return inlineFromTokens.call(this, tokens);
|
|
435
|
+
return `<a href="${safeHref}">${inlineFromTokens.call(this, tokens)}</a>`;
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
html(this: TelegramRendererContext, token: Tokens.HTML | Tokens.Tag): string {
|
|
439
|
+
// pi-tui renders raw HTML as plain text; mirror that here instead of
|
|
440
|
+
// letting it pass through and trip Telegram's parser.
|
|
441
|
+
const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
|
|
442
|
+
return escapeHtml(raw.trim());
|
|
83
443
|
},
|
|
84
444
|
|
|
85
|
-
|
|
86
|
-
|
|
445
|
+
br(this: TelegramRendererContext): string {
|
|
446
|
+
// Telegram HTML has no <br>; a literal newline is the line break.
|
|
447
|
+
return "\n";
|
|
87
448
|
},
|
|
88
449
|
};
|
|
89
450
|
|
|
@@ -95,7 +456,15 @@ marked.use({ renderer });
|
|
|
95
456
|
* formatting within the Telegram supported subset.
|
|
96
457
|
*/
|
|
97
458
|
export function markdownToTelegramHtml(markdown: string): string {
|
|
98
|
-
|
|
459
|
+
_listDepth = 0;
|
|
460
|
+
_textAppendNewline = true;
|
|
461
|
+
// Pre-scan: wrap "pseudo-tables" (aligned pipe text that looks like a
|
|
462
|
+
// markdown table but lacks the `| --- |` separator row) in fenced code so
|
|
463
|
+
// marked renders them as <pre><code> — preserving column alignment in
|
|
464
|
+
// Telegram's monospace font instead of mangling pipes in a proportional-font
|
|
465
|
+
// paragraph. Real markdown tables (with a separator) are left for marked.
|
|
466
|
+
const protected_ = protectPseudoTables(markdown);
|
|
467
|
+
return (marked.parse(protected_, { async: false }) as string).replace(/\n+$/, "");
|
|
99
468
|
}
|
|
100
469
|
|
|
101
470
|
export function escapeAttr(text: string): string {
|
package/lib/menu-commands.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { setTelegramMyCommands } from "./telegram-api.ts";
|
|
3
|
+
import { log } from "./logger.ts";
|
|
4
|
+
|
|
5
|
+
const menuLog = log.child("menu-commands");
|
|
3
6
|
|
|
4
7
|
const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> = [
|
|
5
8
|
// Keep the built-in pi commands in the same order as the TUI slash menu.
|
|
@@ -31,14 +34,15 @@ const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> =
|
|
|
31
34
|
{ command: "thinking", description: "Show or change thinking level" },
|
|
32
35
|
{ command: "stop", description: "Stop the current agent turn" },
|
|
33
36
|
{ command: "debug", description: "Show debug information" },
|
|
37
|
+
{ command: "status", description: "Show runtime snapshot (workspace, model, context, messages)" },
|
|
34
38
|
// tg-* commands visible in the Telegram bot menu.
|
|
35
39
|
// tg-bind-cwd / tg-unbind-cwd are workspace-management commands that
|
|
36
40
|
// require local cwd context and do not belong in the bot command list.
|
|
37
|
-
{ command: "
|
|
38
|
-
{ command: "
|
|
39
|
-
{ command: "
|
|
40
|
-
{ command: "
|
|
41
|
-
{ command: "
|
|
41
|
+
{ command: "tg_global_setup", description: "Configure global Telegram bot token" },
|
|
42
|
+
{ command: "tg_global_connect", description: "Enable/start global Telegram bot" },
|
|
43
|
+
{ command: "tg_global_disconnect", description: "Disable/stop global Telegram bot" },
|
|
44
|
+
{ command: "tg_config", description: "Configure Telegram message rendering" },
|
|
45
|
+
{ command: "tg_list", description: "List Telegram bot bindings" },
|
|
42
46
|
];
|
|
43
47
|
|
|
44
48
|
const toTelegramCommandName = (name: string): string | undefined => {
|
|
@@ -68,5 +72,5 @@ export async function syncTelegramCommands(botToken: string | undefined, pi: Ext
|
|
|
68
72
|
if (!botToken) return;
|
|
69
73
|
try {
|
|
70
74
|
await setTelegramMyCommands(botToken, buildTelegramMenuCommands(pi));
|
|
71
|
-
} catch {
|
|
75
|
+
} catch (err) { menuLog.warn("syncTelegramCommands failed", { err }); }
|
|
72
76
|
}
|
package/lib/polling.ts
CHANGED
|
@@ -4,6 +4,9 @@ import { join } from "node:path";
|
|
|
4
4
|
import { getAgentDir } from "./config.ts";
|
|
5
5
|
import { getTelegramUpdates } from "./telegram-api.ts";
|
|
6
6
|
import type { TelegramConfig, TelegramUpdate } from "./types.ts";
|
|
7
|
+
import { log } from "./logger.ts";
|
|
8
|
+
|
|
9
|
+
const pollLog = log.child("polling");
|
|
7
10
|
|
|
8
11
|
export type TelegramPollingRuntime = {
|
|
9
12
|
start(): void;
|
|
@@ -42,7 +45,8 @@ function isPidAlive(pid: unknown): boolean {
|
|
|
42
45
|
try {
|
|
43
46
|
process.kill(pid, 0);
|
|
44
47
|
return true;
|
|
45
|
-
} catch {
|
|
48
|
+
} catch (err) {
|
|
49
|
+
pollLog.debug("isPollingLockStale stat failed (treated as not stale)", { err });
|
|
46
50
|
return false;
|
|
47
51
|
}
|
|
48
52
|
}
|
|
@@ -61,7 +65,8 @@ async function readPollingLockOwner(ownerPath: string): Promise<PollingLockOwner
|
|
|
61
65
|
at: typeof owner.at === "string" ? owner.at : "",
|
|
62
66
|
touchedAt: typeof owner.touchedAt === "string" ? owner.touchedAt : "",
|
|
63
67
|
};
|
|
64
|
-
} catch {
|
|
68
|
+
} catch (err) {
|
|
69
|
+
pollLog.debug("readPollingLockOwner failed (treated as no owner)", { err });
|
|
65
70
|
return undefined;
|
|
66
71
|
}
|
|
67
72
|
}
|
|
@@ -93,7 +98,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
|
|
|
93
98
|
|
|
94
99
|
// Clean up stale lock if it exists.
|
|
95
100
|
if (!await isPollingLockStale(lockPath)) return undefined;
|
|
96
|
-
await rm(lockPath, { recursive: true, force: true }).catch((
|
|
101
|
+
await rm(lockPath, { recursive: true, force: true }).catch(pollLog.swallow("debug", "rm stale polling lock failed", { lockPath }));
|
|
97
102
|
|
|
98
103
|
// Create lock directory; if another process won, bail out.
|
|
99
104
|
try {
|
|
@@ -110,7 +115,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
|
|
|
110
115
|
await writeFile(tmpPath, ownerText(owner), { mode: 0o600, flag: "wx" });
|
|
111
116
|
} catch (error) {
|
|
112
117
|
// Another process's candidate won; clean up and bail.
|
|
113
|
-
await rm(tmpPath).catch((
|
|
118
|
+
await rm(tmpPath).catch(pollLog.swallow("debug", "rm polling candidate tmp failed", { tmpPath }));
|
|
114
119
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
|
|
115
120
|
throw error;
|
|
116
121
|
}
|
|
@@ -120,7 +125,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
|
|
|
120
125
|
await nodeRename(tmpPath, ownerPath);
|
|
121
126
|
} catch {
|
|
122
127
|
// rename failed (e.g. cross-device or another process already wrote owner.json).
|
|
123
|
-
await rm(tmpPath).catch((
|
|
128
|
+
await rm(tmpPath).catch(pollLog.swallow("debug", "rm polling candidate tmp after rename failure", { tmpPath }));
|
|
124
129
|
return undefined;
|
|
125
130
|
}
|
|
126
131
|
|
|
@@ -129,7 +134,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
|
|
|
129
134
|
const current = await readPollingLockOwner(ownerPath);
|
|
130
135
|
if (current?.id !== owner.id) return;
|
|
131
136
|
owner.touchedAt = new Date().toISOString();
|
|
132
|
-
await writeFile(ownerPath, ownerText(owner), { mode: 0o600 }).catch((
|
|
137
|
+
await writeFile(ownerPath, ownerText(owner), { mode: 0o600 }).catch(pollLog.swallow("warn", "polling lock touch write failed", { ownerPath }));
|
|
133
138
|
})();
|
|
134
139
|
}, POLL_LOCK_TOUCH_MS);
|
|
135
140
|
|
|
@@ -138,7 +143,7 @@ async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<
|
|
|
138
143
|
release: async () => {
|
|
139
144
|
clearInterval(touch);
|
|
140
145
|
const current = await readPollingLockOwner(ownerPath);
|
|
141
|
-
if (current?.id === owner.id) await rm(lockPath, { recursive: true, force: true }).catch((
|
|
146
|
+
if (current?.id === owner.id) await rm(lockPath, { recursive: true, force: true }).catch(pollLog.swallow("debug", "rm polling lock on release failed", { lockPath }));
|
|
142
147
|
},
|
|
143
148
|
};
|
|
144
149
|
}
|
|
@@ -159,7 +164,7 @@ export function createTelegramPollingRuntime(deps: {
|
|
|
159
164
|
const releasePollLock = async () => {
|
|
160
165
|
const lock = pollLock;
|
|
161
166
|
pollLock = undefined;
|
|
162
|
-
await lock?.release().catch((
|
|
167
|
+
await lock?.release().catch(pollLog.swallow("warn", "poll lock release failed"));
|
|
163
168
|
};
|
|
164
169
|
|
|
165
170
|
const ensurePollLock = async (token: string): Promise<boolean> => {
|