@llblab/pi-telegram 0.3.0 → 0.4.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 +27 -5
- package/docs/README.md +2 -3
- package/docs/architecture.md +15 -7
- package/docs/locks.md +136 -0
- package/index.ts +76 -140
- package/lib/config.ts +17 -5
- package/lib/handlers.ts +474 -0
- package/lib/locks.ts +306 -0
- package/lib/media.ts +50 -6
- package/lib/pi.ts +11 -1
- package/lib/queue.ts +3 -0
- package/lib/registration.ts +89 -5
- package/lib/routing.ts +217 -0
- package/lib/setup.ts +17 -3
- package/lib/status.ts +4 -0
- package/lib/turns.ts +98 -21
- package/package.json +1 -1
package/lib/locks.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram singleton lock helpers
|
|
3
|
+
* Owns shared locks.json access and Telegram bridge ownership semantics
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
|
|
11
|
+
|
|
12
|
+
function getAgentDir(): string {
|
|
13
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
14
|
+
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
15
|
+
: join(homedir(), ".pi", "agent");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getLocksPath(): string {
|
|
19
|
+
return join(getAgentDir(), "locks.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TelegramLockEntry {
|
|
23
|
+
pid: number;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TelegramLockContext {
|
|
28
|
+
cwd: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type TelegramLockState =
|
|
32
|
+
| { kind: "inactive" }
|
|
33
|
+
| { kind: "active-here"; lock: TelegramLockEntry }
|
|
34
|
+
| { kind: "active-elsewhere"; lock: TelegramLockEntry }
|
|
35
|
+
| { kind: "stale"; lock: TelegramLockEntry };
|
|
36
|
+
|
|
37
|
+
export interface TelegramLockAcquireOptions {
|
|
38
|
+
force?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type TelegramLockAcquireResult =
|
|
42
|
+
| { ok: true; lock: TelegramLockEntry; replacedStale: boolean }
|
|
43
|
+
| { ok: false; lock: TelegramLockEntry };
|
|
44
|
+
|
|
45
|
+
export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
46
|
+
acquire: (
|
|
47
|
+
ctx: TContext,
|
|
48
|
+
options?: TelegramLockAcquireOptions,
|
|
49
|
+
) => TelegramLockAcquireResult;
|
|
50
|
+
release: () => TelegramLockState;
|
|
51
|
+
getState: () => TelegramLockState;
|
|
52
|
+
getStatusLabel: () => string;
|
|
53
|
+
owns: (ctx?: TelegramLockContext) => boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TelegramLockRuntimeOptions {
|
|
57
|
+
key?: string;
|
|
58
|
+
locksPath?: string;
|
|
59
|
+
pid?: number;
|
|
60
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TelegramLockedPollingStartOptions {
|
|
64
|
+
force?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type TelegramLockedPollingStartResult =
|
|
68
|
+
| { ok: true; message: string; canTakeover?: false }
|
|
69
|
+
| { ok: false; message: string; canTakeover?: boolean; owner?: string };
|
|
70
|
+
|
|
71
|
+
export interface TelegramLockedPollingRuntime<TContext extends TelegramLockContext> {
|
|
72
|
+
start: (
|
|
73
|
+
ctx: TContext,
|
|
74
|
+
options?: TelegramLockedPollingStartOptions,
|
|
75
|
+
) => Promise<TelegramLockedPollingStartResult>;
|
|
76
|
+
stop: () => Promise<string>;
|
|
77
|
+
suspend: () => Promise<void>;
|
|
78
|
+
onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TelegramLockedPollingRuntimeDeps<TContext extends TelegramLockContext> {
|
|
82
|
+
lock: TelegramLockRuntime<TContext>;
|
|
83
|
+
hasBotToken: () => boolean;
|
|
84
|
+
startPolling: (ctx: TContext) => void | Promise<void>;
|
|
85
|
+
stopPolling: () => Promise<void>;
|
|
86
|
+
updateStatus: (ctx: TContext) => void;
|
|
87
|
+
recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
|
|
88
|
+
ownershipCheckMs?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readLocks(path = getLocksPath()): Record<string, unknown> {
|
|
92
|
+
if (!existsSync(path)) return {};
|
|
93
|
+
try {
|
|
94
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
95
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
96
|
+
? (value as Record<string, unknown>)
|
|
97
|
+
: {};
|
|
98
|
+
} catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function writeLocks(path: string, locks: Record<string, unknown>): void {
|
|
104
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
105
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
|
|
108
|
+
renameSync(tempPath, path);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(tempPath);
|
|
112
|
+
} catch {
|
|
113
|
+
/* best effort */
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseTelegramLockEntry(value: unknown): TelegramLockEntry | undefined {
|
|
120
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
121
|
+
const record = value as Record<string, unknown>;
|
|
122
|
+
if (typeof record.pid !== "number") return undefined;
|
|
123
|
+
return {
|
|
124
|
+
pid: record.pid,
|
|
125
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function isProcessAlive(pid: number): boolean {
|
|
130
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return true;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return (error as { code?: string }).code === "EPERM";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatLock(lock: TelegramLockEntry): string {
|
|
140
|
+
return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getLockState(lock: TelegramLockEntry | undefined, pid: number, isAlive: (pid: number) => boolean): TelegramLockState {
|
|
144
|
+
if (!lock) return { kind: "inactive" };
|
|
145
|
+
if (lock.pid === pid) return { kind: "active-here", lock };
|
|
146
|
+
if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
|
|
147
|
+
return { kind: "stale", lock };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ownsLockContext(
|
|
151
|
+
lock: TelegramLockEntry | undefined,
|
|
152
|
+
pid: number,
|
|
153
|
+
ctx?: TelegramLockContext,
|
|
154
|
+
): boolean {
|
|
155
|
+
if (!lock || lock.pid !== pid) return false;
|
|
156
|
+
return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
|
|
160
|
+
return { cwd: ctx.cwd };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatLockState(state: TelegramLockState): string {
|
|
164
|
+
switch (state.kind) {
|
|
165
|
+
case "inactive":
|
|
166
|
+
return "inactive";
|
|
167
|
+
case "active-here":
|
|
168
|
+
return "active here";
|
|
169
|
+
case "active-elsewhere":
|
|
170
|
+
return `active elsewhere (${formatLock(state.lock)})`;
|
|
171
|
+
case "stale":
|
|
172
|
+
return `stale (${formatLock(state.lock)})`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
177
|
+
options: TelegramLockRuntimeOptions = {},
|
|
178
|
+
): TelegramLockRuntime<TContext> {
|
|
179
|
+
const key = options.key ?? TELEGRAM_LOCK_KEY;
|
|
180
|
+
const locksPath = options.locksPath ?? getLocksPath();
|
|
181
|
+
const pid = options.pid ?? process.pid;
|
|
182
|
+
const isAlive = options.isProcessAlive ?? isProcessAlive;
|
|
183
|
+
const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
|
|
184
|
+
const writeLock = (lock: TelegramLockEntry) => {
|
|
185
|
+
const locks = readLocks(locksPath);
|
|
186
|
+
locks[key] = lock;
|
|
187
|
+
writeLocks(locksPath, locks);
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
acquire: (ctx, acquireOptions = {}) => {
|
|
191
|
+
const state = getLockState(readLock(), pid, isAlive);
|
|
192
|
+
if (state.kind === "active-elsewhere" && !acquireOptions.force)
|
|
193
|
+
return { ok: false, lock: state.lock };
|
|
194
|
+
const lock = { pid, cwd: ctx.cwd };
|
|
195
|
+
writeLock(lock);
|
|
196
|
+
return { ok: true, lock, replacedStale: state.kind === "stale" };
|
|
197
|
+
},
|
|
198
|
+
release: () => {
|
|
199
|
+
const state = getLockState(readLock(), pid, isAlive);
|
|
200
|
+
if (state.kind === "active-here" || state.kind === "stale") {
|
|
201
|
+
const locks = readLocks(locksPath);
|
|
202
|
+
delete locks[key];
|
|
203
|
+
writeLocks(locksPath, locks);
|
|
204
|
+
}
|
|
205
|
+
return state;
|
|
206
|
+
},
|
|
207
|
+
getState: () => getLockState(readLock(), pid, isAlive),
|
|
208
|
+
getStatusLabel: () => formatLockState(getLockState(readLock(), pid, isAlive)),
|
|
209
|
+
owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createTelegramLockedPollingRuntime<TContext extends TelegramLockContext>(
|
|
214
|
+
deps: TelegramLockedPollingRuntimeDeps<TContext>,
|
|
215
|
+
): TelegramLockedPollingRuntime<TContext> {
|
|
216
|
+
let ownershipInterval: ReturnType<typeof setInterval> | undefined;
|
|
217
|
+
let ownershipStop: Promise<void> | undefined;
|
|
218
|
+
const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
|
|
219
|
+
const stopOwnershipWatcher = () => {
|
|
220
|
+
if (!ownershipInterval) return;
|
|
221
|
+
clearInterval(ownershipInterval);
|
|
222
|
+
ownershipInterval = undefined;
|
|
223
|
+
};
|
|
224
|
+
const updateStatusSafely = (ctx: TContext, phase: string) => {
|
|
225
|
+
try {
|
|
226
|
+
deps.updateStatus(ctx);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
deps.recordRuntimeEvent?.("lock", error, { phase });
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const suspendPolling = async () => {
|
|
232
|
+
stopOwnershipWatcher();
|
|
233
|
+
if (ownershipStop) {
|
|
234
|
+
await ownershipStop;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await deps.stopPolling();
|
|
238
|
+
};
|
|
239
|
+
const stopAfterOwnershipLoss = (ctx: TContext) => {
|
|
240
|
+
if (ownershipStop) return;
|
|
241
|
+
stopOwnershipWatcher();
|
|
242
|
+
ownershipStop = deps.stopPolling()
|
|
243
|
+
.catch((error) => deps.recordRuntimeEvent?.("lock", error, { phase: "ownership-loss" }))
|
|
244
|
+
.finally(() => {
|
|
245
|
+
ownershipStop = undefined;
|
|
246
|
+
updateStatusSafely(ctx, "ownership-loss-status");
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
const startOwnershipWatcher = (ctx: TContext) => {
|
|
250
|
+
const owner = snapshotLockContext(ctx);
|
|
251
|
+
stopOwnershipWatcher();
|
|
252
|
+
ownershipInterval = setInterval(() => {
|
|
253
|
+
if (deps.lock.owns(owner)) return;
|
|
254
|
+
stopAfterOwnershipLoss(ctx);
|
|
255
|
+
}, ownershipCheckMs);
|
|
256
|
+
ownershipInterval.unref?.();
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
start: async (ctx, options = {}) => {
|
|
260
|
+
if (!deps.hasBotToken()) return { ok: false, message: "Telegram bot is not configured." };
|
|
261
|
+
const acquired = deps.lock.acquire(ctx, options);
|
|
262
|
+
if (!acquired.ok) {
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
canTakeover: true,
|
|
266
|
+
owner: formatLock(acquired.lock),
|
|
267
|
+
message: `Telegram bridge is active in another pi instance (${formatLock(acquired.lock)}).`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
await deps.startPolling(ctx);
|
|
271
|
+
startOwnershipWatcher(ctx);
|
|
272
|
+
deps.updateStatus(ctx);
|
|
273
|
+
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
274
|
+
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
275
|
+
},
|
|
276
|
+
stop: async () => {
|
|
277
|
+
await suspendPolling();
|
|
278
|
+
const state = deps.lock.release();
|
|
279
|
+
if (state.kind === "active-elsewhere") {
|
|
280
|
+
return `Telegram bridge is active in another pi instance (${formatLock(state.lock)}).`;
|
|
281
|
+
}
|
|
282
|
+
if (state.kind === "stale") return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
|
|
283
|
+
return "Telegram bridge disconnected.";
|
|
284
|
+
},
|
|
285
|
+
suspend: suspendPolling,
|
|
286
|
+
onSessionStart: async (_event, ctx) => {
|
|
287
|
+
if (!deps.hasBotToken()) return;
|
|
288
|
+
const ownsCurrentLock = deps.lock.owns(ctx);
|
|
289
|
+
const state = ownsCurrentLock ? undefined : deps.lock.getState();
|
|
290
|
+
const canResumeStaleSameCwd =
|
|
291
|
+
state?.kind === "stale" && state.lock.cwd === ctx.cwd;
|
|
292
|
+
if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
|
|
293
|
+
try {
|
|
294
|
+
if (canResumeStaleSameCwd) {
|
|
295
|
+
const acquired = deps.lock.acquire(ctx);
|
|
296
|
+
if (!acquired.ok) return;
|
|
297
|
+
}
|
|
298
|
+
await deps.startPolling(ctx);
|
|
299
|
+
startOwnershipWatcher(ctx);
|
|
300
|
+
deps.updateStatus(ctx);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
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/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/queue.ts
CHANGED
|
@@ -1100,6 +1100,7 @@ export interface TelegramPromptEnqueueControllerDeps<
|
|
|
1100
1100
|
createTurn: (
|
|
1101
1101
|
messages: TMessage[],
|
|
1102
1102
|
historyTurns: PendingTelegramTurn[],
|
|
1103
|
+
ctx: TContext,
|
|
1103
1104
|
) => Promise<PendingTelegramTurn>;
|
|
1104
1105
|
updateStatus: (ctx: TContext) => void;
|
|
1105
1106
|
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
|
|
@@ -1365,6 +1366,8 @@ export function createTelegramPromptEnqueueController<
|
|
|
1365
1366
|
enqueue: (messages, ctx) =>
|
|
1366
1367
|
enqueueTelegramPromptTurnRuntime(messages, {
|
|
1367
1368
|
...deps,
|
|
1369
|
+
createTurn: (nextMessages, historyTurns) =>
|
|
1370
|
+
deps.createTurn(nextMessages, historyTurns, ctx),
|
|
1368
1371
|
updateStatus: () => deps.updateStatus(ctx),
|
|
1369
1372
|
dispatchNextQueuedTelegramTurn: () =>
|
|
1370
1373
|
deps.dispatchNextQueuedTelegramTurn(ctx),
|
package/lib/registration.ts
CHANGED
|
@@ -28,7 +28,7 @@ const SYSTEM_PROMPT_SUFFIX = `
|
|
|
28
28
|
|
|
29
29
|
Telegram bridge extension is active.
|
|
30
30
|
- Messages forwarded from Telegram are prefixed with "[telegram]".
|
|
31
|
-
- [telegram] messages may include
|
|
31
|
+
- [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
|
|
32
32
|
- Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
|
|
33
33
|
- If a [telegram] user asked for a file or generated artifact, use the telegram_attach tool with the local file path so the extension can send it with your next final reply.
|
|
34
34
|
- Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.`;
|
|
@@ -96,16 +96,49 @@ export function registerTelegramAttachmentTool(
|
|
|
96
96
|
|
|
97
97
|
// --- Command Registration ---
|
|
98
98
|
|
|
99
|
+
export interface TelegramCommandStartPollingOptions {
|
|
100
|
+
force?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TelegramCommandStartPollingResult {
|
|
104
|
+
ok: boolean;
|
|
105
|
+
message?: string;
|
|
106
|
+
canTakeover?: boolean;
|
|
107
|
+
owner?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
export interface TelegramCommandRegistrationDeps {
|
|
100
111
|
promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
101
112
|
getStatusLines: () => string[];
|
|
102
113
|
reloadConfig: () => Promise<void>;
|
|
103
114
|
hasBotToken: () => boolean;
|
|
104
|
-
startPolling: (
|
|
105
|
-
|
|
115
|
+
startPolling: (
|
|
116
|
+
ctx: ExtensionCommandContext,
|
|
117
|
+
options?: TelegramCommandStartPollingOptions,
|
|
118
|
+
) =>
|
|
119
|
+
| void
|
|
120
|
+
| Promise<void | TelegramCommandStartPollingResult>
|
|
121
|
+
| TelegramCommandStartPollingResult;
|
|
122
|
+
stopPolling: () => Promise<void | string>;
|
|
106
123
|
updateStatus: (ctx: ExtensionCommandContext) => void;
|
|
107
124
|
}
|
|
108
125
|
|
|
126
|
+
function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
|
|
127
|
+
return ctx.ui.theme.fg("accent", "pi-telegram");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatTelegramTakeoverPrompt(
|
|
131
|
+
ctx: ExtensionCommandContext,
|
|
132
|
+
owner?: string,
|
|
133
|
+
): string {
|
|
134
|
+
const theme = ctx.ui.theme;
|
|
135
|
+
const action = theme.fg("warning", "move singleton lock here?");
|
|
136
|
+
const from = theme.fg("muted", "from:");
|
|
137
|
+
const to = theme.fg("muted", "to:");
|
|
138
|
+
const source = owner ?? "another pi instance";
|
|
139
|
+
return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
export function registerTelegramCommands(
|
|
110
143
|
pi: ExtensionAPI,
|
|
111
144
|
deps: TelegramCommandRegistrationDeps,
|
|
@@ -130,14 +163,30 @@ export function registerTelegramCommands(
|
|
|
130
163
|
await deps.promptForConfig(ctx);
|
|
131
164
|
return;
|
|
132
165
|
}
|
|
133
|
-
await deps.startPolling(ctx);
|
|
166
|
+
let result = await deps.startPolling(ctx);
|
|
167
|
+
if (result && !result.ok && result.canTakeover) {
|
|
168
|
+
const confirmed = await ctx.ui.confirm(
|
|
169
|
+
formatTelegramTakeoverTitle(ctx),
|
|
170
|
+
formatTelegramTakeoverPrompt(ctx, result.owner),
|
|
171
|
+
);
|
|
172
|
+
if (!confirmed) {
|
|
173
|
+
ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
|
|
174
|
+
deps.updateStatus(ctx);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
result = await deps.startPolling(ctx, { force: true });
|
|
178
|
+
}
|
|
179
|
+
if (result?.message) {
|
|
180
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "warning");
|
|
181
|
+
}
|
|
134
182
|
deps.updateStatus(ctx);
|
|
135
183
|
},
|
|
136
184
|
});
|
|
137
185
|
pi.registerCommand("telegram-disconnect", {
|
|
138
186
|
description: "Stop the Telegram bridge in this pi session",
|
|
139
187
|
handler: async (_args, ctx) => {
|
|
140
|
-
await deps.stopPolling();
|
|
188
|
+
const message = await deps.stopPolling();
|
|
189
|
+
if (message) ctx.ui.notify(message, "info");
|
|
141
190
|
deps.updateStatus(ctx);
|
|
142
191
|
},
|
|
143
192
|
});
|
|
@@ -225,6 +274,41 @@ export interface TelegramLifecycleRegistrationDeps {
|
|
|
225
274
|
onAgentEnd: (event: AgentEndEvent, ctx: ExtensionContext) => Promise<void>;
|
|
226
275
|
}
|
|
227
276
|
|
|
277
|
+
export interface TelegramSessionLifecycleHooks {
|
|
278
|
+
onSessionStart: (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void>;
|
|
279
|
+
onSessionShutdown: (
|
|
280
|
+
event: SessionShutdownEvent,
|
|
281
|
+
ctx: ExtensionContext,
|
|
282
|
+
) => Promise<void>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface TelegramExtraLifecycleHooks {
|
|
286
|
+
onSessionStart?: (
|
|
287
|
+
event: SessionStartEvent,
|
|
288
|
+
ctx: ExtensionContext,
|
|
289
|
+
) => Promise<void>;
|
|
290
|
+
onSessionShutdown?: (
|
|
291
|
+
event: SessionShutdownEvent,
|
|
292
|
+
ctx: ExtensionContext,
|
|
293
|
+
) => Promise<void>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function appendTelegramLifecycleHooks(
|
|
297
|
+
base: TelegramSessionLifecycleHooks,
|
|
298
|
+
extra: TelegramExtraLifecycleHooks,
|
|
299
|
+
): TelegramSessionLifecycleHooks {
|
|
300
|
+
return {
|
|
301
|
+
onSessionStart: async (event, ctx) => {
|
|
302
|
+
await base.onSessionStart(event, ctx);
|
|
303
|
+
await extra.onSessionStart?.(event, ctx);
|
|
304
|
+
},
|
|
305
|
+
onSessionShutdown: async (event, ctx) => {
|
|
306
|
+
await base.onSessionShutdown(event, ctx);
|
|
307
|
+
await extra.onSessionShutdown?.(event, ctx);
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
228
312
|
export function registerTelegramLifecycleHooks(
|
|
229
313
|
pi: ExtensionAPI,
|
|
230
314
|
deps: TelegramLifecycleRegistrationDeps,
|