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 +15 -10
- package/index.ts +13 -6
- package/lib/commands/info.ts +109 -3
- package/lib/commands/register.ts +15 -3
- package/lib/commands/telegram-commands.ts +136 -54
- package/lib/config.ts +13 -1
- package/lib/markdown.ts +181 -18
- package/lib/menu-commands.ts +6 -5
- package/lib/renderer.ts +10 -0
- package/lib/telegram-api.ts +46 -26
- package/package.json +1 -1
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
|
-
###
|
|
122
|
+
### Installation
|
|
116
123
|
|
|
117
|
-
|
|
124
|
+
**Install via npm (recommended):**
|
|
118
125
|
|
|
119
126
|
```bash
|
|
120
|
-
|
|
121
|
-
cd pi-telegram-plus
|
|
122
|
-
npm install
|
|
127
|
+
pi install npm:pi-telegram-plus
|
|
123
128
|
```
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
**Or install from source:**
|
|
126
131
|
|
|
127
132
|
```bash
|
|
128
|
-
|
|
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 =>
|
|
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
|
}
|
package/lib/commands/info.ts
CHANGED
|
@@ -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 & 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: {
|
|
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",
|
package/lib/commands/register.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
24
|
-
async function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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:
|
|
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:
|
|
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
|
|
91
|
-
handler:
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/menu-commands.ts
CHANGED
|
@@ -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: "
|
|
38
|
-
{ command: "
|
|
39
|
-
{ command: "
|
|
40
|
-
{ command: "
|
|
41
|
-
{ command: "
|
|
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
|
}
|
package/lib/telegram-api.ts
CHANGED
|
@@ -50,12 +50,17 @@ export async function telegramApi<T>(
|
|
|
50
50
|
body: Record<string, unknown>,
|
|
51
51
|
signal?: AbortSignal,
|
|
52
52
|
): Promise<T> {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
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",
|