pi-telegram-plus 0.0.1 → 0.0.2

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # pi-telegram-plus
2
2
 
3
+ <p>
4
+ <a href="https://www.npmjs.com/package/pi-telegram-plus"><img src="https://img.shields.io/npm/v/pi-telegram-plus?style=flat-square&logo=npm" alt="npm" /></a>
5
+ <a href="https://github.com/jalyfeng/pi-telegram-plus"><img src="https://img.shields.io/github/license/jalyfeng/pi-telegram-plus?style=flat-square" alt="license" /></a>
6
+ <img src="https://img.shields.io/badge/node-%3E%3D22.19.0-339933?style=flat-square&logo=node.js" alt="node" />
7
+ </p>
8
+
3
9
  **Full Telegram control of [pi coding agent](https://github.com/earendil-works/pi-coding-agent) — commands, interactive UI, model/session management, file transfer, and real-time streaming output, all from Telegram.**
4
10
 
5
11
  `pi-telegram-plus` is a pi extension that turns Telegram into a full-featured remote control surface for the pi coding agent. It's not just a notification bot — it mirrors the core pi TUI experience into Telegram, with interactive menus, inline keyboards, file attachments, and live agent output rendering.
@@ -91,6 +97,7 @@ Full interactive UI components built on inline keyboards:
91
97
  | `/cwd` | Show current working directory |
92
98
  | `/cd` | Switch pi working directory |
93
99
  | `/stop` | Abort the current agent turn |
100
+ | `/status` | Show runtime snapshot (workspace, model, context, messages) |
94
101
  | `/debug` | Show debug info (model, thinking, streaming, entries) |
95
102
  | `/settings` | Open settings menu |
96
103
  | `/copy` | Copy last assistant text |
@@ -112,25 +119,23 @@ Full interactive UI components built on inline keyboards:
112
119
  - [pi coding agent](https://github.com/earendil-works/pi-coding-agent) installed globally
113
120
  - A Telegram bot token from [@BotFather](https://t.me/BotFather)
114
121
 
115
- ### Setup
122
+ ### Installation
116
123
 
117
- 1. **Clone or create the extension directory:**
124
+ **Install via npm (recommended):**
118
125
 
119
126
  ```bash
120
- git clone <repo-url> pi-telegram-plus
121
- cd pi-telegram-plus
122
- npm install
127
+ pi install npm:pi-telegram-plus
123
128
  ```
124
129
 
125
- 2. **Configure pi to load the extension:**
130
+ **Or install from source:**
126
131
 
127
132
  ```bash
128
- # Inside the pi-telegram-plus directory
133
+ git clone https://github.com/jalyfeng/pi-telegram-plus.git
134
+ cd pi-telegram-plus
135
+ npm install
129
136
  pi packages add .
130
137
  ```
131
138
 
132
- This registers the extension via `.pi/settings.json`.
133
-
134
139
  3. **Start pi and configure your Telegram bot:**
135
140
 
136
141
  ```bash
@@ -269,7 +274,7 @@ pi-telegram-plus/
269
274
  │ ├── model.ts # /model, /scoped-models, /thinking
270
275
  │ ├── session.ts # /new, /fork, /clone, /tree, /resume, /cd, /cwd, /name, /session
271
276
  │ ├── auth.ts # /login (OAuth + API key), /logout
272
- │ ├── info.ts # /copy, /export, /import, /share, /changelog, /hotkeys, /debug
277
+ │ ├── info.ts # /copy, /export, /import, /share, /status, /changelog, /hotkeys, /debug
273
278
  │ ├── lifecycle.ts # /compact, /reload, /stop, /quit
274
279
  │ ├── settings.ts # /settings menu
275
280
  │ ├── tg-config.ts # /tg-config
package/index.ts CHANGED
@@ -88,7 +88,11 @@ export default function piTelegramPlus(pi: ExtensionAPI): void {
88
88
  refreshStatus();
89
89
  };
90
90
 
91
- const isTelegramEnabled = (): boolean => config.telegramEnabled ?? resolvedConfig?.scope !== "global";
91
+ const isTelegramEnabled = (): boolean => {
92
+ if (config.telegramEnabled !== undefined) return config.telegramEnabled;
93
+ // Default: workspace binding implies intent to use; global requires explicit enable.
94
+ return resolvedConfig?.scope === "workspace";
95
+ };
92
96
 
93
97
  const transport = createTelegramTransport(() => config);
94
98
  const ui = createTelegramUiRuntime({
@@ -121,11 +125,11 @@ export default function piTelegramPlus(pi: ExtensionAPI): void {
121
125
  // Pi built-in commands (model, session, new, etc.) are already registered by pi core.
122
126
  const TUI_VISIBLE_COMMANDS = new Set([
123
127
  // tg-* commands
124
- "tg-setup", "tg-connect", "tg-disconnect", "tg-config",
125
- "tg-bind-cwd", "tg-unbind-cwd", "tg-list",
128
+ "tg-global-setup", "tg-global-connect", "tg-global-disconnect", "tg-config",
129
+ "tg-bind-cwd", "tg-unbind-cwd", "tg-cwd-connect", "tg-cwd-disconnect", "tg-list",
126
130
  // other pi-telegram-plus custom commands (TUI-only command list excludes /import, which is now
127
131
  // a built-in pi command; keep Telegram handler registration only.
128
- "cwd", "cd", "thinking", "stop", "debug",
132
+ "cwd", "cd", "status", "thinking", "stop", "debug",
129
133
  ]);
130
134
 
131
135
  registerAllCommands({
@@ -135,7 +139,10 @@ export default function piTelegramPlus(pi: ExtensionAPI): void {
135
139
  pi.registerCommand(name, { description: options.description, handler: options.handler });
136
140
  }
137
141
  },
138
- }, sessionDeps, sessionNameDeps, tgConfigDeps);
142
+ }, sessionDeps, sessionNameDeps, tgConfigDeps, {
143
+ getTransport: () => transport,
144
+ getActiveChatId: () => config.activeChatId,
145
+ });
139
146
 
140
147
  registerTelegramCommands({
141
148
  registerCommand: (name: string, options: { description?: string; handler: TelegramCommandHandler }) => {
@@ -300,7 +307,7 @@ export default function piTelegramPlus(pi: ExtensionAPI): void {
300
307
  } catch (error) {
301
308
  switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
302
309
  getActiveSession()?.extensionRunner.getUIContext().notify(
303
- `Telegram config is not v2 yet. Run /tg-setup to recreate it. ${error instanceof Error ? error.message : String(error)}`,
310
+ `Telegram config is not v2 yet. Run /tg-global-setup to recreate it. ${error instanceof Error ? error.message : String(error)}`,
304
311
  "error",
305
312
  );
306
313
  }
@@ -1,8 +1,83 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { CommandRegistry } from "./register.ts";
3
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
3
  import { escapeHtml } from "../html.ts";
5
- import type { CapturedAgentSession } from "../types.ts";
4
+ import type { CapturedAgentSession, TelegramTransport } from "../types.ts";
5
+
6
+ function formatFooterLikeTokenCount(value: number): string {
7
+ if (value < 1_000) return value.toString();
8
+ if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`;
9
+ if (value < 1_000_000) return `${Math.round(value / 1_000)}k`;
10
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
11
+ return `${Math.round(value / 1_000_000)}M`;
12
+ }
13
+
14
+ const STATUS_TEXT_LIMIT = 200;
15
+
16
+ function truncateStatusText(value: string | undefined): string | undefined {
17
+ if (value === undefined) return undefined;
18
+ if (value.length <= STATUS_TEXT_LIMIT) return value;
19
+ return `${value.slice(0, STATUS_TEXT_LIMIT - 1)}…`;
20
+ }
21
+
22
+ /**
23
+ * @internal Exported for tests; not part of the public module API.
24
+ * Keeps the rendered snapshot under Telegram's 4096-byte (and `splitTelegramText`'s
25
+ * 3600-byte single-chunk) limit by capping free-form text fields up front.
26
+ */
27
+ export function buildStatusSnapshot(session: CapturedAgentSession): string {
28
+ const stats = session.getSessionStats();
29
+ const usage = session.getContextUsage();
30
+ const model = session.model;
31
+ const safeCwd = truncateStatusText(session.sessionManager.getCwd()) ?? "";
32
+ const safeSessionId = truncateStatusText(stats.sessionId) ?? "";
33
+ const safeSessionFile = truncateStatusText(stats.sessionFile);
34
+ const safeSessionName = truncateStatusText(session.sessionManager.getSessionName());
35
+ const contextWindow = usage?.contextWindow ?? 0;
36
+ const contextPercent = usage?.percent;
37
+
38
+ const stateBadge = session.isStreaming
39
+ ? "🟢 active"
40
+ : session.pendingMessageCount > 0
41
+ ? "🟡 queueing"
42
+ : "⚪ idle";
43
+
44
+ const contextDisplay = contextPercent === null || contextPercent === undefined
45
+ ? `?/${contextWindow}`
46
+ : `${contextPercent.toFixed(1)}%/${contextWindow}`;
47
+
48
+ // Labels are hard-coded, so escaping is unnecessary; only the value is dynamic.
49
+ const line = (label: string, value: string): string =>
50
+ ` <b>${label}</b> ${escapeHtml(value)}`;
51
+
52
+ return [
53
+ "<b>🛰 TUI Status</b>",
54
+ "━━━━━━━━━━━━━━━━━━━━",
55
+ "<b>📂 Workspace</b>",
56
+ line("cwd", safeCwd),
57
+ line("session", safeSessionId + (safeSessionName ? ` • ${safeSessionName}` : "")),
58
+ line("file", safeSessionFile ?? "ephemeral"),
59
+ "",
60
+ "<b>🤖 Model</b>",
61
+ line("model", model ? `${model.provider}/${model.id}` : "(none)"),
62
+ line("thinking", session.thinkingLevel),
63
+ line("state", stateBadge),
64
+ line("queued", String(session.pendingMessageCount)),
65
+ "",
66
+ "<b>📊 Context &amp; Tokens</b>",
67
+ line("context", contextDisplay),
68
+ line("in", formatFooterLikeTokenCount(stats.tokens.input)),
69
+ line("out", formatFooterLikeTokenCount(stats.tokens.output)),
70
+ line("cache R", formatFooterLikeTokenCount(stats.tokens.cacheRead)),
71
+ line("cache W", formatFooterLikeTokenCount(stats.tokens.cacheWrite)),
72
+ line("total", formatFooterLikeTokenCount(stats.tokens.total)),
73
+ line("cost", `$${stats.cost.toFixed(4)}`),
74
+ "",
75
+ "<b>💬 Messages</b>",
76
+ line("user", String(stats.userMessages)),
77
+ line("assistant", String(stats.assistantMessages)),
78
+ line("tool calls", String(stats.toolCalls)),
79
+ ].join("\n");
80
+ }
6
81
 
7
82
  /**
8
83
  * All command handlers capture ctx.ui at entry and use the captured reference.
@@ -10,7 +85,13 @@ import type { CapturedAgentSession } from "../types.ts";
10
85
  */
11
86
  export function registerInfoCommands(
12
87
  registry: CommandRegistry,
13
- deps: { getSession: () => CapturedAgentSession | undefined },
88
+ deps: {
89
+ getSession: () => CapturedAgentSession | undefined;
90
+ /** Optional transport for direct sends (bypasses ui.notify wrapping). */
91
+ getTransport?: () => TelegramTransport | undefined;
92
+ /** Optional active chat id used for direct sends. */
93
+ getActiveChatId?: () => number | undefined;
94
+ },
14
95
  ): void {
15
96
  // ── /copy ──────────────────────────────────────────────────────────────
16
97
  registry.registerCommand("copy", {
@@ -27,6 +108,31 @@ export function registerInfoCommands(
27
108
  },
28
109
  });
29
110
 
111
+ // ── /status ───────────────────────────────────────────────────────────
112
+ registry.registerCommand("status", {
113
+ description: "Show runtime snapshot (workspace, model, context, messages)",
114
+ handler: async (_args, ctx) => {
115
+ const ui = ctx.ui;
116
+ const session = deps.getSession();
117
+ if (!session) {
118
+ ui.notify("No active session", "error");
119
+ return;
120
+ }
121
+
122
+ const html = buildStatusSnapshot(session);
123
+ const transport = deps.getTransport?.();
124
+ const chatId = deps.getActiveChatId?.();
125
+ if (transport && chatId !== undefined) {
126
+ await transport.sendText(chatId, html).catch(() => {
127
+ // Fall back to the standard notify path if the direct send fails.
128
+ ui.notify(html.replace(/<[^>]+>/g, ""), "info");
129
+ });
130
+ return;
131
+ }
132
+ ui.notify(html, "info");
133
+ },
134
+ });
135
+
30
136
  // ── /export ────────────────────────────────────────────────────────────
31
137
  registry.registerCommand("export", {
32
138
  description: "Export session to HTML or JSONL",
@@ -1,4 +1,4 @@
1
- import type { CapturedAgentSession } from "../types.ts";
1
+ import type { CapturedAgentSession, TelegramTransport } from "../types.ts";
2
2
  import { registerModelCommands } from "./model.ts";
3
3
  import { registerSessionCommands } from "./session.ts";
4
4
  import { registerAuthCommands } from "./auth.ts";
@@ -29,12 +29,24 @@ export type TgConfigDeps = SessionDeps & {
29
29
  persistConfig: (c: import("../types.ts").TelegramConfig) => Promise<void>;
30
30
  };
31
31
 
32
- export function registerAllCommands(registry: CommandRegistry, sessionDeps: SessionDeps, sessionNameDeps: SessionNameDeps, tgConfigDeps?: TgConfigDeps): void {
32
+ /** Optional deps for commands that need to send preformatted HTML directly. */
33
+ export type InfoDeps = {
34
+ getTransport?: () => TelegramTransport | undefined;
35
+ getActiveChatId?: () => number | undefined;
36
+ };
37
+
38
+ export function registerAllCommands(
39
+ registry: CommandRegistry,
40
+ sessionDeps: SessionDeps,
41
+ sessionNameDeps: SessionNameDeps,
42
+ tgConfigDeps?: TgConfigDeps,
43
+ infoDeps?: InfoDeps,
44
+ ): void {
33
45
  registerSettingsCommands(registry, sessionDeps);
34
46
  registerModelCommands(registry, sessionDeps);
35
47
  registerSessionCommands(registry, sessionNameDeps);
36
48
  registerAuthCommands(registry, sessionDeps);
37
- registerInfoCommands(registry, sessionDeps);
49
+ registerInfoCommands(registry, { ...sessionDeps, ...(infoDeps ?? {}) });
38
50
  registerLifecycleCommands(registry);
39
51
  if (tgConfigDeps) registerTgConfigCommands(registry, tgConfigDeps);
40
52
  }
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { bindWorkspaceTelegramConfig, readTelegramConfigStore, unbindWorkspaceTelegramConfig } from "../config.ts";
2
+ import { bindWorkspaceTelegramConfig, readTelegramConfigStore, unbindWorkspaceTelegramConfig, writeGlobalTelegramConfig } from "../config.ts";
3
3
  import { escapeHtml } from "../html.ts";
4
4
  import { getTelegramBotUsername } from "../telegram-api.ts";
5
5
  import type { ResolvedTelegramConfig, TelegramConfig, TelegramTransport } from "../types.ts";
@@ -20,82 +20,103 @@ export type TelegramCommandDeps = {
20
20
  clearStatusError: () => void;
21
21
  };
22
22
 
23
- /** Shared "connect and start" sequence used by both /tg-setup and /tg-connect. */
24
- async function connectAndStart(
23
+ /** Shared "connect and start" sequence for the global bot. */
24
+ async function globalConnectAndStart(
25
25
  deps: TelegramCommandDeps,
26
26
  token: string,
27
27
  botUsername: string | undefined,
28
28
  ): Promise<void> {
29
29
  const config = deps.getConfig();
30
30
  deps.setConfig({ ...config, botToken: token, botUsername, telegramEnabled: true });
31
- await deps.persistConfig(deps.getConfig());
31
+ await writeGlobalTelegramConfig({ botToken: token, botUsername, telegramEnabled: true });
32
32
  deps.getPolling().start();
33
33
  await deps.syncTelegramCommands();
34
34
  deps.refreshStatus();
35
35
  }
36
36
 
37
- export function configureTelegramToken(
37
+ async function configureGlobalTelegramToken(
38
38
  ui: { input: (title: string, placeholder?: string) => Promise<string | undefined>; inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> },
39
39
  deps: TelegramCommandDeps,
40
40
  ): Promise<boolean> {
41
- return (async () => {
42
- const token = await (ui.inputSecret?.("Telegram bot token") ?? ui.input("Telegram bot token"));
43
- if (!token) return false;
44
- const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
45
- if (!deps.getResolvedConfig()) {
46
- deps.switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
41
+ const token = await (ui.inputSecret?.("Telegram bot token") ?? ui.input("Telegram bot token"));
42
+ if (!token) return false;
43
+ const botUsername = await getTelegramBotUsername(token).catch(() => undefined);
44
+ if (!deps.getResolvedConfig()) {
45
+ deps.switchResolvedConfig({ store: { version: 2, global: {}, workspaces: [] }, scope: "global", config: {} });
46
+ }
47
+ await globalConnectAndStart(deps, token, botUsername);
48
+ return true;
49
+ }
50
+
51
+ // ── Handler implementations for reuse by aliases ──────────────────────────
52
+
53
+ async function handleTgGlobalSetup(
54
+ _args: string,
55
+ ctx: any,
56
+ deps: TelegramCommandDeps,
57
+ ): Promise<void> {
58
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
59
+ if (!(await configureGlobalTelegramToken(ui, deps))) return;
60
+ deps.clearStatusError();
61
+ deps.startStatusHeartbeat();
62
+ ui.notify("Telegram global bot token saved and connected.", "info");
63
+ }
64
+
65
+ async function handleTgGlobalConnect(
66
+ _args: string,
67
+ ctx: any,
68
+ deps: TelegramCommandDeps,
69
+ ): Promise<void> {
70
+ const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
71
+ try {
72
+ if (!deps.getConfig().botToken) {
73
+ if (!(await configureGlobalTelegramToken(ui, deps))) return;
74
+ } else {
75
+ const botUsername = deps.getConfig().botUsername ?? await getTelegramBotUsername(deps.getConfig().botToken!).catch(() => undefined);
76
+ await globalConnectAndStart(deps, deps.getConfig().botToken!, botUsername);
47
77
  }
48
- await connectAndStart(deps, token, botUsername);
49
- return true;
50
- })();
78
+ } catch { /* connection errors reported via polling onError */ }
79
+ deps.clearStatusError();
80
+ deps.startStatusHeartbeat();
81
+ ui.notify("Telegram global bot connected.", "info");
82
+ }
83
+
84
+ async function handleTgGlobalDisconnect(
85
+ _args: string,
86
+ ctx: any,
87
+ deps: TelegramCommandDeps,
88
+ ): Promise<void> {
89
+ await deps.getPolling().stop();
90
+ const config = deps.getConfig();
91
+ deps.setConfig({ ...config, telegramEnabled: false });
92
+ await writeGlobalTelegramConfig({ telegramEnabled: false });
93
+ deps.clearStatusError();
94
+ deps.refreshStatus();
95
+ ctx.ui.notify("Telegram global bot disconnected. Token is kept; use /tg-global-connect to reconnect.", "info");
51
96
  }
52
97
 
98
+ // ── Registration ──────────────────────────────────────────────────────────
99
+
53
100
  export function registerTelegramCommands(
54
101
  registry: { registerCommand: (name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }) => void },
55
102
  deps: TelegramCommandDeps,
56
103
  ): void {
57
- // ── /tg-setup ─────────────────────────────────────────────────────────
58
- registry.registerCommand("tg-setup", {
59
- description: "Configure Telegram bot token",
60
- handler: async (_args, ctx) => {
61
- const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
62
- if (!(await configureTelegramToken(ui, deps))) return;
63
- deps.clearStatusError();
64
- deps.startStatusHeartbeat();
65
- ui.notify("Telegram bot token saved and connected.", "info");
66
- },
104
+ // ── /tg-global-setup ──────────────────────────────────────────────────
105
+ registry.registerCommand("tg-global-setup", {
106
+ description: "Configure global Telegram bot token and connect",
107
+ handler: (args, ctx) => handleTgGlobalSetup(args, ctx, deps),
67
108
  });
68
109
 
69
- // ── /tg-connect ───────────────────────────────────────────────────────
70
- registry.registerCommand("tg-connect", {
71
- description: "Enable/start Telegram connection",
72
- handler: async (_args, ctx) => {
73
- const ui = ctx.ui as typeof ctx.ui & { inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
74
- try {
75
- if (!deps.getConfig().botToken) {
76
- if (!(await configureTelegramToken(ui, deps))) return;
77
- } else {
78
- const botUsername = deps.getConfig().botUsername ?? await getTelegramBotUsername(deps.getConfig().botToken!).catch(() => undefined);
79
- await connectAndStart(deps, deps.getConfig().botToken!, botUsername);
80
- }
81
- } catch { /* connection errors reported via polling onError */ }
82
- deps.clearStatusError();
83
- deps.startStatusHeartbeat();
84
- ui.notify("Telegram connected.", "info");
85
- },
110
+ // ── /tg-global-connect ────────────────────────────────────────────────
111
+ registry.registerCommand("tg-global-connect", {
112
+ description: "Enable/start the global Telegram bot connection",
113
+ handler: (args, ctx) => handleTgGlobalConnect(args, ctx, deps),
86
114
  });
87
115
 
88
- // ── /tg-disconnect ────────────────────────────────────────────────────
89
- registry.registerCommand("tg-disconnect", {
90
- description: "Disable/stop Telegram connection without deleting the token",
91
- handler: async (_args, ctx) => {
92
- await deps.getPolling().stop();
93
- deps.setConfig({ ...deps.getConfig(), telegramEnabled: false });
94
- await deps.persistConfig(deps.getConfig());
95
- deps.clearStatusError();
96
- deps.refreshStatus();
97
- ctx.ui.notify("Telegram disconnected. Token is kept; use /tg-connect to reconnect.", "info");
98
- },
116
+ // ── /tg-global-disconnect ─────────────────────────────────────────────
117
+ registry.registerCommand("tg-global-disconnect", {
118
+ description: "Disable/stop the global Telegram bot without deleting the token",
119
+ handler: (args, ctx) => handleTgGlobalDisconnect(args, ctx, deps),
99
120
  });
100
121
 
101
122
  // ── /tg-bind-cwd ──────────────────────────────────────────────────────
@@ -125,6 +146,57 @@ export function registerTelegramCommands(
125
146
  },
126
147
  });
127
148
 
149
+ // ── /tg-cwd-connect ───────────────────────────────────────────────────
150
+ registry.registerCommand("tg-cwd-connect", {
151
+ description: "Enable the Telegram bot for the current directory",
152
+ handler: async (_args, ctx) => {
153
+ const previousScope = deps.getResolvedConfig()?.scope;
154
+ const config = deps.getConfig();
155
+ // If no bot token is configured at all, prompt user to bind or set up global first
156
+ if (!config.botToken && !deps.getResolvedConfig()?.store.global?.botToken) {
157
+ ctx.ui.notify("No Telegram bot configured. Use /tg-global-setup or /tg-bind-cwd first.", "error");
158
+ return;
159
+ }
160
+ // If no token in current scope, try falling back to global
161
+ if (!config.botToken) {
162
+ const global = deps.getResolvedConfig()?.store.global;
163
+ if (global?.botToken) {
164
+ deps.setConfig({ ...config, botToken: global.botToken, botUsername: global.botUsername });
165
+ }
166
+ }
167
+ deps.setConfig({ ...deps.getConfig(), telegramEnabled: true });
168
+ await deps.persistConfig(deps.getConfig());
169
+ await deps.getPolling().stop();
170
+ if (deps.isTelegramEnabled()) deps.getPolling().start();
171
+ deps.clearStatusError();
172
+ deps.startStatusHeartbeat();
173
+ ctx.ui.notify(`Telegram bot enabled for current scope (${previousScope}).`, "info");
174
+ },
175
+ });
176
+
177
+ // ── /tg-cwd-disconnect ────────────────────────────────────────────────
178
+ registry.registerCommand("tg-cwd-disconnect", {
179
+ description: "Disable the Telegram bot for the current directory",
180
+ handler: async (_args, ctx) => {
181
+ const previousScope = deps.getResolvedConfig()?.scope;
182
+ await deps.getPolling().stop();
183
+ deps.setConfig({ ...deps.getConfig(), telegramEnabled: false });
184
+ await deps.persistConfig(deps.getConfig());
185
+ // If there's a global bot token, switch to it when disabling workspace bot
186
+ if (previousScope === "workspace") {
187
+ const global = deps.getResolvedConfig()?.store.global;
188
+ if (global?.botToken && global?.telegramEnabled !== false) {
189
+ deps.setConfig({ ...deps.getConfig(), botToken: global.botToken, botUsername: global.botUsername, telegramEnabled: true });
190
+ await deps.persistConfig(deps.getConfig());
191
+ deps.getPolling().start();
192
+ }
193
+ }
194
+ deps.clearStatusError();
195
+ deps.refreshStatus();
196
+ ctx.ui.notify(`Telegram bot disabled for current scope (${previousScope}).`, "info");
197
+ },
198
+ });
199
+
128
200
  // ── /tg-unbind-cwd ────────────────────────────────────────────────────
129
201
  registry.registerCommand("tg-unbind-cwd", {
130
202
  description: "Remove current directory Telegram bot binding",
@@ -135,7 +207,14 @@ export function registerTelegramCommands(
135
207
  return;
136
208
  }
137
209
  await deps.getPolling().stop();
210
+ const global = previous.store.global;
138
211
  deps.switchResolvedConfig(await unbindWorkspaceTelegramConfig(ctx.cwd || process.cwd()));
212
+ // Fall back to global bot if it exists and is enabled
213
+ if (global?.botToken && global?.telegramEnabled !== false) {
214
+ const newConfig = deps.getConfig();
215
+ deps.setConfig({ ...newConfig, botToken: global.botToken, botUsername: global.botUsername });
216
+ await deps.persistConfig(deps.getConfig());
217
+ }
139
218
  if (deps.isTelegramEnabled()) deps.getPolling().start();
140
219
  await deps.syncTelegramCommands();
141
220
  deps.refreshStatus();
@@ -149,11 +228,14 @@ export function registerTelegramCommands(
149
228
  handler: async (_args, ctx) => {
150
229
  const store = await readTelegramConfigStore();
151
230
  const lines = [
152
- `global: ${store.global?.botUsername ? `@${store.global.botUsername}` : store.global?.botToken ? "configured" : "not configured"}`,
231
+ `global: ${store.global?.botUsername ? `@${store.global.botUsername}` : store.global?.botToken ? "configured" : "not configured"}${store.global?.telegramEnabled === false ? " (disabled)" : store.global?.telegramEnabled ? "" : ""}`,
153
232
  "",
154
233
  "workspaces:",
155
234
  ...((store.workspaces ?? []).length
156
- ? (store.workspaces ?? []).map((workspace) => `- ${escapeHtml(workspace.path)}\n ${workspace.config.botUsername ? `@${workspace.config.botUsername}` : workspace.config.botToken ? "configured" : "not configured"}`)
235
+ ? (store.workspaces ?? []).map((workspace) => {
236
+ const status = workspace.config.telegramEnabled === false ? " (disabled)" : "";
237
+ return `- ${escapeHtml(workspace.path)}\n ${workspace.config.botUsername ? `@${workspace.config.botUsername}${status}` : workspace.config.botToken ? `configured${status}` : "not configured"}`;
238
+ })
157
239
  : ["none"]),
158
240
  ];
159
241
  ctx.ui.notify(lines.join("\n"), "info");
package/lib/config.ts CHANGED
@@ -48,7 +48,7 @@ async function withTelegramConfigLock<T>(run: () => Promise<T>): Promise<T> {
48
48
 
49
49
  function assertV2Store(value: unknown): TelegramConfigStore {
50
50
  if (!value || typeof value !== "object" || (value as { version?: unknown }).version !== 2) {
51
- throw new Error("Unsupported Telegram config format. Please recreate ~/.pi/agent/tg.json as version 2 or run /tg-setup.");
51
+ throw new Error("Unsupported Telegram config format. Please recreate ~/.pi/agent/tg.json as version 2 or run /tg-global-setup.");
52
52
  }
53
53
  const store = value as TelegramConfigStore;
54
54
  return {
@@ -169,3 +169,15 @@ export async function unbindWorkspaceTelegramConfig(cwd: string): Promise<Resolv
169
169
  return resolveTelegramConfigStore(store, cwd);
170
170
  });
171
171
  }
172
+
173
+ /**
174
+ * Update only the global section of the Telegram config store.
175
+ * Merges with existing global config and preserves lastUpdateId monotonically.
176
+ */
177
+ export async function writeGlobalTelegramConfig(config: TelegramConfig): Promise<void> {
178
+ await withTelegramConfigLock(async () => {
179
+ const store = await readTelegramConfigStore();
180
+ store.global = mergeTelegramConfigForWrite(store.global, config);
181
+ await writeTelegramConfigStore(store);
182
+ });
183
+ }
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
- return this.parser.parseInline(tokens as unknown[]);
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,9 +30,99 @@ 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
+ /**
78
+ * Track list nesting depth for proper indentation.
79
+ * Resets per top-level markdownToTelegramHtml call.
80
+ */
81
+ let _listDepth = 0;
82
+
83
+ /**
84
+ * Flag: when true, the text renderer appends \\n after content.
85
+ * True during block-level parse (parser.parse), false during inline
86
+ * (parser.parseInline) so inline text tokens don't get spurious newlines.
87
+ */
88
+ let _textAppendNewline = true;
89
+
26
90
  const renderer = {
27
- heading(this: TelegramRendererContext, { tokens }: Tokens.Heading): string {
28
- return `<b>${inlineFromTokens.call(this, tokens)}</b>\n`;
91
+ text(this: TelegramRendererContext, token: Tokens.Text | Tokens.Escape): string {
92
+ if ('tokens' in token && token.tokens && token.tokens.length > 0) {
93
+ // Text with nested inline tokens — parse inline (disable \\n for nested),
94
+ // then add \\n only if we are at block level
95
+ const prev = _textAppendNewline;
96
+ _textAppendNewline = false;
97
+ try {
98
+ return this.parser.parseInline(token.tokens as unknown[]) + (prev ? '\n' : '');
99
+ } finally {
100
+ _textAppendNewline = prev;
101
+ }
102
+ }
103
+ // Plain text / escape token
104
+ return escapeHtml(token.text) + (_textAppendNewline ? '\n' : '');
105
+ },
106
+
107
+ checkbox(this: TelegramRendererContext): string {
108
+ // Suppress default <input> rendering; custom [x]/[ ] marker is added by list renderer
109
+ return "";
110
+ },
111
+
112
+
113
+ heading(this: TelegramRendererContext, token: Tokens.Heading): string {
114
+ const content = inlineFromTokens.call(this, token.tokens);
115
+ if (token.depth === 1) {
116
+ // H1: bold + underline, matching TUI style
117
+ return `<b><u>${content}</u></b>\n`;
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`;
29
126
  },
30
127
 
31
128
  paragraph(this: TelegramRendererContext, { tokens }: Tokens.Paragraph): string {
@@ -49,37 +146,101 @@ const renderer = {
49
146
  },
50
147
 
51
148
  code(this: TelegramRendererContext, { text, lang }: Tokens.Code): string {
52
- const language = lang ? ` class="language-${escapeHtml(lang)}"` : "";
53
- return `<pre><code${language}>${escapeHtml(text)}\n</code></pre>`;
149
+ const codeContent = escapeHtml(text);
150
+ const langTag = lang ? escapeHtml(lang) : "";
151
+ const openMarker = langTag ? `\`\`\`${langTag}` : "\`\`\`";
152
+ return `<pre><code>${openMarker}\n${codeContent}\n\`\`\`\n</code></pre>`;
54
153
  },
55
154
 
56
155
  table(this: TelegramRendererContext, token: Tokens.Table): string {
57
- const rows: string[] = [];
58
- if (token.header.length > 0) {
59
- rows.push(token.header.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).join(" | "));
60
- rows.push(token.header.map(() => "---").join("-+-"));
156
+ const numCols = token.header.length;
157
+ if (numCols === 0) return "";
158
+
159
+ // Calculate column widths based on all cells (header + rows)
160
+ const colWidths: number[] = [];
161
+ for (let i = 0; i < numCols; i++) {
162
+ let maxW = 0;
163
+ const hText = stripHtmlTags(inlineFromTokens.call(this, token.header[i].tokens, token.header[i].text));
164
+ maxW = Math.max(maxW, visibleWidth(hText));
165
+ for (const row of token.rows) {
166
+ if (i < row.length) {
167
+ const cText = stripHtmlTags(inlineFromTokens.call(this, row[i].tokens, row[i].text));
168
+ maxW = Math.max(maxW, visibleWidth(cText));
169
+ }
170
+ }
171
+ colWidths.push(Math.max(1, maxW));
61
172
  }
173
+
174
+ const lines: string[] = [];
175
+
176
+ // Top border
177
+ lines.push(`┌─${colWidths.map(w => "─".repeat(w)).join("─┬─")}─┐`);
178
+
179
+ // Header (bold)
180
+ const headerCells = token.header.map((c, i) =>
181
+ padCell(inlineFromTokens.call(this, c.tokens, c.text), colWidths[i], true),
182
+ );
183
+ lines.push(`│ ${headerCells.join(" │ ")} │`);
184
+
185
+ // Separator
186
+ lines.push(`├─${colWidths.map(w => "─".repeat(w)).join("─┼─")}─┤`);
187
+
188
+ // Data rows
62
189
  for (const row of token.rows) {
63
- rows.push(row.map((c) => inlineFromTokens.call(this, c.tokens, c.text)).join(" | "));
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(" │ ")} │`);
64
197
  }
65
- return `<pre>${rows.join("\n")}</pre>\n`;
198
+
199
+ // Bottom border
200
+ lines.push(`└─${colWidths.map(w => "─".repeat(w)).join("─┴─")}─┘`);
201
+
202
+ return `<pre>${lines.join("\n")}</pre>\n`;
66
203
  },
67
204
 
68
205
  list(this: TelegramRendererContext, token: Tokens.List): string {
69
- const start = typeof token.start === "number" ? token.start : 1;
70
- const lines = token.items.map((item, index) => {
71
- const bullet = token.ordered ? `${start + index}. ` : "• ";
72
- return bullet + blockFromTokens.call(this, item.tokens, item.text);
73
- });
74
- return lines.join("\n") + "\n";
206
+ _listDepth++;
207
+ try {
208
+ const start = typeof token.start === "number" ? token.start : 1;
209
+ const baseIndent = " ".repeat(Math.max(0, _listDepth - 1));
210
+ const lines = token.items.map((item, index) => {
211
+ const bullet = token.ordered ? `${start + index}. ` : "- ";
212
+ const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : "";
213
+ const marker = bullet + taskMarker;
214
+ const content = blockFromTokens.call(this, item.tokens, item.text);
215
+ const contentLines = content.trimEnd().split("\n");
216
+ const contIndent = " ".repeat(visibleWidth(marker));
217
+ return contentLines.map((line, i) => {
218
+ const prefix = i === 0 ? baseIndent + marker : baseIndent + contIndent;
219
+ return prefix + line;
220
+ }).join("\n");
221
+ });
222
+ return lines.join("\n") + "\n";
223
+ } finally {
224
+ _listDepth--;
225
+ }
75
226
  },
76
227
 
77
228
  listitem(this: TelegramRendererContext, token: Tokens.ListItem): string {
229
+ // Used when a list item contains block-level children (e.g. nested lists).
230
+ // blockFromTokens will re-enter `list` for nested lists via parser.parse().
78
231
  return blockFromTokens.call(this, token.tokens, token.text);
79
232
  },
80
233
 
81
234
  blockquote(this: TelegramRendererContext, { tokens }: Tokens.Blockquote): string {
82
- return `<blockquote>${this.parser.parse(tokens as unknown[])}</blockquote>`;
235
+ const content = this.parser.parse(tokens as unknown[]).trim();
236
+ // Only wrap in <i> if content doesn't contain block-level tags
237
+ const shouldItalic = !/^<blockquote|^<pre|^<ul|^<ol|^<table/i.test(content);
238
+ const wrapped = shouldItalic ? `<i>${content}</i>` : content;
239
+ return `<blockquote>${wrapped}</blockquote>\n`;
240
+ },
241
+
242
+ hr(this: TelegramRendererContext): string {
243
+ return `<pre>─────────────────────</pre>\n`;
83
244
  },
84
245
 
85
246
  image(this: TelegramRendererContext, { text, title }: Tokens.Image): string {
@@ -95,6 +256,8 @@ marked.use({ renderer });
95
256
  * formatting within the Telegram supported subset.
96
257
  */
97
258
  export function markdownToTelegramHtml(markdown: string): string {
259
+ _listDepth = 0;
260
+ _textAppendNewline = true;
98
261
  return (marked.parse(markdown, { async: false }) as string).replace(/\n+$/, "");
99
262
  }
100
263
 
@@ -31,14 +31,15 @@ const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> =
31
31
  { command: "thinking", description: "Show or change thinking level" },
32
32
  { command: "stop", description: "Stop the current agent turn" },
33
33
  { command: "debug", description: "Show debug information" },
34
+ { command: "status", description: "Show runtime snapshot (workspace, model, context, messages)" },
34
35
  // tg-* commands visible in the Telegram bot menu.
35
36
  // tg-bind-cwd / tg-unbind-cwd are workspace-management commands that
36
37
  // require local cwd context and do not belong in the bot command list.
37
- { command: "tg-setup", description: "Configure Telegram bot token" },
38
- { command: "tg-connect", description: "Enable/start Telegram connection" },
39
- { command: "tg-disconnect", description: "Disable/stop Telegram connection" },
40
- { command: "tg-config", description: "Configure Telegram message rendering" },
41
- { command: "tg-list", description: "List Telegram bot bindings" },
38
+ { command: "tg_global_setup", description: "Configure global Telegram bot token" },
39
+ { command: "tg_global_connect", description: "Enable/start global Telegram bot" },
40
+ { command: "tg_global_disconnect", description: "Disable/stop global Telegram bot" },
41
+ { command: "tg_config", description: "Configure Telegram message rendering" },
42
+ { command: "tg_list", description: "List Telegram bot bindings" },
42
43
  ];
43
44
 
44
45
  const toTelegramCommandName = (name: string): string | undefined => {
package/lib/renderer.ts CHANGED
@@ -201,15 +201,18 @@ export function registerTelegramRenderer(
201
201
  };
202
202
 
203
203
  pi.on("agent_start", async () => {
204
+ try {
204
205
  sentInlineEvents.clear();
205
206
  toolArgs.clear();
206
207
  toolUpdateAt.clear();
207
208
  const turn = deps.getActiveTurn();
208
209
  if (!turn) return;
209
210
  if (turn.replaceMessageId !== undefined) await deps.transport.editText(turn.chatId, turn.replaceMessageId, "🤖 <b>Working…</b>");
211
+ } catch { /* suppressed */ }
210
212
  });
211
213
 
212
214
  pi.on("tool_execution_start", async (event) => {
215
+ try {
213
216
  const level = renderLevel(deps.getConfig(), "tool");
214
217
  if (level === "hidden") return;
215
218
  toolArgs.set(event.toolCallId, event.args);
@@ -218,9 +221,11 @@ export function registerTelegramRenderer(
218
221
  : `🔧 ${event.toolName} started
219
222
  ${stringifyShort(event.args, 1200)}`;
220
223
  await sendInlineEvent(inline);
224
+ } catch { /* suppressed */ }
221
225
  });
222
226
 
223
227
  pi.on("tool_execution_update", async (event) => {
228
+ try {
224
229
  const level = renderLevel(deps.getConfig(), "tool");
225
230
  if (level !== "full") return;
226
231
  const now = Date.now();
@@ -231,9 +236,11 @@ ${stringifyShort(event.args, 1200)}`;
231
236
  if (!partial || partial === "{}") return;
232
237
  await sendInlineEvent(`🔄 ${event.toolName} update
233
238
  ${partial}`);
239
+ } catch { /* suppressed */ }
234
240
  });
235
241
 
236
242
  pi.on("tool_execution_end", async (event) => {
243
+ try {
237
244
  const level = renderLevel(deps.getConfig(), "tool");
238
245
  toolUpdateAt.delete(event.toolCallId);
239
246
  const args = toolArgs.get(event.toolCallId);
@@ -250,9 +257,11 @@ ${partial}`);
250
257
  ${result}`
251
258
  : `${status}: ${event.toolName}`);
252
259
  }
260
+ } catch { /* suppressed */ }
253
261
  });
254
262
 
255
263
  pi.on("message_end", async (event) => {
264
+ try {
256
265
  const message = event.message as AnyMessage;
257
266
  if (message.role !== "assistant") return;
258
267
  const config = deps.getConfig();
@@ -279,6 +288,7 @@ ${result}`
279
288
  await deps.transport.editText(turn.chatId, turn.replaceMessageId, `✅ <b>Output sent.</b>\n${noun}`);
280
289
  turn.replaceMessageId = undefined;
281
290
  }
291
+ } catch { /* suppressed */ }
282
292
  });
283
293
 
284
294
  }
@@ -50,12 +50,17 @@ export async function telegramApi<T>(
50
50
  body: Record<string, unknown>,
51
51
  signal?: AbortSignal,
52
52
  ): Promise<T> {
53
- const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
54
- method: "POST",
55
- headers: { "content-type": "application/json" },
56
- body: JSON.stringify(body),
57
- signal,
58
- });
53
+ let response: Response;
54
+ try {
55
+ response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify(body),
59
+ signal,
60
+ });
61
+ } catch (error) {
62
+ throw new Error(`Telegram API request failed: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
59
64
  const json = (await response.json()) as TelegramApiError & { result: T };
60
65
  if (!json.ok) throw new Error(json.description ?? `${method} failed`);
61
66
  return json.result;
@@ -72,10 +77,15 @@ export async function getTelegramFile(token: string, fileId: string, signal?: Ab
72
77
 
73
78
  export async function downloadTelegramFile(token: string, filePath: string, signal?: AbortSignal): Promise<Buffer> {
74
79
  const encodedPath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
75
- const response = await fetch(`https://api.telegram.org/file/bot${token}/${encodedPath}`, {
76
- method: "GET",
77
- signal,
78
- });
80
+ let response: Response;
81
+ try {
82
+ response = await fetch(`https://api.telegram.org/file/bot${token}/${encodedPath}`, {
83
+ method: "GET",
84
+ signal,
85
+ });
86
+ } catch (error) {
87
+ throw new Error(`Telegram file download failed: ${error instanceof Error ? error.message : String(error)}`);
88
+ }
79
89
  if (!response.ok) {
80
90
  throw new Error(`Failed to download Telegram file: ${response.status}`);
81
91
  }
@@ -234,14 +244,19 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
234
244
  type: inferMimeTypeFromPath(path) ?? "application/octet-stream",
235
245
  });
236
246
  form.set("document", documentBlob, basename(path));
237
- const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
238
- method: "POST",
239
- body: form,
240
- signal,
241
- });
242
- const json = await response.json() as { ok: boolean; description?: string };
243
- if (!json.ok) throw new Error(json.description ?? "sendDocument failed");
244
- return;
247
+ try {
248
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
249
+ method: "POST",
250
+ body: form,
251
+ signal,
252
+ });
253
+ const json = await response.json() as { ok: boolean; description?: string };
254
+ if (!json.ok) throw new Error(json.description ?? "sendDocument failed");
255
+ return;
256
+ } catch (fetchError) {
257
+ if ((fetchError as any)?.name === "AbortError") throw fetchError;
258
+ throw new Error(`Telegram sendDocument failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
259
+ }
245
260
  } catch (error) {
246
261
  lastError = error;
247
262
  if (attempt >= maxRetries || signal?.aborted) throw error;
@@ -272,14 +287,19 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
272
287
  const bytes = Buffer.from(base64, "base64");
273
288
  form.set("photo", new Blob([bytes], { type: mime }), `image.${mime.split("/")[1] ?? "png"}`);
274
289
  }
275
- const response = await fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
276
- method: "POST",
277
- body: form,
278
- signal,
279
- });
280
- const json = await response.json() as { ok: boolean; description?: string };
281
- if (!json.ok) throw new Error(json.description ?? "sendPhoto failed");
282
- return;
290
+ try {
291
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
292
+ method: "POST",
293
+ body: form,
294
+ signal,
295
+ });
296
+ const json = await response.json() as { ok: boolean; description?: string };
297
+ if (!json.ok) throw new Error(json.description ?? "sendPhoto failed");
298
+ return;
299
+ } catch (fetchError) {
300
+ if ((fetchError as any)?.name === "AbortError") throw fetchError;
301
+ throw new Error(`Telegram sendPhoto failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
302
+ }
283
303
  } catch (error) {
284
304
  lastError = error;
285
305
  if (attempt >= maxRetries || signal?.aborted) throw error;
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.1",
4
+ "version": "0.0.2",
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",