@llblab/pi-telegram 0.3.0 → 0.5.0
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 +41 -10
- package/docs/README.md +4 -3
- package/docs/architecture.md +43 -38
- package/docs/attachment-handlers.md +60 -0
- package/docs/command-templates.md +75 -0
- package/docs/locks.md +136 -0
- package/index.ts +80 -142
- package/lib/attachments.ts +70 -2
- package/lib/commands.ts +116 -48
- package/lib/config.ts +17 -5
- package/lib/handlers.ts +400 -0
- package/lib/lifecycle.ts +140 -0
- package/lib/locks.ts +336 -0
- package/lib/media.ts +50 -6
- package/lib/menu.ts +0 -4
- package/lib/pi.ts +11 -1
- package/lib/prompts.ts +44 -0
- package/lib/queue.ts +12 -6
- package/lib/routing.ts +219 -0
- package/lib/runtime.ts +9 -6
- package/lib/setup.ts +21 -3
- package/lib/status.ts +33 -4
- package/lib/turns.ts +103 -21
- package/package.json +1 -1
- package/lib/registration.ts +0 -262
package/lib/locks.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram singleton lock helpers
|
|
3
|
+
* Owns shared locks.json access and Telegram bridge ownership semantics
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
|
|
18
|
+
|
|
19
|
+
function getAgentDir(): string {
|
|
20
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
21
|
+
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
22
|
+
: join(homedir(), ".pi", "agent");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getLocksPath(): string {
|
|
26
|
+
return join(getAgentDir(), "locks.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TelegramLockEntry {
|
|
30
|
+
pid: number;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TelegramLockContext {
|
|
35
|
+
cwd: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TelegramLockState =
|
|
39
|
+
| { kind: "inactive" }
|
|
40
|
+
| { kind: "active-here"; lock: TelegramLockEntry }
|
|
41
|
+
| { kind: "active-elsewhere"; lock: TelegramLockEntry }
|
|
42
|
+
| { kind: "stale"; lock: TelegramLockEntry };
|
|
43
|
+
|
|
44
|
+
export interface TelegramLockAcquireOptions {
|
|
45
|
+
force?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TelegramLockAcquireResult =
|
|
49
|
+
| { ok: true; lock: TelegramLockEntry; replacedStale: boolean }
|
|
50
|
+
| { ok: false; lock: TelegramLockEntry };
|
|
51
|
+
|
|
52
|
+
export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
53
|
+
acquire: (
|
|
54
|
+
ctx: TContext,
|
|
55
|
+
options?: TelegramLockAcquireOptions,
|
|
56
|
+
) => TelegramLockAcquireResult;
|
|
57
|
+
release: () => TelegramLockState;
|
|
58
|
+
getState: () => TelegramLockState;
|
|
59
|
+
getStatusLabel: () => string;
|
|
60
|
+
owns: (ctx?: TelegramLockContext) => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TelegramLockRuntimeOptions {
|
|
64
|
+
key?: string;
|
|
65
|
+
locksPath?: string;
|
|
66
|
+
pid?: number;
|
|
67
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TelegramLockedPollingStartOptions {
|
|
71
|
+
force?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TelegramLockedPollingStartResult =
|
|
75
|
+
| { ok: true; message: string; canTakeover?: false }
|
|
76
|
+
| { ok: false; message: string; canTakeover?: boolean; owner?: string };
|
|
77
|
+
|
|
78
|
+
export interface TelegramLockedPollingRuntime<
|
|
79
|
+
TContext extends TelegramLockContext,
|
|
80
|
+
> {
|
|
81
|
+
start: (
|
|
82
|
+
ctx: TContext,
|
|
83
|
+
options?: TelegramLockedPollingStartOptions,
|
|
84
|
+
) => Promise<TelegramLockedPollingStartResult>;
|
|
85
|
+
stop: () => Promise<string>;
|
|
86
|
+
suspend: () => Promise<void>;
|
|
87
|
+
onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TelegramLockedPollingRuntimeDeps<
|
|
91
|
+
TContext extends TelegramLockContext,
|
|
92
|
+
> {
|
|
93
|
+
lock: TelegramLockRuntime<TContext>;
|
|
94
|
+
hasBotToken: () => boolean;
|
|
95
|
+
startPolling: (ctx: TContext) => void | Promise<void>;
|
|
96
|
+
stopPolling: () => Promise<void>;
|
|
97
|
+
updateStatus: (ctx: TContext) => void;
|
|
98
|
+
recordRuntimeEvent?: (
|
|
99
|
+
category: string,
|
|
100
|
+
error: unknown,
|
|
101
|
+
details?: Record<string, unknown>,
|
|
102
|
+
) => void;
|
|
103
|
+
ownershipCheckMs?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readLocks(path = getLocksPath()): Record<string, unknown> {
|
|
107
|
+
if (!existsSync(path)) return {};
|
|
108
|
+
try {
|
|
109
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
110
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
111
|
+
? (value as Record<string, unknown>)
|
|
112
|
+
: {};
|
|
113
|
+
} catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function writeLocks(path: string, locks: Record<string, unknown>): void {
|
|
119
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
120
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
|
|
123
|
+
renameSync(tempPath, path);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
try {
|
|
126
|
+
unlinkSync(tempPath);
|
|
127
|
+
} catch {
|
|
128
|
+
/* best effort */
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseTelegramLockEntry(
|
|
135
|
+
value: unknown,
|
|
136
|
+
): TelegramLockEntry | undefined {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
138
|
+
return undefined;
|
|
139
|
+
const record = value as Record<string, unknown>;
|
|
140
|
+
if (typeof record.pid !== "number") return undefined;
|
|
141
|
+
return {
|
|
142
|
+
pid: record.pid,
|
|
143
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isProcessAlive(pid: number): boolean {
|
|
148
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, 0);
|
|
151
|
+
return true;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return (error as { code?: string }).code === "EPERM";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatLock(lock: TelegramLockEntry): string {
|
|
158
|
+
return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getLockState(
|
|
162
|
+
lock: TelegramLockEntry | undefined,
|
|
163
|
+
pid: number,
|
|
164
|
+
isAlive: (pid: number) => boolean,
|
|
165
|
+
): TelegramLockState {
|
|
166
|
+
if (!lock) return { kind: "inactive" };
|
|
167
|
+
if (lock.pid === pid) return { kind: "active-here", lock };
|
|
168
|
+
if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
|
|
169
|
+
return { kind: "stale", lock };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ownsLockContext(
|
|
173
|
+
lock: TelegramLockEntry | undefined,
|
|
174
|
+
pid: number,
|
|
175
|
+
ctx?: TelegramLockContext,
|
|
176
|
+
): boolean {
|
|
177
|
+
if (!lock || lock.pid !== pid) return false;
|
|
178
|
+
return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
|
|
182
|
+
return { cwd: ctx.cwd };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatLockState(state: TelegramLockState): string {
|
|
186
|
+
switch (state.kind) {
|
|
187
|
+
case "inactive":
|
|
188
|
+
return "inactive";
|
|
189
|
+
case "active-here":
|
|
190
|
+
return "active here";
|
|
191
|
+
case "active-elsewhere":
|
|
192
|
+
return `active elsewhere (${formatLock(state.lock)})`;
|
|
193
|
+
case "stale":
|
|
194
|
+
return `stale (${formatLock(state.lock)})`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
199
|
+
options: TelegramLockRuntimeOptions = {},
|
|
200
|
+
): TelegramLockRuntime<TContext> {
|
|
201
|
+
const key = options.key ?? TELEGRAM_LOCK_KEY;
|
|
202
|
+
const locksPath = options.locksPath ?? getLocksPath();
|
|
203
|
+
const pid = options.pid ?? process.pid;
|
|
204
|
+
const isAlive = options.isProcessAlive ?? isProcessAlive;
|
|
205
|
+
const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
|
|
206
|
+
const writeLock = (lock: TelegramLockEntry) => {
|
|
207
|
+
const locks = readLocks(locksPath);
|
|
208
|
+
locks[key] = lock;
|
|
209
|
+
writeLocks(locksPath, locks);
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
acquire: (ctx, acquireOptions = {}) => {
|
|
213
|
+
const state = getLockState(readLock(), pid, isAlive);
|
|
214
|
+
if (state.kind === "active-elsewhere" && !acquireOptions.force)
|
|
215
|
+
return { ok: false, lock: state.lock };
|
|
216
|
+
const lock = { pid, cwd: ctx.cwd };
|
|
217
|
+
writeLock(lock);
|
|
218
|
+
return { ok: true, lock, replacedStale: state.kind === "stale" };
|
|
219
|
+
},
|
|
220
|
+
release: () => {
|
|
221
|
+
const state = getLockState(readLock(), pid, isAlive);
|
|
222
|
+
if (state.kind === "active-here" || state.kind === "stale") {
|
|
223
|
+
const locks = readLocks(locksPath);
|
|
224
|
+
delete locks[key];
|
|
225
|
+
writeLocks(locksPath, locks);
|
|
226
|
+
}
|
|
227
|
+
return state;
|
|
228
|
+
},
|
|
229
|
+
getState: () => getLockState(readLock(), pid, isAlive),
|
|
230
|
+
getStatusLabel: () =>
|
|
231
|
+
formatLockState(getLockState(readLock(), pid, isAlive)),
|
|
232
|
+
owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createTelegramLockedPollingRuntime<
|
|
237
|
+
TContext extends TelegramLockContext,
|
|
238
|
+
>(
|
|
239
|
+
deps: TelegramLockedPollingRuntimeDeps<TContext>,
|
|
240
|
+
): TelegramLockedPollingRuntime<TContext> {
|
|
241
|
+
let ownershipInterval: ReturnType<typeof setInterval> | undefined;
|
|
242
|
+
let ownershipStop: Promise<void> | undefined;
|
|
243
|
+
const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
|
|
244
|
+
const stopOwnershipWatcher = () => {
|
|
245
|
+
if (!ownershipInterval) return;
|
|
246
|
+
clearInterval(ownershipInterval);
|
|
247
|
+
ownershipInterval = undefined;
|
|
248
|
+
};
|
|
249
|
+
const updateStatusSafely = (ctx: TContext, phase: string) => {
|
|
250
|
+
try {
|
|
251
|
+
deps.updateStatus(ctx);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
deps.recordRuntimeEvent?.("lock", error, { phase });
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const suspendPolling = async () => {
|
|
257
|
+
stopOwnershipWatcher();
|
|
258
|
+
if (ownershipStop) {
|
|
259
|
+
await ownershipStop;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await deps.stopPolling();
|
|
263
|
+
};
|
|
264
|
+
const stopAfterOwnershipLoss = (ctx: TContext) => {
|
|
265
|
+
if (ownershipStop) return;
|
|
266
|
+
stopOwnershipWatcher();
|
|
267
|
+
ownershipStop = deps
|
|
268
|
+
.stopPolling()
|
|
269
|
+
.catch((error) =>
|
|
270
|
+
deps.recordRuntimeEvent?.("lock", error, { phase: "ownership-loss" }),
|
|
271
|
+
)
|
|
272
|
+
.finally(() => {
|
|
273
|
+
ownershipStop = undefined;
|
|
274
|
+
updateStatusSafely(ctx, "ownership-loss-status");
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
const startOwnershipWatcher = (ctx: TContext) => {
|
|
278
|
+
const owner = snapshotLockContext(ctx);
|
|
279
|
+
stopOwnershipWatcher();
|
|
280
|
+
ownershipInterval = setInterval(() => {
|
|
281
|
+
if (deps.lock.owns(owner)) return;
|
|
282
|
+
stopAfterOwnershipLoss(ctx);
|
|
283
|
+
}, ownershipCheckMs);
|
|
284
|
+
ownershipInterval.unref?.();
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
start: async (ctx, options = {}) => {
|
|
288
|
+
if (!deps.hasBotToken())
|
|
289
|
+
return { ok: false, message: "Telegram bot is not configured." };
|
|
290
|
+
const acquired = deps.lock.acquire(ctx, options);
|
|
291
|
+
if (!acquired.ok) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
canTakeover: true,
|
|
295
|
+
owner: formatLock(acquired.lock),
|
|
296
|
+
message: `Telegram bridge is active in another pi instance (${formatLock(acquired.lock)}).`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
await deps.startPolling(ctx);
|
|
300
|
+
startOwnershipWatcher(ctx);
|
|
301
|
+
deps.updateStatus(ctx);
|
|
302
|
+
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
303
|
+
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
304
|
+
},
|
|
305
|
+
stop: async () => {
|
|
306
|
+
await suspendPolling();
|
|
307
|
+
const state = deps.lock.release();
|
|
308
|
+
if (state.kind === "active-elsewhere") {
|
|
309
|
+
return `Telegram bridge is active in another pi instance (${formatLock(state.lock)}).`;
|
|
310
|
+
}
|
|
311
|
+
if (state.kind === "stale")
|
|
312
|
+
return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
|
|
313
|
+
return "Telegram bridge disconnected.";
|
|
314
|
+
},
|
|
315
|
+
suspend: suspendPolling,
|
|
316
|
+
onSessionStart: async (_event, ctx) => {
|
|
317
|
+
if (!deps.hasBotToken()) return;
|
|
318
|
+
const ownsCurrentLock = deps.lock.owns(ctx);
|
|
319
|
+
const state = ownsCurrentLock ? undefined : deps.lock.getState();
|
|
320
|
+
const canResumeStaleSameCwd =
|
|
321
|
+
state?.kind === "stale" && state.lock.cwd === ctx.cwd;
|
|
322
|
+
if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
|
|
323
|
+
try {
|
|
324
|
+
if (canResumeStaleSameCwd) {
|
|
325
|
+
const acquired = deps.lock.acquire(ctx);
|
|
326
|
+
if (!acquired.ok) return;
|
|
327
|
+
}
|
|
328
|
+
await deps.startPolling(ctx);
|
|
329
|
+
startOwnershipWatcher(ctx);
|
|
330
|
+
deps.updateStatus(ctx);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
package/lib/media.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { basename, dirname } from "node:path";
|
|
7
|
+
|
|
6
8
|
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
|
|
7
9
|
|
|
8
10
|
export interface TelegramPhotoSize {
|
|
@@ -89,10 +91,20 @@ export interface TelegramMediaGroupControllerOptions {
|
|
|
89
91
|
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
export type TelegramAttachmentKind =
|
|
95
|
+
| "photo"
|
|
96
|
+
| "document"
|
|
97
|
+
| "video"
|
|
98
|
+
| "audio"
|
|
99
|
+
| "voice"
|
|
100
|
+
| "animation"
|
|
101
|
+
| "sticker";
|
|
102
|
+
|
|
92
103
|
export interface TelegramFileInfo {
|
|
93
104
|
file_id: string;
|
|
94
105
|
fileName: string;
|
|
95
106
|
mimeType?: string;
|
|
107
|
+
kind: TelegramAttachmentKind;
|
|
96
108
|
isImage: boolean;
|
|
97
109
|
}
|
|
98
110
|
|
|
@@ -101,6 +113,7 @@ export interface DownloadedTelegramFile {
|
|
|
101
113
|
fileName?: string;
|
|
102
114
|
isImage?: boolean;
|
|
103
115
|
mimeType?: string;
|
|
116
|
+
kind?: TelegramAttachmentKind;
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
export interface DownloadedTelegramMessageFile {
|
|
@@ -108,6 +121,7 @@ export interface DownloadedTelegramMessageFile {
|
|
|
108
121
|
fileName: string;
|
|
109
122
|
isImage: boolean;
|
|
110
123
|
mimeType?: string;
|
|
124
|
+
kind?: TelegramAttachmentKind;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
export interface DownloadTelegramMessageFilesDeps {
|
|
@@ -281,17 +295,39 @@ export function createTelegramMediaGroupDispatchRuntime<
|
|
|
281
295
|
};
|
|
282
296
|
}
|
|
283
297
|
|
|
298
|
+
function appendTelegramListSection(
|
|
299
|
+
text: string,
|
|
300
|
+
title: string,
|
|
301
|
+
items: string[],
|
|
302
|
+
): string {
|
|
303
|
+
if (items.length === 0) return text;
|
|
304
|
+
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
305
|
+
return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function appendTelegramAttachmentSection(
|
|
309
|
+
text: string,
|
|
310
|
+
files: Pick<DownloadedTelegramFile, "path">[],
|
|
311
|
+
): string {
|
|
312
|
+
if (files.length === 0) return text;
|
|
313
|
+
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
|
|
314
|
+
const sameDir = dirs.length === 1;
|
|
315
|
+
const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
|
|
316
|
+
const items = sameDir
|
|
317
|
+
? files.map((file) => `/${basename(file.path)}`)
|
|
318
|
+
: files.map((file) => file.path);
|
|
319
|
+
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
320
|
+
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
284
323
|
export function formatTelegramHistoryText(
|
|
285
324
|
rawText: string,
|
|
286
325
|
files: DownloadedTelegramFile[],
|
|
326
|
+
handlerOutputs: string[] = [],
|
|
287
327
|
): string {
|
|
288
328
|
let summary = rawText.length > 0 ? rawText : "(no text)";
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
for (const file of files) {
|
|
292
|
-
summary += `\n- ${file.path}`;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
329
|
+
summary = appendTelegramAttachmentSection(summary, files);
|
|
330
|
+
summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
|
|
295
331
|
return summary;
|
|
296
332
|
}
|
|
297
333
|
|
|
@@ -306,6 +342,7 @@ export async function downloadTelegramMessageFiles(
|
|
|
306
342
|
fileName: file.fileName,
|
|
307
343
|
isImage: file.isImage,
|
|
308
344
|
mimeType: file.mimeType,
|
|
345
|
+
kind: file.kind,
|
|
309
346
|
});
|
|
310
347
|
}
|
|
311
348
|
return downloaded;
|
|
@@ -325,6 +362,7 @@ export function collectTelegramFileInfos(
|
|
|
325
362
|
file_id: photo.file_id,
|
|
326
363
|
fileName: `photo-${message.message_id}.jpg`,
|
|
327
364
|
mimeType: "image/jpeg",
|
|
365
|
+
kind: "photo",
|
|
328
366
|
isImage: true,
|
|
329
367
|
});
|
|
330
368
|
}
|
|
@@ -340,6 +378,7 @@ export function collectTelegramFileInfos(
|
|
|
340
378
|
file_id: message.document.file_id,
|
|
341
379
|
fileName,
|
|
342
380
|
mimeType: message.document.mime_type,
|
|
381
|
+
kind: "document",
|
|
343
382
|
isImage: isImageMimeType(message.document.mime_type),
|
|
344
383
|
});
|
|
345
384
|
}
|
|
@@ -354,6 +393,7 @@ export function collectTelegramFileInfos(
|
|
|
354
393
|
file_id: message.video.file_id,
|
|
355
394
|
fileName,
|
|
356
395
|
mimeType: message.video.mime_type,
|
|
396
|
+
kind: "video",
|
|
357
397
|
isImage: false,
|
|
358
398
|
});
|
|
359
399
|
}
|
|
@@ -368,6 +408,7 @@ export function collectTelegramFileInfos(
|
|
|
368
408
|
file_id: message.audio.file_id,
|
|
369
409
|
fileName,
|
|
370
410
|
mimeType: message.audio.mime_type,
|
|
411
|
+
kind: "audio",
|
|
371
412
|
isImage: false,
|
|
372
413
|
});
|
|
373
414
|
}
|
|
@@ -379,6 +420,7 @@ export function collectTelegramFileInfos(
|
|
|
379
420
|
".ogg",
|
|
380
421
|
)}`,
|
|
381
422
|
mimeType: message.voice.mime_type,
|
|
423
|
+
kind: "voice",
|
|
382
424
|
isImage: false,
|
|
383
425
|
});
|
|
384
426
|
}
|
|
@@ -393,6 +435,7 @@ export function collectTelegramFileInfos(
|
|
|
393
435
|
file_id: message.animation.file_id,
|
|
394
436
|
fileName,
|
|
395
437
|
mimeType: message.animation.mime_type,
|
|
438
|
+
kind: "animation",
|
|
396
439
|
isImage: false,
|
|
397
440
|
});
|
|
398
441
|
}
|
|
@@ -401,6 +444,7 @@ export function collectTelegramFileInfos(
|
|
|
401
444
|
file_id: message.sticker.file_id,
|
|
402
445
|
fileName: `sticker-${message.message_id}.webp`,
|
|
403
446
|
mimeType: "image/webp",
|
|
447
|
+
kind: "sticker",
|
|
404
448
|
isImage: true,
|
|
405
449
|
});
|
|
406
450
|
}
|
package/lib/menu.ts
CHANGED
|
@@ -807,10 +807,6 @@ export function buildTelegramModelCallbackPlan<
|
|
|
807
807
|
export async function openTelegramStatusMenu<
|
|
808
808
|
TModel extends MenuModel = MenuModel,
|
|
809
809
|
>(deps: TelegramStatusMenuOpenDeps<TModel>): Promise<void> {
|
|
810
|
-
if (!deps.isIdle()) {
|
|
811
|
-
await deps.sendBusyMessage();
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
810
|
const state = await deps.getModelMenuState();
|
|
815
811
|
const messageId = await deps.sendStatusMenu(
|
|
816
812
|
state,
|
package/lib/pi.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface PiSettingsManager {
|
|
|
33
33
|
|
|
34
34
|
export interface PiExtensionApiRuntimePorts {
|
|
35
35
|
sendUserMessage: ExtensionAPI["sendUserMessage"];
|
|
36
|
+
exec: ExtensionAPI["exec"];
|
|
36
37
|
getThinkingLevel: ExtensionAPI["getThinkingLevel"];
|
|
37
38
|
setThinkingLevel: ExtensionAPI["setThinkingLevel"];
|
|
38
39
|
setModel: ExtensionAPI["setModel"];
|
|
@@ -41,11 +42,16 @@ export interface PiExtensionApiRuntimePorts {
|
|
|
41
42
|
export function createExtensionApiRuntimePorts(
|
|
42
43
|
api: Pick<
|
|
43
44
|
ExtensionAPI,
|
|
44
|
-
|
|
45
|
+
| "sendUserMessage"
|
|
46
|
+
| "exec"
|
|
47
|
+
| "getThinkingLevel"
|
|
48
|
+
| "setThinkingLevel"
|
|
49
|
+
| "setModel"
|
|
45
50
|
>,
|
|
46
51
|
): PiExtensionApiRuntimePorts {
|
|
47
52
|
return {
|
|
48
53
|
sendUserMessage: (content) => api.sendUserMessage(content),
|
|
54
|
+
exec: (command, args, options) => api.exec(command, args, options),
|
|
49
55
|
getThinkingLevel: () => api.getThinkingLevel(),
|
|
50
56
|
setThinkingLevel: (level) => api.setThinkingLevel(level),
|
|
51
57
|
setModel: (model) => api.setModel(model),
|
|
@@ -62,6 +68,10 @@ export function getExtensionContextModel(
|
|
|
62
68
|
return ctx.model;
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
export function getExtensionContextCwd(ctx: ExtensionContext): string {
|
|
72
|
+
return ctx.cwd;
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
export function isExtensionContextIdle(ctx: ExtensionContext): boolean {
|
|
66
76
|
return ctx.isIdle();
|
|
67
77
|
}
|
package/lib/prompts.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram prompt injection helpers
|
|
3
|
+
* Owns Telegram-specific system prompt suffixes injected into pi agent turns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BeforeAgentStartEvent } from "./pi.ts";
|
|
7
|
+
import { TELEGRAM_PREFIX } from "./turns.ts";
|
|
8
|
+
|
|
9
|
+
const SYSTEM_PROMPT_SUFFIX = `
|
|
10
|
+
|
|
11
|
+
Telegram bridge extension is active.
|
|
12
|
+
- Messages forwarded from Telegram are prefixed with "[telegram]".
|
|
13
|
+
- [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
|
|
14
|
+
- Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
|
|
15
|
+
- If a [telegram] user asked for a file or generated artifact, use telegram_attach with the local path instead of only mentioning the path in text.
|
|
16
|
+
- Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`;
|
|
17
|
+
|
|
18
|
+
export function buildTelegramBridgeSystemPrompt(options: {
|
|
19
|
+
prompt: string;
|
|
20
|
+
systemPrompt: string;
|
|
21
|
+
telegramPrefix?: string;
|
|
22
|
+
systemPromptSuffix: string;
|
|
23
|
+
}): { systemPrompt: string } {
|
|
24
|
+
const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
|
|
25
|
+
const suffix = options.prompt.trimStart().startsWith(telegramPrefix)
|
|
26
|
+
? `${options.systemPromptSuffix}\n- The current user message came from Telegram.`
|
|
27
|
+
: options.systemPromptSuffix;
|
|
28
|
+
return { systemPrompt: options.systemPrompt + suffix };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createTelegramBeforeAgentStartHook(
|
|
32
|
+
options: {
|
|
33
|
+
telegramPrefix?: string;
|
|
34
|
+
systemPromptSuffix?: string;
|
|
35
|
+
} = {},
|
|
36
|
+
): (event: BeforeAgentStartEvent) => { systemPrompt: string } {
|
|
37
|
+
return (event) =>
|
|
38
|
+
buildTelegramBridgeSystemPrompt({
|
|
39
|
+
prompt: event.prompt,
|
|
40
|
+
systemPrompt: event.systemPrompt,
|
|
41
|
+
telegramPrefix: options.telegramPrefix,
|
|
42
|
+
systemPromptSuffix: options.systemPromptSuffix ?? SYSTEM_PROMPT_SUFFIX,
|
|
43
|
+
});
|
|
44
|
+
}
|
package/lib/queue.ts
CHANGED
|
@@ -856,8 +856,9 @@ export function createTelegramAgentEndHook<
|
|
|
856
856
|
preserveQueuedTurnsAsHistory: deps.getPreserveQueuedTurnsAsHistory(),
|
|
857
857
|
resetRuntimeState: deps.resetRuntimeState,
|
|
858
858
|
updateStatus: () => deps.updateStatus(ctx),
|
|
859
|
-
dispatchNextQueuedTelegramTurn: () =>
|
|
860
|
-
deps.dispatchNextQueuedTelegramTurn(ctx),
|
|
859
|
+
dispatchNextQueuedTelegramTurn: () => {
|
|
860
|
+
setTimeout(() => deps.dispatchNextQueuedTelegramTurn(ctx), 0);
|
|
861
|
+
},
|
|
861
862
|
clearPreview: deps.clearPreview,
|
|
862
863
|
setPreviewPendingText: deps.setPreviewPendingText,
|
|
863
864
|
finalizeMarkdownPreview: deps.finalizeMarkdownPreview,
|
|
@@ -1100,6 +1101,7 @@ export interface TelegramPromptEnqueueControllerDeps<
|
|
|
1100
1101
|
createTurn: (
|
|
1101
1102
|
messages: TMessage[],
|
|
1102
1103
|
historyTurns: PendingTelegramTurn[],
|
|
1104
|
+
ctx: TContext,
|
|
1103
1105
|
) => Promise<PendingTelegramTurn>;
|
|
1104
1106
|
updateStatus: (ctx: TContext) => void;
|
|
1105
1107
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
@@ -1365,6 +1367,8 @@ export function createTelegramPromptEnqueueController<
|
|
|
1365
1367
|
enqueue: (messages, ctx) =>
|
|
1366
1368
|
enqueueTelegramPromptTurnRuntime(messages, {
|
|
1367
1369
|
...deps,
|
|
1370
|
+
createTurn: (nextMessages, historyTurns) =>
|
|
1371
|
+
deps.createTurn(nextMessages, historyTurns, ctx),
|
|
1368
1372
|
updateStatus: () => deps.updateStatus(ctx),
|
|
1369
1373
|
dispatchNextQueuedTelegramTurn: () =>
|
|
1370
1374
|
deps.dispatchNextQueuedTelegramTurn(ctx),
|
|
@@ -1397,8 +1401,9 @@ export interface TelegramRuntimeEventRecorderPort {
|
|
|
1397
1401
|
) => void;
|
|
1398
1402
|
}
|
|
1399
1403
|
|
|
1400
|
-
export interface TelegramControlRuntimeDeps<
|
|
1401
|
-
|
|
1404
|
+
export interface TelegramControlRuntimeDeps<
|
|
1405
|
+
TContext,
|
|
1406
|
+
> extends TelegramRuntimeEventRecorderPort {
|
|
1402
1407
|
ctx: TContext;
|
|
1403
1408
|
sendTextReply: (
|
|
1404
1409
|
chatId: number,
|
|
@@ -1451,8 +1456,9 @@ export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
|
|
|
1451
1456
|
onIdle: () => void;
|
|
1452
1457
|
}
|
|
1453
1458
|
|
|
1454
|
-
export interface TelegramQueueDispatchControllerDeps<
|
|
1455
|
-
|
|
1459
|
+
export interface TelegramQueueDispatchControllerDeps<
|
|
1460
|
+
TContext = unknown,
|
|
1461
|
+
> extends TelegramRuntimeEventRecorderPort {
|
|
1456
1462
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
1457
1463
|
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
1458
1464
|
canDispatch: (ctx: TContext) => boolean;
|