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/renderer.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { escapeHtml } from "./html.ts";
|
|
3
6
|
import { markdownToTelegramHtml } from "./markdown.ts";
|
|
4
7
|
import type { TelegramConfig, TelegramRenderLevel, TelegramTransport, TelegramTurn } from "./types.ts";
|
|
5
8
|
import { RENDER_LEVELS } from "./types.ts";
|
|
9
|
+
import { log } from "./logger.ts";
|
|
10
|
+
|
|
11
|
+
const renderLog = log.child("renderer");
|
|
6
12
|
|
|
7
13
|
type AnyMessage = {
|
|
8
14
|
role?: string;
|
|
@@ -51,7 +57,9 @@ function contentToRenderParts(
|
|
|
51
57
|
: `๐ง ${name}\n${stringifyShort(p.arguments ?? {}, 1200)}`);
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
|
-
|
|
60
|
+
// Multiple text parts (common when a turn interleaves text around tool calls)
|
|
61
|
+
// are separated by a blank line so paragraphs don't run together on mobile.
|
|
62
|
+
return { body: body.filter(Boolean).join("\n\n"), inlineEvents };
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
function contentImages(content: unknown): Array<{ data: string; mimeType?: string }> {
|
|
@@ -65,6 +73,33 @@ function contentImages(content: unknown): Array<{ data: string; mimeType?: strin
|
|
|
65
73
|
});
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Extract renderable text + image parts from a tool result. Tools return
|
|
78
|
+
* `{ content: (TextContent | ImageContent)[], details }`; we surface the
|
|
79
|
+
* actual output text (rendered as markdown) and any images, instead of the
|
|
80
|
+
* truncated JSON blob the previous `full` mode emitted.
|
|
81
|
+
*
|
|
82
|
+
* @internal Exported for tests; not part of the public module API.
|
|
83
|
+
*/
|
|
84
|
+
export function extractToolResultParts(result: unknown): {
|
|
85
|
+
body: string;
|
|
86
|
+
images: Array<{ data: string; mimeType?: string }>;
|
|
87
|
+
} {
|
|
88
|
+
const content = (result as Record<string, any> | null | undefined)?.content;
|
|
89
|
+
const images = contentImages(content);
|
|
90
|
+
const body: string[] = [];
|
|
91
|
+
if (Array.isArray(content)) {
|
|
92
|
+
for (const part of content) {
|
|
93
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
|
|
94
|
+
body.push(part.text);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else if (typeof result === "string") {
|
|
98
|
+
body.push(result);
|
|
99
|
+
}
|
|
100
|
+
return { body: body.filter(Boolean).join("\n\n"), images };
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
function stringifyShort(value: unknown, max = 900): string {
|
|
69
104
|
let text: string;
|
|
70
105
|
if (typeof value === "string") text = value;
|
|
@@ -75,6 +110,63 @@ function stringifyShort(value: unknown, max = 900): string {
|
|
|
75
110
|
return text.length <= max ? text : text.slice(0, max - 1) + "โฆ";
|
|
76
111
|
}
|
|
77
112
|
|
|
113
|
+
// โโ Oversized code block โ file attachment (C1) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
114
|
+
// A single fenced code block that won't fit in one Telegram message is sent
|
|
115
|
+
// as a downloadable document instead of being split across many <pre> chunks
|
|
116
|
+
// (which are painful to read on mobile). The fence is replaced in-body by a
|
|
117
|
+
// short notice so the assistant's prose still reads coherently.
|
|
118
|
+
|
|
119
|
+
const OVERSIZED_CODE_BYTES = 4000;
|
|
120
|
+
|
|
121
|
+
const LANG_EXT: Record<string, string> = {
|
|
122
|
+
ts: ".ts", tsx: ".tsx", js: ".js", jsx: ".jsx", mjs: ".mjs", cjs: ".cjs",
|
|
123
|
+
py: ".py", python: ".py", rb: ".rb", ruby: ".rb", go: ".go", rs: ".rs", rust: ".rs", java: ".java", kt: ".kt", kotlin: ".kt",
|
|
124
|
+
c: ".c", h: ".h", cpp: ".cpp", cc: ".cc", hpp: ".hpp", cs: ".cs", php: ".php",
|
|
125
|
+
swift: ".swift", sh: ".sh", bash: ".sh", zsh: ".sh", fish: ".sh", ps1: ".ps1",
|
|
126
|
+
sql: ".sql", json: ".json", yml: ".yml", yaml: ".yaml", toml: ".toml",
|
|
127
|
+
xml: ".xml", html: ".html", htm: ".html", css: ".css", scss: ".scss",
|
|
128
|
+
md: ".md", markdown: ".md", txt: ".txt", text: ".txt", dockerfile: "Dockerfile",
|
|
129
|
+
makefile: "Makefile", graphql: ".graphql", lua: ".lua", r: ".r", dart: ".dart",
|
|
130
|
+
scala: ".scala", clj: ".clj", ex: ".ex", exs: ".exs", erl: ".erl", vim: ".vim",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function langToExt(lang: string): string {
|
|
134
|
+
const key = (lang || "").trim().toLowerCase();
|
|
135
|
+
return LANG_EXT[key] ?? ".txt";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Pull fenced code blocks > OVERSIZED_CODE_BYTES out of the markdown body,
|
|
140
|
+
* replacing each with a one-line notice. Returns the trimmed body and the
|
|
141
|
+
* extracted blocks (lang + raw content) for file attachment.
|
|
142
|
+
*
|
|
143
|
+
* @internal Exported for tests; not part of the public module API.
|
|
144
|
+
*/
|
|
145
|
+
export function extractOversizedCodeBlocks(body: string): {
|
|
146
|
+
body: string;
|
|
147
|
+
blocks: Array<{ lang: string; content: string; fileName: string }>;
|
|
148
|
+
} {
|
|
149
|
+
const blocks: Array<{ lang: string; content: string; fileName: string }> = [];
|
|
150
|
+
const stripped = body.replace(/```([^\n]*)\n([\s\S]*?)\n```/g, (m, langRaw, content) => {
|
|
151
|
+
const lang = String(langRaw || "").trim();
|
|
152
|
+
const code = String(content);
|
|
153
|
+
if (Buffer.byteLength(code, "utf8") <= OVERSIZED_CODE_BYTES) return m;
|
|
154
|
+
const idx = blocks.length + 1;
|
|
155
|
+
const fileName = `code-${idx}${langToExt(lang)}`;
|
|
156
|
+
blocks.push({ lang, content: code, fileName });
|
|
157
|
+
const lines = code.split("\n").length;
|
|
158
|
+
return `\n\n๐ \`${lang || "code"} block (${lines} lines) โ attached: ${fileName}\`\n\n`;
|
|
159
|
+
});
|
|
160
|
+
return { body: stripped, blocks };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeTempCodeFile(fileName: string, content: string): Promise<string> {
|
|
164
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-tg-code-"));
|
|
165
|
+
const filePath = join(dir, fileName);
|
|
166
|
+
await writeFile(filePath, content, "utf8");
|
|
167
|
+
return filePath;
|
|
168
|
+
}
|
|
169
|
+
|
|
78
170
|
function renderLevel(config: TelegramConfig, key: "tool" | "thinking"): TelegramRenderLevel {
|
|
79
171
|
const value = config[key];
|
|
80
172
|
return (RENDER_LEVELS as readonly string[]).includes(value ?? "") ? value! : "brief";
|
|
@@ -176,15 +268,16 @@ export function registerTelegramRenderer(
|
|
|
176
268
|
return;
|
|
177
269
|
}
|
|
178
270
|
if (options.final && Buffer.byteLength(html, "utf8") > EDIT_LIMIT) {
|
|
179
|
-
await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐ค <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch((
|
|
271
|
+
await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐ค <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch(renderLog.swallow("warn", "editText final-answer pointer failed", { chatId: turn.chatId, messageId: turn.replaceMessageId }));
|
|
180
272
|
turn.replaceMessageId = undefined;
|
|
181
273
|
await deps.transport.sendText(turn.chatId, html);
|
|
182
274
|
return;
|
|
183
275
|
}
|
|
184
276
|
try {
|
|
185
277
|
await deps.transport.editText(turn.chatId, turn.replaceMessageId, html);
|
|
186
|
-
} catch {
|
|
278
|
+
} catch (err) {
|
|
187
279
|
// Edit failed (message deleted / too many edits). Fall back to a new message.
|
|
280
|
+
renderLog.debug("editText failed; falling back to sendText", { chatId: turn.chatId, messageId: turn.replaceMessageId, err });
|
|
188
281
|
turn.replaceMessageId = undefined;
|
|
189
282
|
await deps.transport.sendText(turn.chatId, html);
|
|
190
283
|
}
|
|
@@ -208,7 +301,7 @@ export function registerTelegramRenderer(
|
|
|
208
301
|
const turn = deps.getActiveTurn();
|
|
209
302
|
if (!turn) return;
|
|
210
303
|
if (turn.replaceMessageId !== undefined) await deps.transport.editText(turn.chatId, turn.replaceMessageId, "๐ค <b>Workingโฆ</b>");
|
|
211
|
-
} catch {
|
|
304
|
+
} catch (err) { renderLog.warn("render status-clear handler failed", { err }); }
|
|
212
305
|
});
|
|
213
306
|
|
|
214
307
|
pi.on("tool_execution_start", async (event) => {
|
|
@@ -221,7 +314,7 @@ export function registerTelegramRenderer(
|
|
|
221
314
|
: `๐ง ${event.toolName} started
|
|
222
315
|
${stringifyShort(event.args, 1200)}`;
|
|
223
316
|
await sendInlineEvent(inline);
|
|
224
|
-
} catch {
|
|
317
|
+
} catch (err) { renderLog.warn("render tool_execution_start handler failed", { err }); }
|
|
225
318
|
});
|
|
226
319
|
|
|
227
320
|
pi.on("tool_execution_update", async (event) => {
|
|
@@ -236,7 +329,7 @@ ${stringifyShort(event.args, 1200)}`;
|
|
|
236
329
|
if (!partial || partial === "{}") return;
|
|
237
330
|
await sendInlineEvent(`๐ ${event.toolName} update
|
|
238
331
|
${partial}`);
|
|
239
|
-
} catch {
|
|
332
|
+
} catch (err) { renderLog.warn("render tool_execution_update handler failed", { err }); }
|
|
240
333
|
});
|
|
241
334
|
|
|
242
335
|
pi.on("tool_execution_end", async (event) => {
|
|
@@ -247,17 +340,33 @@ ${partial}`);
|
|
|
247
340
|
toolArgs.delete(event.toolCallId);
|
|
248
341
|
if (level === "hidden") return;
|
|
249
342
|
const status = event.isError ? "โ Tool failed" : "โ
Tool finished";
|
|
250
|
-
const result = stringifyShort(event.result, event.isError ? 1800 : 900);
|
|
251
343
|
if (level === "brief") {
|
|
252
344
|
if (!event.isError) return;
|
|
253
345
|
await sendInlineEvent(formatToolFailureBrief(event.toolName, event.result, args));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// full mode: render the tool's actual output (text + images) as a
|
|
349
|
+
// persistent new message, so users who opt into `full` see complete
|
|
350
|
+
// content instead of a truncated JSON blob. Tool results are excluded
|
|
351
|
+
// from the "never fold" rule, so long ones use an expandable blockquote.
|
|
352
|
+
const parts = extractToolResultParts(event.result);
|
|
353
|
+
const header = `${event.isError ? "โ" : "โ
"} <b>${escapeHtml(event.toolName)}</b>`;
|
|
354
|
+
if (parts.body.trim()) {
|
|
355
|
+
const rendered = markdownToTelegramHtml(parts.body);
|
|
356
|
+
const expandable = rendered.length > 600 || rendered.split("\n").length > 8;
|
|
357
|
+
const tag = expandable ? "<blockquote expandable>" : "<blockquote>";
|
|
358
|
+
await send(`${tag}${header}\n${rendered}</blockquote>`);
|
|
254
359
|
} else {
|
|
255
|
-
await sendInlineEvent(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
360
|
+
await sendInlineEvent(`${status}: ${event.toolName}`);
|
|
361
|
+
}
|
|
362
|
+
for (const image of parts.images) {
|
|
363
|
+
const chatIds = currentChats();
|
|
364
|
+
for (const chatId of chatIds) {
|
|
365
|
+
await deps.transport.sendChatAction(chatId, "upload_photo");
|
|
366
|
+
await deps.transport.sendPhoto(chatId, image.data, "image").catch(renderLog.swallow("warn", "sendPhoto failed", { chatId }));
|
|
367
|
+
}
|
|
259
368
|
}
|
|
260
|
-
} catch {
|
|
369
|
+
} catch (err) { renderLog.warn("tool-result image upload failed", { err }); }
|
|
261
370
|
});
|
|
262
371
|
|
|
263
372
|
pi.on("message_end", async (event) => {
|
|
@@ -269,13 +378,31 @@ ${result}`
|
|
|
269
378
|
const toolLevel = renderLevel(config, "tool");
|
|
270
379
|
const rendered = contentToRenderParts(message.content, thinkingLevel, toolLevel);
|
|
271
380
|
await sendInlineEvents(rendered.inlineEvents);
|
|
272
|
-
const
|
|
381
|
+
const rawBody = rendered.body || message.errorMessage || "";
|
|
382
|
+
// Pull oversized code blocks out of the body before rendering: they'd
|
|
383
|
+
// otherwise be split across many <pre> messages (painful on mobile). They
|
|
384
|
+
// are sent as downloadable files after the body.
|
|
385
|
+
const { body, blocks: codeFiles } = extractOversizedCodeBlocks(rawBody);
|
|
273
386
|
const images = contentImages(message.content);
|
|
274
387
|
|
|
275
388
|
const hasBody = body.trim().length > 0;
|
|
276
389
|
if (hasBody) await sendToTurn(markdownToTelegramHtml(body), { final: true });
|
|
277
390
|
|
|
278
391
|
const turn = deps.getActiveTurn();
|
|
392
|
+
if (codeFiles.length > 0) {
|
|
393
|
+
const chatIds = turn ? [turn.chatId] : currentChats();
|
|
394
|
+
for (const chatId of chatIds) {
|
|
395
|
+
for (const block of codeFiles) {
|
|
396
|
+
const filePath = await writeTempCodeFile(block.fileName, block.content).catch(renderLog.swallow("warn", "writeTempCodeFile failed", { fileName: block.fileName }));
|
|
397
|
+
if (!filePath) continue;
|
|
398
|
+
await deps.transport.sendChatAction(chatId, "upload_document");
|
|
399
|
+
const lines = block.content.split("\n").length;
|
|
400
|
+
const caption = `๐ ${block.lang || "code"} block (${lines} lines)`;
|
|
401
|
+
await deps.transport.sendDocument(chatId, filePath, caption).catch(renderLog.swallow("warn", "sendDocument code block failed", { chatId, fileName: block.fileName }));
|
|
402
|
+
await rm(filePath, { force: true }).catch(renderLog.swallow("debug", "rm temp code file failed", { filePath }));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
279
406
|
for (const image of images) {
|
|
280
407
|
const chatIds = turn ? [turn.chatId] : currentChats();
|
|
281
408
|
for (const chatId of chatIds) {
|
|
@@ -288,7 +415,7 @@ ${result}`
|
|
|
288
415
|
await deps.transport.editText(turn.chatId, turn.replaceMessageId, `โ
<b>Output sent.</b>\n${noun}`);
|
|
289
416
|
turn.replaceMessageId = undefined;
|
|
290
417
|
}
|
|
291
|
-
} catch {
|
|
418
|
+
} catch (err) { renderLog.warn("render assistant_message handler failed", { err }); }
|
|
292
419
|
});
|
|
293
420
|
|
|
294
421
|
}
|
package/lib/telegram-api.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, extname } from "node:path";
|
|
3
3
|
import type { TelegramButton, TelegramConfig, TelegramSentMessage, TelegramTransport, TelegramUpdate } from "./types.ts";
|
|
4
|
-
import { stripHtml,
|
|
4
|
+
import { stripHtml, splitTelegramHtml } from "./text-split.ts";
|
|
5
|
+
import { log } from "./logger.ts";
|
|
6
|
+
|
|
7
|
+
const apiLog = log.child("telegram-api");
|
|
5
8
|
|
|
6
9
|
type TelegramFileInfo = {
|
|
7
10
|
file_id: string;
|
|
@@ -141,20 +144,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
141
144
|
return token;
|
|
142
145
|
};
|
|
143
146
|
|
|
147
|
+
/** True when an error represents a network-level failure (fetch rejected,
|
|
148
|
+
* DNS, connection refused/timeout, โฆ). When the bot is unreachable the
|
|
149
|
+
* plain-text fallback retry will fail identically, so callers should skip
|
|
150
|
+
* it and let existing `.catch(swallow(...))` / status-line reporting
|
|
151
|
+
* surface the outage instead of pointlessly retrying (and logging). */
|
|
152
|
+
const isNetworkError = (err: unknown): boolean => {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
// telegramApi wraps raw fetch failures as "Telegram API request failed: <cause>".
|
|
155
|
+
if (msg.startsWith("Telegram API request failed")) return true;
|
|
156
|
+
// sendDocument/sendPhoto wrap as "Telegram sendX failed: <cause>"; direct
|
|
157
|
+
// Node fetch errors also surface with these signatures.
|
|
158
|
+
return /\bfetch failed\b|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|UND_ERR|socket hang up|network(?:\s|_)?error/i.test(msg);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Log why an HTML-format send failed before falling back to plain text.
|
|
162
|
+
* Without this the renderer's failures were completely silent โ a single
|
|
163
|
+
* unsupported tag nuked the whole message's formatting and nobody noticed.
|
|
164
|
+
*
|
|
165
|
+
* Network-level failures are intentionally NOT logged here: the polling
|
|
166
|
+
* loop already reports them via the status line / ui.notify, and logging
|
|
167
|
+
* every failed send when the bot is simply offline would only noise up the
|
|
168
|
+
* log file. */
|
|
169
|
+
const warnHtmlFallback = (label: string, err: unknown, preview: string) => {
|
|
170
|
+
if (isNetworkError(err)) return;
|
|
171
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
172
|
+
const snippet = preview.replace(/\s+/g, " ").slice(0, 120);
|
|
173
|
+
apiLog.warn(`HTML ${label} rejected; falling back to plain text`, { reason, snippet });
|
|
174
|
+
};
|
|
175
|
+
|
|
144
176
|
return {
|
|
145
177
|
async sendText(chatId, text) {
|
|
146
178
|
const sent: TelegramSentMessage[] = [];
|
|
147
|
-
|
|
179
|
+
// Use the semantic HTML splitter so multi-message sends cut at block
|
|
180
|
+
// boundaries (every chunk is independently valid Telegram HTML) instead
|
|
181
|
+
// of the legacy byte splitter that could split mid-<pre>/<blockquote>
|
|
182
|
+
// and force a plain-text fallback.
|
|
183
|
+
for (const chunk of splitTelegramHtml(text)) {
|
|
148
184
|
const body = {
|
|
149
185
|
chat_id: chatId,
|
|
150
186
|
text: chunk,
|
|
151
187
|
parse_mode: "HTML",
|
|
152
188
|
};
|
|
153
189
|
const msg = await callApi<TelegramSentMessage>("sendMessage", body)
|
|
154
|
-
.catch(() =>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
.catch((err) => {
|
|
191
|
+
// Network failure: the plain-text retry will fail identically.
|
|
192
|
+
// Propagate so the caller's `.catch` (or renderer suppression)
|
|
193
|
+
// handles it; the polling status line already reports outages.
|
|
194
|
+
if (isNetworkError(err)) throw err;
|
|
195
|
+
warnHtmlFallback("sendMessage", err, chunk);
|
|
196
|
+
return callApi<TelegramSentMessage>("sendMessage", {
|
|
197
|
+
chat_id: chatId,
|
|
198
|
+
text: stripHtml(chunk),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
158
201
|
sent.push(msg);
|
|
159
202
|
}
|
|
160
203
|
return sent;
|
|
@@ -164,48 +207,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
164
207
|
// Button messages cannot be split without duplicating keyboards, so keep
|
|
165
208
|
// title text short. The UI layer already truncates button labels.
|
|
166
209
|
const reply_markup = buildInlineKeyboard(rows);
|
|
167
|
-
const first =
|
|
210
|
+
const first = splitTelegramHtml(text)[0];
|
|
168
211
|
return await callApi<TelegramSentMessage>("sendMessage", {
|
|
169
212
|
chat_id: chatId,
|
|
170
213
|
text: first,
|
|
171
214
|
parse_mode: "HTML",
|
|
172
215
|
reply_markup,
|
|
173
|
-
}).catch(() =>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
}).catch((err) => {
|
|
217
|
+
if (isNetworkError(err)) throw err;
|
|
218
|
+
warnHtmlFallback("sendButtons", err, first);
|
|
219
|
+
return callApi<TelegramSentMessage>("sendMessage", {
|
|
220
|
+
chat_id: chatId,
|
|
221
|
+
text: stripHtml(first),
|
|
222
|
+
reply_markup,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
178
225
|
},
|
|
179
226
|
|
|
180
227
|
async editText(chatId, messageId, text) {
|
|
181
|
-
const first =
|
|
228
|
+
const first = splitTelegramHtml(text)[0];
|
|
182
229
|
await callApi("editMessageText", {
|
|
183
230
|
chat_id: chatId,
|
|
184
231
|
message_id: messageId,
|
|
185
232
|
text: first,
|
|
186
233
|
parse_mode: "HTML",
|
|
187
|
-
}).catch(() =>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
234
|
+
}).catch((err) => {
|
|
235
|
+
if (isNetworkError(err)) return; // swallow; nothing useful to retry
|
|
236
|
+
warnHtmlFallback("editMessageText", err, first);
|
|
237
|
+
return callApi("editMessageText", {
|
|
238
|
+
chat_id: chatId,
|
|
239
|
+
message_id: messageId,
|
|
240
|
+
text: stripHtml(first),
|
|
241
|
+
}).catch(apiLog.swallow("debug", "editMessageText plain-text fallback failed", { chatId, messageId }));
|
|
242
|
+
});
|
|
192
243
|
},
|
|
193
244
|
|
|
194
245
|
async editButtons(chatId, messageId, text, rows) {
|
|
195
246
|
const reply_markup = buildInlineKeyboard(rows);
|
|
196
|
-
const first =
|
|
247
|
+
const first = splitTelegramHtml(text)[0];
|
|
197
248
|
await callApi("editMessageText", {
|
|
198
249
|
chat_id: chatId,
|
|
199
250
|
message_id: messageId,
|
|
200
251
|
text: first,
|
|
201
252
|
parse_mode: "HTML",
|
|
202
253
|
reply_markup,
|
|
203
|
-
}).catch(() =>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
254
|
+
}).catch((err) => {
|
|
255
|
+
if (isNetworkError(err)) return; // swallow; nothing useful to retry
|
|
256
|
+
warnHtmlFallback("editButtons", err, first);
|
|
257
|
+
return callApi("editMessageText", {
|
|
258
|
+
chat_id: chatId,
|
|
259
|
+
message_id: messageId,
|
|
260
|
+
text: stripHtml(first),
|
|
261
|
+
reply_markup,
|
|
262
|
+
}).catch(apiLog.swallow("debug", "editButtons plain-text fallback failed", { chatId, messageId }));
|
|
263
|
+
});
|
|
209
264
|
},
|
|
210
265
|
|
|
211
266
|
async answerCallbackQuery(callbackQueryId, text) {
|
|
@@ -220,14 +275,14 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
220
275
|
chat_id: chatId,
|
|
221
276
|
message_id: messageId,
|
|
222
277
|
reply_markup: { inline_keyboard: [] },
|
|
223
|
-
}).catch((
|
|
278
|
+
}).catch(apiLog.swallow("debug", "removeInlineKeyboard failed", { chatId, messageId }));
|
|
224
279
|
},
|
|
225
280
|
|
|
226
281
|
async deleteMessage(chatId, messageId) {
|
|
227
282
|
await callApi("deleteMessage", {
|
|
228
283
|
chat_id: chatId,
|
|
229
284
|
message_id: messageId,
|
|
230
|
-
}).catch((
|
|
285
|
+
}).catch(apiLog.swallow("warn", "deleteMessage failed", { chatId, messageId }));
|
|
231
286
|
},
|
|
232
287
|
|
|
233
288
|
async sendDocument(chatId, path, caption, signal) {
|
|
@@ -313,7 +368,7 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
313
368
|
await telegramApi(requireToken(), "sendChatAction", {
|
|
314
369
|
chat_id: chatId,
|
|
315
370
|
action,
|
|
316
|
-
}).catch((
|
|
371
|
+
}).catch(apiLog.swallow("debug", "sendChatAction failed", { chatId, action }));
|
|
317
372
|
},
|
|
318
373
|
};
|
|
319
374
|
}
|
package/lib/telegram-ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { encodeUiCallback } from "./callback-protocol.ts";
|
|
3
|
+
import { bridgeCustomDialog } from "./custom-dialogs.ts";
|
|
3
4
|
import { escapeHtml } from "./html.ts";
|
|
4
5
|
import type { CapturedAgentSession, PendingInputResolver, TelegramTransport } from "./types.ts";
|
|
5
6
|
|
|
@@ -84,14 +85,29 @@ export function createTelegramUiRuntime(deps: {
|
|
|
84
85
|
|
|
85
86
|
return {
|
|
86
87
|
create(chatId) {
|
|
87
|
-
|
|
88
|
+
// base is the real TUI UI context (create() is called BEFORE runWithTelegramUi
|
|
89
|
+
// swaps the UI context, so this is the genuine TUI context). We forward persistent
|
|
90
|
+
// /stateful methods to base so the TUI stays accurate even when the trigger came
|
|
91
|
+
// from Telegram. Interactive modals stay Telegram-only; editor ops are no-ops.
|
|
92
|
+
const base = deps.getSession()?.extensionRunner.getUIContext?.() as ExtensionUIContext | undefined;
|
|
93
|
+
|
|
94
|
+
const notifyFn = (message: string, level: "info" | "warning" | "error" = "info") => {
|
|
95
|
+
const fid = activeFlowByChat.get(chatId);
|
|
96
|
+
void sendOrReplaceText(chatId, `<b>${escapeHtml(String(level))}</b>\n${escapeHtml(message)}`, fid);
|
|
97
|
+
};
|
|
98
|
+
|
|
88
99
|
return {
|
|
89
|
-
...(base as ExtensionUIContext),
|
|
90
100
|
chatId,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
|
|
102
|
+
// ---- Interactive modals: Telegram-only (do NOT forward to base) ----
|
|
103
|
+
// Plan Layer A: "่ฐ่งฆๅ็ turn ๅฐฑ็ป่ฐ". A Telegram-triggered turn must NOT
|
|
104
|
+
// also pop the modal in the local TUI, because ExtensionUIContext.custom and
|
|
105
|
+
// the other modals expose no external cancel handle โ once the Telegram side
|
|
106
|
+
// resolves, any TUI-side component we mounted could never be dismissed, so
|
|
107
|
+
// the local TUI would stay "stuck at the selection". Local TUI turns never
|
|
108
|
+
// enter runWithTelegramUi, so they keep using the real TUI UIContext and are
|
|
109
|
+
// completely unaffected by this Telegram-only path.
|
|
110
|
+
notify: notifyFn,
|
|
95
111
|
confirm: async (title, message) => {
|
|
96
112
|
const flowId = beginFlow();
|
|
97
113
|
activeFlowByChat.set(chatId, flowId);
|
|
@@ -100,6 +116,7 @@ export function createTelegramUiRuntime(deps: {
|
|
|
100
116
|
]], flowId);
|
|
101
117
|
const value = await waitInput(chatId, flowId, false, false, sent.message_id);
|
|
102
118
|
activeFlowByChat.delete(chatId);
|
|
119
|
+
void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
|
|
103
120
|
return value === true || value === "yes";
|
|
104
121
|
},
|
|
105
122
|
input: async (title, placeholder) => {
|
|
@@ -108,6 +125,7 @@ export function createTelegramUiRuntime(deps: {
|
|
|
108
125
|
const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
|
|
109
126
|
const value = await waitInput(chatId, flowId, false, true, sent.message_id);
|
|
110
127
|
activeFlowByChat.delete(chatId);
|
|
128
|
+
void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
|
|
111
129
|
return typeof value === "string" ? value : undefined;
|
|
112
130
|
},
|
|
113
131
|
inputSecret: async (title: string, placeholder?: string) => {
|
|
@@ -116,6 +134,7 @@ export function createTelegramUiRuntime(deps: {
|
|
|
116
134
|
const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
|
|
117
135
|
const value = await waitInput(chatId, flowId, true, true, sent.message_id);
|
|
118
136
|
activeFlowByChat.delete(chatId);
|
|
137
|
+
void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
|
|
119
138
|
return typeof value === "string" ? value : undefined;
|
|
120
139
|
},
|
|
121
140
|
editor: async (title, prefill) => {
|
|
@@ -124,6 +143,7 @@ export function createTelegramUiRuntime(deps: {
|
|
|
124
143
|
const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${prefill ? `\n${escapeHtml(prefill)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
|
|
125
144
|
const value = await waitInput(chatId, flowId, false, true, sent.message_id);
|
|
126
145
|
activeFlowByChat.delete(chatId);
|
|
146
|
+
void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
|
|
127
147
|
return typeof value === "string" ? value : undefined;
|
|
128
148
|
},
|
|
129
149
|
select: async (title, options) => {
|
|
@@ -140,15 +160,74 @@ export function createTelegramUiRuntime(deps: {
|
|
|
140
160
|
const suffix = pageCount > 1 ? ` (${page + 1}/${pageCount})` : "";
|
|
141
161
|
const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title + suffix)}</b>`, rows, flowId);
|
|
142
162
|
const value = await waitInput(chatId, flowId, false, false, sent.message_id);
|
|
143
|
-
if (typeof value !== "string") { activeFlowByChat.delete(chatId); return undefined; }
|
|
144
|
-
if (value === "cancel") { activeFlowByChat.delete(chatId); return undefined; }
|
|
163
|
+
if (typeof value !== "string") { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return undefined; }
|
|
164
|
+
if (value === "cancel") { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return undefined; }
|
|
145
165
|
if (value.startsWith("p:")) { const next = parseInt(value.slice(2), 10); if (next >= 0 && next < pageCount) page = next; continue; }
|
|
146
|
-
if (value.startsWith("s:")) { const idx = parseInt(value.slice(2), 10); activeFlowByChat.delete(chatId); return idx >= 0 && idx < options.length ? options[idx] : undefined; }
|
|
147
|
-
if (options.includes(value)) { activeFlowByChat.delete(chatId); return value; }
|
|
166
|
+
if (value.startsWith("s:")) { const idx = parseInt(value.slice(2), 10); activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return idx >= 0 && idx < options.length ? options[idx] : undefined; }
|
|
167
|
+
if (options.includes(value)) { activeFlowByChat.delete(chatId); void deps.transport.removeInlineKeyboard(chatId, sent.message_id); return value; }
|
|
148
168
|
activeFlowByChat.delete(chatId);
|
|
169
|
+
void deps.transport.removeInlineKeyboard(chatId, sent.message_id);
|
|
149
170
|
return undefined;
|
|
150
171
|
}
|
|
151
172
|
},
|
|
173
|
+
// custom: bridge pi-goal dialogs to Telegram buttons (Layer B). Does NOT
|
|
174
|
+
// forward to base โ see the modals comment above for why racing the TUI is
|
|
175
|
+
// structurally un-dismissible and would leave the local TUI stuck.
|
|
176
|
+
custom: async <T>(factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any | Promise<any>, _options?: any): Promise<T> => {
|
|
177
|
+
const flowId = beginFlow();
|
|
178
|
+
activeFlowByChat.set(chatId, flowId);
|
|
179
|
+
let promptMessageId: number | undefined;
|
|
180
|
+
const result = await bridgeCustomDialog<T>({
|
|
181
|
+
factory,
|
|
182
|
+
theme: base?.theme,
|
|
183
|
+
width: 80,
|
|
184
|
+
sendButtons: async (text, rows) => {
|
|
185
|
+
const encodedRows = rows.map((row) => row.map((btn) => ({ text: btn.text, value: cb(flowId, btn.value) })));
|
|
186
|
+
const sent = await sendOrReplaceButtons(chatId, text, encodedRows, flowId);
|
|
187
|
+
promptMessageId = sent.message_id;
|
|
188
|
+
return sent;
|
|
189
|
+
},
|
|
190
|
+
waitInput: (acceptsText = false, sensitive = false) =>
|
|
191
|
+
waitInput(chatId, flowId, sensitive, acceptsText, promptMessageId),
|
|
192
|
+
notify: notifyFn,
|
|
193
|
+
removeKeyboard: async () => { if (promptMessageId) await deps.transport.removeInlineKeyboard(chatId, promptMessageId); },
|
|
194
|
+
});
|
|
195
|
+
activeFlowByChat.delete(chatId);
|
|
196
|
+
// pi-goal accesses result.cancelled/answers without an undefined-guard, so
|
|
197
|
+
// never resolve undefined: fall back to a structured cancelled result.
|
|
198
|
+
return (result ?? ({ questions: [], answers: [], cancelled: true } as unknown as T)) as T;
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
// ---- Persistent/stateful UI: forward to TUI base (keeps TUI accurate) ----
|
|
202
|
+
setStatus: (key: string, text: string | undefined) => { base?.setStatus?.(key, text); },
|
|
203
|
+
setWorkingMessage: (message?: string) => { base?.setWorkingMessage?.(message); },
|
|
204
|
+
setWorkingVisible: (visible: boolean) => { base?.setWorkingVisible?.(visible); },
|
|
205
|
+
setWorkingIndicator: (options?: { frames?: string[]; intervalMs?: number }) => { base?.setWorkingIndicator?.(options); },
|
|
206
|
+
setHiddenThinkingLabel: (label?: string) => { base?.setHiddenThinkingLabel?.(label); },
|
|
207
|
+
setWidget: ((key: string, content: unknown, options?: unknown) => { base?.setWidget?.(key as string, content as any, options as any); }) as ExtensionUIContext["setWidget"],
|
|
208
|
+
setFooter: (factory: unknown) => { base?.setFooter?.(factory as any); },
|
|
209
|
+
setHeader: (factory: unknown) => { base?.setHeader?.(factory as any); },
|
|
210
|
+
setTitle: (title: string) => { base?.setTitle?.(title); },
|
|
211
|
+
setToolsExpanded: (expanded: boolean) => { base?.setToolsExpanded?.(expanded); },
|
|
212
|
+
getToolsExpanded: () => base?.getToolsExpanded?.() ?? false,
|
|
213
|
+
setTheme: (theme: string | unknown) => base?.setTheme?.(theme as any) ?? { success: false, error: "UI not available" },
|
|
214
|
+
getTheme: (name: string) => base?.getTheme?.(name),
|
|
215
|
+
getAllThemes: () => base?.getAllThemes?.() ?? [],
|
|
216
|
+
|
|
217
|
+
// ---- Editor/terminal: no-ops (remote turns must not touch local editor) ----
|
|
218
|
+
onTerminalInput: () => () => {},
|
|
219
|
+
pasteToEditor: () => {},
|
|
220
|
+
setEditorText: () => {},
|
|
221
|
+
getEditorText: () => "",
|
|
222
|
+
setEditorComponent: () => {},
|
|
223
|
+
getEditorComponent: () => undefined,
|
|
224
|
+
addAutocompleteProvider: () => {},
|
|
225
|
+
|
|
226
|
+
// ---- Theme getter: delegate to base (factory needs theme.fg/bg) ----
|
|
227
|
+
// base is always defined when create() runs (create() is called before the
|
|
228
|
+
// runWithTelegramUi UI swap, per plan ยง2.1). The non-null assertion makes
|
|
229
|
+
// this precondition explicit instead of hiding it behind a cast.
|
|
230
|
+
get theme() { return base!.theme; },
|
|
152
231
|
};
|
|
153
232
|
},
|
|
154
233
|
resolveInput(chatId, raw, replyToMessageId, fromCallback = false) {
|