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/markdown.ts
CHANGED
|
@@ -74,6 +74,166 @@ function padCell(text: string, width: number, bold = false): string {
|
|
|
74
74
|
return cell + " ".repeat(padding);
|
|
75
75
|
}
|
|
76
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
|
+
|
|
77
237
|
/**
|
|
78
238
|
* Track list nesting depth for proper indentation.
|
|
79
239
|
* Resets per top-level markdownToTelegramHtml call.
|
|
@@ -112,21 +272,16 @@ const renderer = {
|
|
|
112
272
|
|
|
113
273
|
heading(this: TelegramRendererContext, token: Tokens.Heading): string {
|
|
114
274
|
const content = inlineFromTokens.call(this, token.tokens);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
if (token.depth >= 3) {
|
|
120
|
-
// H3+: bold with # prefix, matching TUI style
|
|
121
|
-
const prefix = "#".repeat(token.depth) + " ";
|
|
122
|
-
return `<b>${escapeHtml(prefix)}${content}</b>\n`;
|
|
123
|
-
}
|
|
124
|
-
// H2: bold only
|
|
125
|
-
return `<b>${content}</b>\n`;
|
|
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`;
|
|
126
279
|
},
|
|
127
280
|
|
|
128
281
|
paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
|
|
129
|
-
|
|
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`;
|
|
130
285
|
},
|
|
131
286
|
|
|
132
287
|
strong(this: TelegramRendererContext, { tokens }: Tokens.Strong): string {
|
|
@@ -146,69 +301,82 @@ const renderer = {
|
|
|
146
301
|
},
|
|
147
302
|
|
|
148
303
|
code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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`;
|
|
153
311
|
},
|
|
154
312
|
|
|
155
313
|
table(this: TelegramRendererContext, token: Tokens.Table): string {
|
|
156
314
|
const numCols = token.header.length;
|
|
157
315
|
if (numCols === 0) return "";
|
|
158
316
|
|
|
159
|
-
//
|
|
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.
|
|
160
331
|
const colWidths: number[] = [];
|
|
332
|
+
let allAscii = true;
|
|
161
333
|
for (let i = 0; i < numCols; i++) {
|
|
162
|
-
let maxW =
|
|
163
|
-
const
|
|
164
|
-
maxW = Math.max(maxW, visibleWidth(hText));
|
|
165
|
-
for (const row of token.rows) {
|
|
334
|
+
let maxW = Math.max(1, visibleWidth(stripHtmlTags(headerHtml[i])));
|
|
335
|
+
for (const row of rowsHtml) {
|
|
166
336
|
if (i < row.length) {
|
|
167
|
-
const
|
|
168
|
-
maxW =
|
|
337
|
+
const w = visibleWidth(stripHtmlTags(row[i]));
|
|
338
|
+
if (w > maxW) maxW = w;
|
|
339
|
+
if (!isAscii(stripHtmlTags(row[i]))) allAscii = false;
|
|
169
340
|
}
|
|
170
341
|
}
|
|
171
|
-
|
|
342
|
+
if (!isAscii(stripHtmlTags(headerHtml[i]))) allAscii = false;
|
|
343
|
+
colWidths.push(maxW);
|
|
172
344
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
lines.push(`│ ${headerCells.join(" │ ")} │`);
|
|
184
|
-
|
|
185
|
-
// Separator
|
|
186
|
-
lines.push(`├─${colWidths.map(w => "─".repeat(w)).join("─┼─")}─┤`);
|
|
187
|
-
|
|
188
|
-
// Data rows
|
|
189
|
-
for (const row of token.rows) {
|
|
190
|
-
const cells = row.map((c, i) =>
|
|
191
|
-
padCell(inlineFromTokens.call(this, c.tokens, c.text), colWidths[i], false),
|
|
192
|
-
);
|
|
193
|
-
while (cells.length < numCols) {
|
|
194
|
-
cells.push(" ".repeat(colWidths[cells.length]));
|
|
195
|
-
}
|
|
196
|
-
lines.push(`│ ${cells.join(" │ ")} │`);
|
|
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);
|
|
197
354
|
}
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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);
|
|
203
363
|
},
|
|
204
364
|
|
|
205
365
|
list(this: TelegramRendererContext, token: Tokens.List): string {
|
|
206
366
|
_listDepth++;
|
|
207
367
|
try {
|
|
208
368
|
const start = typeof token.start === "number" ? token.start : 1;
|
|
209
|
-
|
|
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
|
+
: "";
|
|
210
376
|
const lines = token.items.map((item, index) => {
|
|
211
|
-
const bullet = token.ordered
|
|
377
|
+
const bullet = token.ordered
|
|
378
|
+
? `${start + index}. `
|
|
379
|
+
: (_listDepth >= 3 ? "› " : "- ");
|
|
212
380
|
const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
|
|
213
381
|
const marker = bullet + taskMarker;
|
|
214
382
|
const content = blockFromTokens.call(this, item.tokens, item.text);
|
|
@@ -233,18 +401,50 @@ const renderer = {
|
|
|
233
401
|
|
|
234
402
|
blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
|
|
235
403
|
const content = this.parser.parse(tokens as unknown[]).trim();
|
|
236
|
-
// Only wrap in <i> if content doesn't
|
|
237
|
-
|
|
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);
|
|
238
409
|
const wrapped = shouldItalic ? `<i>${content}</i>` : content;
|
|
239
410
|
return `<blockquote>${wrapped}</blockquote>\n`;
|
|
240
411
|
},
|
|
241
412
|
|
|
242
413
|
hr(this: TelegramRendererContext): string {
|
|
243
|
-
|
|
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());
|
|
244
443
|
},
|
|
245
444
|
|
|
246
|
-
|
|
247
|
-
|
|
445
|
+
br(this: TelegramRendererContext): string {
|
|
446
|
+
// Telegram HTML has no <br>; a literal newline is the line break.
|
|
447
|
+
return "\n";
|
|
248
448
|
},
|
|
249
449
|
};
|
|
250
450
|
|
|
@@ -258,7 +458,13 @@ marked.use({ renderer });
|
|
|
258
458
|
export function markdownToTelegramHtml(markdown: string): string {
|
|
259
459
|
_listDepth = 0;
|
|
260
460
|
_textAppendNewline = true;
|
|
261
|
-
|
|
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+$/, "");
|
|
262
468
|
}
|
|
263
469
|
|
|
264
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.
|
|
@@ -69,5 +72,5 @@ export async function syncTelegramCommands(botToken: string | undefined, pi: Ext
|
|
|
69
72
|
if (!botToken) return;
|
|
70
73
|
try {
|
|
71
74
|
await setTelegramMyCommands(botToken, buildTelegramMenuCommands(pi));
|
|
72
|
-
} catch {
|
|
75
|
+
} catch (err) { menuLog.warn("syncTelegramCommands failed", { err }); }
|
|
73
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> => {
|