pi-telegram-plus 0.0.1
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 +318 -0
- package/index.ts +327 -0
- package/lib/attachments.ts +154 -0
- package/lib/callback-protocol.ts +9 -0
- package/lib/command-parser.ts +15 -0
- package/lib/commands/auth.ts +365 -0
- package/lib/commands/info.ts +158 -0
- package/lib/commands/lifecycle.ts +65 -0
- package/lib/commands/model.ts +188 -0
- package/lib/commands/register.ts +40 -0
- package/lib/commands/session.ts +281 -0
- package/lib/commands/settings.ts +129 -0
- package/lib/commands/telegram-commands.ts +162 -0
- package/lib/commands/tg-config.ts +128 -0
- package/lib/config.ts +171 -0
- package/lib/controller.ts +406 -0
- package/lib/heartbeat.ts +95 -0
- package/lib/html.ts +6 -0
- package/lib/markdown.ts +132 -0
- package/lib/menu-commands.ts +72 -0
- package/lib/polling.ts +255 -0
- package/lib/renderer.ts +284 -0
- package/lib/session-capture.ts +95 -0
- package/lib/status.ts +46 -0
- package/lib/telegram-api.ts +327 -0
- package/lib/telegram-ui.ts +208 -0
- package/lib/text-split.ts +59 -0
- package/lib/types.ts +123 -0
- package/package.json +53 -0
- package/pi-host.d.ts +8 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { setTelegramMyCommands } from "./telegram-api.ts";
|
|
3
|
+
|
|
4
|
+
const TELEGRAM_MENU_COMMANDS: Array<{ command: string; description: string }> = [
|
|
5
|
+
// Keep the built-in pi commands in the same order as the TUI slash menu.
|
|
6
|
+
{ command: "login", description: "Configure provider authentication" },
|
|
7
|
+
{ command: "logout", description: "Remove stored credentials" },
|
|
8
|
+
{ command: "model", description: "Show or change the current model" },
|
|
9
|
+
{ command: "scoped-models", description: "Enable/disable models for cycling" },
|
|
10
|
+
{ command: "settings", description: "Open settings menu" },
|
|
11
|
+
{ command: "resume", description: "Resume a previous session" },
|
|
12
|
+
{ command: "new", description: "Start a new session" },
|
|
13
|
+
{ command: "name", description: "Set or show session name" },
|
|
14
|
+
{ command: "session", description: "Show session statistics" },
|
|
15
|
+
{ command: "tree", description: "Navigate session tree" },
|
|
16
|
+
{ command: "fork", description: "Fork from a previous message" },
|
|
17
|
+
{ command: "clone", description: "Clone at a previous message" },
|
|
18
|
+
{ command: "compact", description: "Compact session context" },
|
|
19
|
+
{ command: "copy", description: "Copy last assistant message" },
|
|
20
|
+
{ command: "export", description: "Export session" },
|
|
21
|
+
{ command: "share", description: "Share session as gist" },
|
|
22
|
+
{ command: "reload", description: "Reload pi resources" },
|
|
23
|
+
{ command: "hotkeys", description: "Show keyboard shortcuts" },
|
|
24
|
+
{ command: "changelog", description: "Show changelog" },
|
|
25
|
+
{ command: "quit", description: "Shut down pi" },
|
|
26
|
+
|
|
27
|
+
// Additional pi-telegram-plus commands.
|
|
28
|
+
{ command: "cwd", description: "Show current working directory" },
|
|
29
|
+
{ command: "cd", description: "Switch pi working directory" },
|
|
30
|
+
{ command: "import", description: "Import a session" },
|
|
31
|
+
{ command: "thinking", description: "Show or change thinking level" },
|
|
32
|
+
{ command: "stop", description: "Stop the current agent turn" },
|
|
33
|
+
{ command: "debug", description: "Show debug information" },
|
|
34
|
+
// tg-* commands visible in the Telegram bot menu.
|
|
35
|
+
// tg-bind-cwd / tg-unbind-cwd are workspace-management commands that
|
|
36
|
+
// 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" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const toTelegramCommandName = (name: string): string | undefined => {
|
|
45
|
+
// Telegram bot menu commands allow only [A-Za-z0-9_] and max 32 chars.
|
|
46
|
+
const telegramName = name.replace(/-/g, "_").toLowerCase();
|
|
47
|
+
if (!/^[a-z0-9_]{1,32}$/.test(telegramName)) return undefined;
|
|
48
|
+
return telegramName;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function buildTelegramMenuCommands(pi: ExtensionAPI): Array<{ command: string; description: string }> {
|
|
52
|
+
const commands = new Map<string, string>();
|
|
53
|
+
const addCommand = (name: string, description?: string) => {
|
|
54
|
+
const telegramName = toTelegramCommandName(name);
|
|
55
|
+
if (!telegramName || commands.has(telegramName)) return;
|
|
56
|
+
commands.set(telegramName, (description?.trim() || `Run /${telegramName}`).slice(0, 256));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (const command of TELEGRAM_MENU_COMMANDS) addCommand(command.command, command.description);
|
|
60
|
+
for (const command of pi.getCommands()) addCommand(command.name, command.description);
|
|
61
|
+
|
|
62
|
+
// Telegram accepts at most 100 bot commands. Keep the curated built-in-style
|
|
63
|
+
// commands first, then fill the rest with extension/prompt/skill commands.
|
|
64
|
+
return Array.from(commands, ([command, description]) => ({ command, description })).slice(0, 100);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function syncTelegramCommands(botToken: string | undefined, pi: ExtensionAPI): Promise<void> {
|
|
68
|
+
if (!botToken) return;
|
|
69
|
+
try {
|
|
70
|
+
await setTelegramMyCommands(botToken, buildTelegramMenuCommands(pi));
|
|
71
|
+
} catch { /* non-critical */ }
|
|
72
|
+
}
|
package/lib/polling.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "./config.ts";
|
|
5
|
+
import { getTelegramUpdates } from "./telegram-api.ts";
|
|
6
|
+
import type { TelegramConfig, TelegramUpdate } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export type TelegramPollingRuntime = {
|
|
9
|
+
start(): void;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
isActive(): boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const MIN_BACKOFF_MS = 1_000;
|
|
15
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
16
|
+
const POLL_LOCK_STALE_MS = 45_000;
|
|
17
|
+
const POLL_LOCK_TOUCH_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
type PollingLockOwner = {
|
|
20
|
+
id: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
at: string;
|
|
23
|
+
touchedAt: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const sleep = (ms: number, signal: AbortSignal) =>
|
|
27
|
+
new Promise<void>((resolve) => {
|
|
28
|
+
const timer = setTimeout(resolve, ms);
|
|
29
|
+
signal.addEventListener("abort", () => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve();
|
|
32
|
+
}, { once: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function tokenLockPath(token: string): string {
|
|
36
|
+
const hash = createHash("sha256").update(token).digest("hex").slice(0, 24);
|
|
37
|
+
return join(getAgentDir(), `tg-poll-${hash}.lock`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPidAlive(pid: unknown): boolean {
|
|
41
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ownerText(owner: PollingLockOwner): string {
|
|
51
|
+
return JSON.stringify(owner, null, 2) + "\n";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readPollingLockOwner(ownerPath: string): Promise<PollingLockOwner | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
const owner = JSON.parse(await readFile(ownerPath, "utf8")) as Partial<PollingLockOwner>;
|
|
57
|
+
if (typeof owner.id !== "string" || typeof owner.pid !== "number") return undefined;
|
|
58
|
+
return {
|
|
59
|
+
id: owner.id,
|
|
60
|
+
pid: owner.pid,
|
|
61
|
+
at: typeof owner.at === "string" ? owner.at : "",
|
|
62
|
+
touchedAt: typeof owner.touchedAt === "string" ? owner.touchedAt : "",
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function isPollingLockStale(lockPath: string): Promise<boolean> {
|
|
70
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
71
|
+
const owner = await readPollingLockOwner(ownerPath);
|
|
72
|
+
const age = Date.now() - (await stat(ownerPath).then((s) => s.mtimeMs).catch(() => 0));
|
|
73
|
+
if (age > POLL_LOCK_STALE_MS) return true;
|
|
74
|
+
return owner !== undefined && !isPidAlive(owner.pid);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Atomically claim the polling lock. Uses a temporary candidate file written
|
|
79
|
+
* with `wx` (exclusive create) inside the agent dir, then renames it into the
|
|
80
|
+
* lock directory. No check-then-rm-then-mkdir TOCTOU window exists: only one
|
|
81
|
+
* process can successfully rename its candidate into the lock's owner.json.
|
|
82
|
+
*/
|
|
83
|
+
async function acquirePollingLock(token: string): Promise<{ owns: () => Promise<boolean>; release: () => Promise<void> } | undefined> {
|
|
84
|
+
await mkdir(getAgentDir(), { recursive: true });
|
|
85
|
+
const lockPath = tokenLockPath(token);
|
|
86
|
+
const ownerPath = join(lockPath, "owner.json");
|
|
87
|
+
const owner: PollingLockOwner = {
|
|
88
|
+
id: randomUUID(),
|
|
89
|
+
pid: process.pid,
|
|
90
|
+
at: new Date().toISOString(),
|
|
91
|
+
touchedAt: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Clean up stale lock if it exists.
|
|
95
|
+
if (!await isPollingLockStale(lockPath)) return undefined;
|
|
96
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
97
|
+
|
|
98
|
+
// Create lock directory; if another process won, bail out.
|
|
99
|
+
try {
|
|
100
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
103
|
+
// Someone else created it after our stale check β their lock is fresh now.
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Write a temp candidate with exclusive flag, then rename atomically.
|
|
108
|
+
const tmpPath = join(getAgentDir(), `tg-poll-candidate-${owner.id}.tmp`);
|
|
109
|
+
try {
|
|
110
|
+
await writeFile(tmpPath, ownerText(owner), { mode: 0o600, flag: "wx" });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Another process's candidate won; clean up and bail.
|
|
113
|
+
await rm(tmpPath).catch(() => undefined);
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return undefined;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const { rename: nodeRename } = await import("node:fs/promises");
|
|
120
|
+
await nodeRename(tmpPath, ownerPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// rename failed (e.g. cross-device or another process already wrote owner.json).
|
|
123
|
+
await rm(tmpPath).catch(() => undefined);
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const touch = setInterval(() => {
|
|
128
|
+
void (async () => {
|
|
129
|
+
const current = await readPollingLockOwner(ownerPath);
|
|
130
|
+
if (current?.id !== owner.id) return;
|
|
131
|
+
owner.touchedAt = new Date().toISOString();
|
|
132
|
+
await writeFile(ownerPath, ownerText(owner), { mode: 0o600 }).catch(() => undefined);
|
|
133
|
+
})();
|
|
134
|
+
}, POLL_LOCK_TOUCH_MS);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
owns: async () => (await readPollingLockOwner(ownerPath))?.id === owner.id,
|
|
138
|
+
release: async () => {
|
|
139
|
+
clearInterval(touch);
|
|
140
|
+
const current = await readPollingLockOwner(ownerPath);
|
|
141
|
+
if (current?.id === owner.id) await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
export function createTelegramPollingRuntime(deps: {
|
|
148
|
+
getConfig: () => TelegramConfig;
|
|
149
|
+
setConfig: (config: TelegramConfig) => void;
|
|
150
|
+
persistConfig: (config: TelegramConfig) => Promise<void>;
|
|
151
|
+
handleUpdate: (update: TelegramUpdate) => Promise<void>;
|
|
152
|
+
reloadConfig?: () => Promise<void>;
|
|
153
|
+
onError: (error: unknown) => void;
|
|
154
|
+
onSuccess?: () => void;
|
|
155
|
+
}): TelegramPollingRuntime {
|
|
156
|
+
let abort: AbortController | undefined;
|
|
157
|
+
let pollLock: { token: string; owns: () => Promise<boolean>; release: () => Promise<void> } | undefined;
|
|
158
|
+
|
|
159
|
+
const releasePollLock = async () => {
|
|
160
|
+
const lock = pollLock;
|
|
161
|
+
pollLock = undefined;
|
|
162
|
+
await lock?.release().catch(() => undefined);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const ensurePollLock = async (token: string): Promise<boolean> => {
|
|
166
|
+
if (pollLock?.token === token) {
|
|
167
|
+
if (await pollLock.owns()) return true;
|
|
168
|
+
await releasePollLock();
|
|
169
|
+
} else {
|
|
170
|
+
await releasePollLock();
|
|
171
|
+
}
|
|
172
|
+
const lock = await acquirePollingLock(token);
|
|
173
|
+
if (!lock) return false;
|
|
174
|
+
pollLock = { token, owns: lock.owns, release: lock.release };
|
|
175
|
+
return true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const loop = async (signal: AbortSignal) => {
|
|
179
|
+
let backoffMs = MIN_BACKOFF_MS;
|
|
180
|
+
|
|
181
|
+
while (!signal.aborted) {
|
|
182
|
+
const token = deps.getConfig().botToken;
|
|
183
|
+
if (!token) {
|
|
184
|
+
await releasePollLock();
|
|
185
|
+
await sleep(MIN_BACKOFF_MS, signal);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!(await ensurePollLock(token))) {
|
|
189
|
+
deps.onError(new Error("Telegram polling skipped: another local pi instance is already polling this bot token."));
|
|
190
|
+
await sleep(MAX_BACKOFF_MS, signal);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Refresh config after owning the poll lock. A process that waited for
|
|
196
|
+
// the lock may have stale in-memory lastUpdateId; re-reading prevents it
|
|
197
|
+
// from polling an already-persisted update after another process/reload.
|
|
198
|
+
await deps.reloadConfig?.();
|
|
199
|
+
if (signal.aborted) return;
|
|
200
|
+
const refreshedToken = deps.getConfig().botToken;
|
|
201
|
+
if (!refreshedToken) continue;
|
|
202
|
+
if (refreshedToken !== token) continue;
|
|
203
|
+
|
|
204
|
+
const updates = await getTelegramUpdates(deps.getConfig(), signal);
|
|
205
|
+
backoffMs = MIN_BACKOFF_MS;
|
|
206
|
+
deps.onSuccess?.();
|
|
207
|
+
|
|
208
|
+
for (const update of updates) {
|
|
209
|
+
if (signal.aborted) return;
|
|
210
|
+
try {
|
|
211
|
+
await deps.handleUpdate(update);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
deps.onError(error);
|
|
214
|
+
// Do not advance offset on failure; retry on next poll loop.
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const nextConfig = { ...deps.getConfig(), lastUpdateId: update.update_id };
|
|
218
|
+
try {
|
|
219
|
+
await deps.persistConfig(nextConfig);
|
|
220
|
+
deps.setConfig(nextConfig);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
deps.onError(error);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (signal.aborted) return;
|
|
228
|
+
deps.onError(error);
|
|
229
|
+
await sleep(backoffMs, signal);
|
|
230
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await releasePollLock();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
start() {
|
|
238
|
+
if (abort) return;
|
|
239
|
+
abort = new AbortController();
|
|
240
|
+
void loop(abort.signal).catch((error) => {
|
|
241
|
+
abort = undefined;
|
|
242
|
+
void releasePollLock();
|
|
243
|
+
deps.onError(error);
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
async stop() {
|
|
247
|
+
abort?.abort();
|
|
248
|
+
abort = undefined;
|
|
249
|
+
await releasePollLock();
|
|
250
|
+
},
|
|
251
|
+
isActive() {
|
|
252
|
+
return !!abort;
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
package/lib/renderer.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { escapeHtml } from "./html.ts";
|
|
3
|
+
import { markdownToTelegramHtml } from "./markdown.ts";
|
|
4
|
+
import type { TelegramConfig, TelegramRenderLevel, TelegramTransport, TelegramTurn } from "./types.ts";
|
|
5
|
+
import { RENDER_LEVELS } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
type AnyMessage = {
|
|
8
|
+
role?: string;
|
|
9
|
+
content?: unknown;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
usage?: { cost?: { total?: number }; totalTokens?: number };
|
|
12
|
+
model?: string;
|
|
13
|
+
provider?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const TOOL_UPDATE_MS = 5000;
|
|
17
|
+
const EDIT_LIMIT = 3500;
|
|
18
|
+
|
|
19
|
+
function formatThinkingInline(part: Record<string, any>, level: TelegramRenderLevel): string {
|
|
20
|
+
if (level === "hidden") return "";
|
|
21
|
+
const text = part.redacted ? "[thinking redacted]" : String(part.thinking ?? "");
|
|
22
|
+
if (!text) return "";
|
|
23
|
+
if (level === "brief") {
|
|
24
|
+
const short = text.length > 200 ? text.slice(0, 197) + "β¦" : text;
|
|
25
|
+
return `π ${short}`;
|
|
26
|
+
}
|
|
27
|
+
return `π Thinking\n${text}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function contentToRenderParts(
|
|
31
|
+
content: unknown,
|
|
32
|
+
thinkingLevel: TelegramRenderLevel = "brief",
|
|
33
|
+
toolLevel: TelegramRenderLevel = "brief",
|
|
34
|
+
): { body: string; inlineEvents: string[] } {
|
|
35
|
+
if (typeof content === "string") return { body: content, inlineEvents: [] };
|
|
36
|
+
if (!Array.isArray(content)) return { body: "", inlineEvents: [] };
|
|
37
|
+
const body: string[] = [];
|
|
38
|
+
const inlineEvents: string[] = [];
|
|
39
|
+
for (const part of content) {
|
|
40
|
+
if (!part || typeof part !== "object") continue;
|
|
41
|
+
const p = part as Record<string, any>;
|
|
42
|
+
if (p.type === "text") body.push(String(p.text ?? ""));
|
|
43
|
+
else if (p.type === "thinking") {
|
|
44
|
+
const inline = formatThinkingInline(p, thinkingLevel);
|
|
45
|
+
if (inline) inlineEvents.push(inline);
|
|
46
|
+
} else if (p.type === "toolCall") {
|
|
47
|
+
if (toolLevel === "hidden") continue;
|
|
48
|
+
const name = String(p.name ?? "tool");
|
|
49
|
+
inlineEvents.push(toolLevel === "brief"
|
|
50
|
+
? formatToolBrief(name, p.arguments ?? {})
|
|
51
|
+
: `π§ ${name}\n${stringifyShort(p.arguments ?? {}, 1200)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { body: body.filter(Boolean).join("\n"), inlineEvents };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function contentImages(content: unknown): Array<{ data: string; mimeType?: string }> {
|
|
58
|
+
if (!Array.isArray(content)) return [];
|
|
59
|
+
return content.flatMap((part) => {
|
|
60
|
+
if (!part || typeof part !== "object") return [];
|
|
61
|
+
const p = part as Record<string, any>;
|
|
62
|
+
return p.type === "image" && typeof p.data === "string"
|
|
63
|
+
? [{ data: p.data, mimeType: typeof p.mimeType === "string" ? p.mimeType : undefined }]
|
|
64
|
+
: [];
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringifyShort(value: unknown, max = 900): string {
|
|
69
|
+
let text: string;
|
|
70
|
+
if (typeof value === "string") text = value;
|
|
71
|
+
else {
|
|
72
|
+
try { text = JSON.stringify(value, null, 2); }
|
|
73
|
+
catch { text = String(value); }
|
|
74
|
+
}
|
|
75
|
+
return text.length <= max ? text : text.slice(0, max - 1) + "β¦";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderLevel(config: TelegramConfig, key: "tool" | "thinking"): TelegramRenderLevel {
|
|
79
|
+
const value = config[key];
|
|
80
|
+
return (RENDER_LEVELS as readonly string[]).includes(value ?? "") ? value! : "brief";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function firstLine(text: string): string {
|
|
84
|
+
return text.split(/\r?\n/, 1)[0].replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shortenSummary(text: string, max: number): string {
|
|
88
|
+
if (!text || text === "{}") return "";
|
|
89
|
+
return text.length <= max ? text : text.slice(0, max - 1) + "β¦";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function summarizeToolArgs(toolName: string, args: unknown, max = 96): string {
|
|
93
|
+
if (!args || typeof args !== "object") return "";
|
|
94
|
+
const a = args as Record<string, unknown>;
|
|
95
|
+
let text = "";
|
|
96
|
+
|
|
97
|
+
if (toolName === "edit" && typeof a.path === "string") text = a.path;
|
|
98
|
+
else if (toolName === "read" && typeof a.path === "string") {
|
|
99
|
+
text = a.path;
|
|
100
|
+
const offset = typeof a.offset === "number" ? a.offset : undefined;
|
|
101
|
+
const limit = typeof a.limit === "number" ? a.limit : undefined;
|
|
102
|
+
if (offset !== undefined || limit !== undefined) {
|
|
103
|
+
const start = offset ?? 1;
|
|
104
|
+
const end = limit !== undefined ? start + limit - 1 : "";
|
|
105
|
+
text += `:${start}${end ? `-${end}` : ""}`;
|
|
106
|
+
}
|
|
107
|
+
} else if (toolName === "bash" && typeof a.command === "string") text = firstLine(a.command);
|
|
108
|
+
else if (typeof a.path === "string") text = a.path;
|
|
109
|
+
else if (typeof a.url === "string") text = a.url;
|
|
110
|
+
else if (Array.isArray(a.paths)) text = a.paths.map(String).join(", ");
|
|
111
|
+
else if (typeof a.file === "string") text = a.file;
|
|
112
|
+
else if (typeof a.query === "string") text = a.query;
|
|
113
|
+
else text = stringifyShort(args, max);
|
|
114
|
+
|
|
115
|
+
return shortenSummary(text, max);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function formatToolBrief(toolName: string, args: unknown): string {
|
|
119
|
+
const summary = summarizeToolArgs(toolName, args);
|
|
120
|
+
return summary ? `π§ ${toolName}: ${summary}` : `π§ ${toolName}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summarizeToolResult(result: unknown, max = 96): string {
|
|
124
|
+
if (typeof result === "string") {
|
|
125
|
+
const text = result.trim();
|
|
126
|
+
return text.length <= max ? text : text.slice(0, max - 1) + "β¦";
|
|
127
|
+
}
|
|
128
|
+
if (!result || typeof result !== "object") return stringifyShort(result, max);
|
|
129
|
+
const r = result as Record<string, unknown>;
|
|
130
|
+
const candidates = [r.errorMessage, r.message, r.error, r.stderr, r.stdout, r.text, r.output, r.result];
|
|
131
|
+
const found = candidates.find((value) => typeof value === "string" && value.trim());
|
|
132
|
+
const text = typeof found === "string" ? firstLine(found) : stringifyShort(result, max);
|
|
133
|
+
return shortenSummary(text, max);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatToolFailureBrief(toolName: string, result: unknown, args?: unknown): string {
|
|
137
|
+
const argSummary = summarizeToolArgs(toolName, args, 72);
|
|
138
|
+
const resultSummary = summarizeToolResult(result, 72);
|
|
139
|
+
const summary = argSummary && resultSummary
|
|
140
|
+
? `${argSummary} β ${resultSummary}`
|
|
141
|
+
: argSummary || resultSummary;
|
|
142
|
+
return summary ? `β ${toolName}: ${summary}` : `β ${toolName}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function registerTelegramRenderer(
|
|
146
|
+
pi: ExtensionAPI,
|
|
147
|
+
deps: {
|
|
148
|
+
getConfig: () => TelegramConfig;
|
|
149
|
+
transport: TelegramTransport;
|
|
150
|
+
getActiveTurn: (chatId?: number) => TelegramTurn | undefined;
|
|
151
|
+
},
|
|
152
|
+
): void {
|
|
153
|
+
const sentInlineEvents = new Set<string>();
|
|
154
|
+
const toolUpdateAt = new Map<string, number>();
|
|
155
|
+
const toolArgs = new Map<string, unknown>();
|
|
156
|
+
|
|
157
|
+
const currentChats = () => {
|
|
158
|
+
const cfg = deps.getConfig();
|
|
159
|
+
return cfg.botToken && cfg.activeChatId !== undefined ? [cfg.activeChatId] : [];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const send = async (html: string) => {
|
|
163
|
+
const chatIds = currentChats();
|
|
164
|
+
if (chatIds.length === 0) return [];
|
|
165
|
+
return [await deps.transport.sendText(chatIds[0], html)];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const sendToTurn = async (html: string, options: { final?: boolean } = {}) => {
|
|
169
|
+
const turn = deps.getActiveTurn();
|
|
170
|
+
if (!turn) {
|
|
171
|
+
await send(html);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (turn.replaceMessageId === undefined) {
|
|
175
|
+
await deps.transport.sendText(turn.chatId, html);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (options.final && Buffer.byteLength(html, "utf8") > EDIT_LIMIT) {
|
|
179
|
+
await deps.transport.editText(turn.chatId, turn.replaceMessageId, "π€ <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch(() => undefined);
|
|
180
|
+
turn.replaceMessageId = undefined;
|
|
181
|
+
await deps.transport.sendText(turn.chatId, html);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await deps.transport.editText(turn.chatId, turn.replaceMessageId, html);
|
|
186
|
+
} catch {
|
|
187
|
+
// Edit failed (message deleted / too many edits). Fall back to a new message.
|
|
188
|
+
turn.replaceMessageId = undefined;
|
|
189
|
+
await deps.transport.sendText(turn.chatId, html);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const sendInlineEvent = async (event: string) => {
|
|
194
|
+
if (!event || sentInlineEvents.has(event)) return;
|
|
195
|
+
sentInlineEvents.add(event);
|
|
196
|
+
await sendToTurn(`<blockquote>${escapeHtml(event)}</blockquote>`);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const sendInlineEvents = async (events: string[]) => {
|
|
200
|
+
for (const event of events) await sendInlineEvent(event);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
pi.on("agent_start", async () => {
|
|
204
|
+
sentInlineEvents.clear();
|
|
205
|
+
toolArgs.clear();
|
|
206
|
+
toolUpdateAt.clear();
|
|
207
|
+
const turn = deps.getActiveTurn();
|
|
208
|
+
if (!turn) return;
|
|
209
|
+
if (turn.replaceMessageId !== undefined) await deps.transport.editText(turn.chatId, turn.replaceMessageId, "π€ <b>Workingβ¦</b>");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
pi.on("tool_execution_start", async (event) => {
|
|
213
|
+
const level = renderLevel(deps.getConfig(), "tool");
|
|
214
|
+
if (level === "hidden") return;
|
|
215
|
+
toolArgs.set(event.toolCallId, event.args);
|
|
216
|
+
const inline = level === "brief"
|
|
217
|
+
? formatToolBrief(event.toolName, event.args)
|
|
218
|
+
: `π§ ${event.toolName} started
|
|
219
|
+
${stringifyShort(event.args, 1200)}`;
|
|
220
|
+
await sendInlineEvent(inline);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.on("tool_execution_update", async (event) => {
|
|
224
|
+
const level = renderLevel(deps.getConfig(), "tool");
|
|
225
|
+
if (level !== "full") return;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const last = toolUpdateAt.get(event.toolCallId) ?? 0;
|
|
228
|
+
if (now - last < TOOL_UPDATE_MS) return;
|
|
229
|
+
toolUpdateAt.set(event.toolCallId, now);
|
|
230
|
+
const partial = stringifyShort(event.partialResult, 700);
|
|
231
|
+
if (!partial || partial === "{}") return;
|
|
232
|
+
await sendInlineEvent(`π ${event.toolName} update
|
|
233
|
+
${partial}`);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
pi.on("tool_execution_end", async (event) => {
|
|
237
|
+
const level = renderLevel(deps.getConfig(), "tool");
|
|
238
|
+
toolUpdateAt.delete(event.toolCallId);
|
|
239
|
+
const args = toolArgs.get(event.toolCallId);
|
|
240
|
+
toolArgs.delete(event.toolCallId);
|
|
241
|
+
if (level === "hidden") return;
|
|
242
|
+
const status = event.isError ? "β Tool failed" : "β
Tool finished";
|
|
243
|
+
const result = stringifyShort(event.result, event.isError ? 1800 : 900);
|
|
244
|
+
if (level === "brief") {
|
|
245
|
+
if (!event.isError) return;
|
|
246
|
+
await sendInlineEvent(formatToolFailureBrief(event.toolName, event.result, args));
|
|
247
|
+
} else {
|
|
248
|
+
await sendInlineEvent(result && result !== "{}"
|
|
249
|
+
? `${status}: ${event.toolName}
|
|
250
|
+
${result}`
|
|
251
|
+
: `${status}: ${event.toolName}`);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
pi.on("message_end", async (event) => {
|
|
256
|
+
const message = event.message as AnyMessage;
|
|
257
|
+
if (message.role !== "assistant") return;
|
|
258
|
+
const config = deps.getConfig();
|
|
259
|
+
const thinkingLevel = renderLevel(config, "thinking");
|
|
260
|
+
const toolLevel = renderLevel(config, "tool");
|
|
261
|
+
const rendered = contentToRenderParts(message.content, thinkingLevel, toolLevel);
|
|
262
|
+
await sendInlineEvents(rendered.inlineEvents);
|
|
263
|
+
const body = rendered.body || message.errorMessage || "";
|
|
264
|
+
const images = contentImages(message.content);
|
|
265
|
+
|
|
266
|
+
const hasBody = body.trim().length > 0;
|
|
267
|
+
if (hasBody) await sendToTurn(markdownToTelegramHtml(body), { final: true });
|
|
268
|
+
|
|
269
|
+
const turn = deps.getActiveTurn();
|
|
270
|
+
for (const image of images) {
|
|
271
|
+
const chatIds = turn ? [turn.chatId] : currentChats();
|
|
272
|
+
for (const chatId of chatIds) {
|
|
273
|
+
await deps.transport.sendChatAction(chatId, "upload_photo");
|
|
274
|
+
await deps.transport.sendPhoto(chatId, image.data, "image").catch(() => deps.transport.sendText(chatId, "[image output could not be sent]"));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!hasBody && turn?.replaceMessageId !== undefined && images.length > 0) {
|
|
278
|
+
const noun = `${images.length} image${images.length === 1 ? "" : "s"}`;
|
|
279
|
+
await deps.transport.editText(turn.chatId, turn.replaceMessageId, `β
<b>Output sent.</b>\n${noun}`);
|
|
280
|
+
turn.replaceMessageId = undefined;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
}
|