@llblab/pi-telegram 0.17.5 → 0.18.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/AGENTS.md +67 -32
- package/BACKLOG.md +59 -19
- package/CHANGELOG.md +42 -15
- package/README.md +63 -35
- package/docs/README.md +3 -1
- package/docs/architecture.md +55 -23
- package/docs/callback-namespaces.md +1 -1
- package/docs/inbound.md +1 -1
- package/docs/locks.md +0 -2
- package/docs/multi-instance-bus.md +410 -0
- package/docs/outbound.md +4 -3
- package/docs/public-api.md +12 -10
- package/docs/sections.md +2 -2
- package/docs/ui-style.md +76 -0
- package/index.ts +789 -32
- package/lib/bindings.ts +68 -12
- package/lib/bus-api.ts +314 -0
- package/lib/bus-follower.ts +853 -0
- package/lib/bus-leader.ts +915 -0
- package/lib/bus.ts +866 -0
- package/lib/command-templates.ts +9 -11
- package/lib/commands.ts +133 -47
- package/lib/config.ts +53 -5
- package/lib/lifecycle.ts +23 -7
- package/lib/locks.ts +230 -66
- package/lib/media.ts +30 -2
- package/lib/menu-model.ts +48 -17
- package/lib/menu-queue.ts +51 -20
- package/lib/menu-settings.ts +9 -5
- package/lib/menu-status.ts +3 -0
- package/lib/menu-thinking.ts +3 -0
- package/lib/menu.ts +67 -26
- package/lib/outbound-attachments.ts +102 -17
- package/lib/outbound-buttons.ts +6 -2
- package/lib/outbound-voice.ts +31 -11
- package/lib/outbound.ts +6 -4
- package/lib/ownership.ts +119 -0
- package/lib/pi.ts +26 -3
- package/lib/polling.ts +477 -7
- package/lib/preview.ts +141 -88
- package/lib/prompt-templates.ts +3 -3
- package/lib/prompts.ts +80 -30
- package/lib/queue.ts +193 -91
- package/lib/rendering.ts +0 -25
- package/lib/replies.ts +187 -55
- package/lib/routing.ts +1673 -9
- package/lib/runtime-log.ts +123 -0
- package/lib/runtime.ts +84 -12
- package/lib/sections.ts +28 -21
- package/lib/setup.ts +9 -2
- package/lib/status.ts +532 -9
- package/lib/sync.ts +618 -0
- package/lib/target.ts +49 -0
- package/lib/telegram-api.ts +409 -41
- package/lib/text-groups.ts +5 -1
- package/lib/thread-reconciler.ts +915 -0
- package/lib/threads.ts +2205 -0
- package/lib/turns.ts +48 -3
- package/lib/updates.ts +355 -32
- package/package.json +24 -2
- package/docs/telegram-bot-api-rich-messages.md +0 -890
package/lib/lifecycle.ts
CHANGED
|
@@ -14,6 +14,9 @@ import type {
|
|
|
14
14
|
SessionCompactEvent,
|
|
15
15
|
SessionShutdownEvent,
|
|
16
16
|
SessionStartEvent,
|
|
17
|
+
ToolExecutionEndEvent,
|
|
18
|
+
ToolExecutionStartEvent,
|
|
19
|
+
ToolExecutionUpdateEvent,
|
|
17
20
|
} from "./pi.ts";
|
|
18
21
|
|
|
19
22
|
let resetTransportReplyDedupFn: (() => void) | undefined;
|
|
@@ -25,7 +28,7 @@ export function setResetTransportReplyDedup(fn: () => void): void {
|
|
|
25
28
|
export function createAgentStartDedupHook(
|
|
26
29
|
inner: (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void>,
|
|
27
30
|
): (event: AgentStartEvent, ctx: ExtensionContext) => Promise<void> {
|
|
28
|
-
return async
|
|
31
|
+
return async (event, ctx) => {
|
|
29
32
|
if (resetTransportReplyDedupFn) resetTransportReplyDedupFn();
|
|
30
33
|
return inner(event, ctx);
|
|
31
34
|
};
|
|
@@ -73,11 +76,15 @@ export interface TelegramLifecycleRegistrationDeps {
|
|
|
73
76
|
ctx: ExtensionContext,
|
|
74
77
|
) => Promise<void>;
|
|
75
78
|
onToolExecutionStart: (
|
|
76
|
-
event:
|
|
79
|
+
event: ToolExecutionStartEvent,
|
|
80
|
+
ctx: ExtensionContext,
|
|
81
|
+
) => Promise<void> | void;
|
|
82
|
+
onToolExecutionUpdate?: (
|
|
83
|
+
event: ToolExecutionUpdateEvent,
|
|
77
84
|
ctx: ExtensionContext,
|
|
78
85
|
) => Promise<void> | void;
|
|
79
86
|
onToolExecutionEnd: (
|
|
80
|
-
event:
|
|
87
|
+
event: ToolExecutionEndEvent,
|
|
81
88
|
ctx: ExtensionContext,
|
|
82
89
|
) => Promise<void> | void;
|
|
83
90
|
onMessageStart: (
|
|
@@ -148,6 +155,7 @@ export interface TelegramCompactionObserverRuntimeDeps<TContext> {
|
|
|
148
155
|
updateStatus: (ctx: TContext) => void;
|
|
149
156
|
startTypingLoop?: (ctx: TContext) => void;
|
|
150
157
|
stopTypingLoop?: () => void;
|
|
158
|
+
shouldStartTypingLoop?: () => boolean;
|
|
151
159
|
requestDeferredDispatchNextQueuedTelegramTurn: (
|
|
152
160
|
dispatch: (ctx: TContext) => void,
|
|
153
161
|
) => void;
|
|
@@ -174,6 +182,7 @@ export function createTelegramCompactionObserverRuntime<TContext>(
|
|
|
174
182
|
const setTimer = deps.setTimer ?? setTimeout;
|
|
175
183
|
const clearTimer = deps.clearTimer ?? clearTimeout;
|
|
176
184
|
let fallbackTimer: TelegramLifecycleTimer | undefined;
|
|
185
|
+
let typingStartedByObserver = false;
|
|
177
186
|
const clearFallbackTimer = (): void => {
|
|
178
187
|
if (!fallbackTimer) return;
|
|
179
188
|
clearTimer(fallbackTimer);
|
|
@@ -187,13 +196,15 @@ export function createTelegramCompactionObserverRuntime<TContext>(
|
|
|
187
196
|
return {
|
|
188
197
|
onSessionBeforeCompact: (_event, ctx) => {
|
|
189
198
|
deps.setCompactionInProgress(true);
|
|
190
|
-
deps.
|
|
199
|
+
typingStartedByObserver = deps.shouldStartTypingLoop?.() ?? true;
|
|
200
|
+
if (typingStartedByObserver) deps.startTypingLoop?.(ctx);
|
|
191
201
|
deps.updateStatus(ctx);
|
|
192
202
|
clearFallbackTimer();
|
|
193
203
|
fallbackTimer = setTimer(() => {
|
|
194
204
|
fallbackTimer = undefined;
|
|
195
205
|
deps.setCompactionInProgress(false);
|
|
196
|
-
deps.stopTypingLoop?.();
|
|
206
|
+
if (typingStartedByObserver) deps.stopTypingLoop?.();
|
|
207
|
+
typingStartedByObserver = false;
|
|
197
208
|
deps.updateStatus(ctx);
|
|
198
209
|
deps.recordRuntimeEvent?.(
|
|
199
210
|
"compact",
|
|
@@ -206,13 +217,15 @@ export function createTelegramCompactionObserverRuntime<TContext>(
|
|
|
206
217
|
onSessionCompact: (_event, ctx) => {
|
|
207
218
|
clearFallbackTimer();
|
|
208
219
|
deps.setCompactionInProgress(false);
|
|
209
|
-
deps.stopTypingLoop?.();
|
|
220
|
+
if (typingStartedByObserver) deps.stopTypingLoop?.();
|
|
221
|
+
typingStartedByObserver = false;
|
|
210
222
|
deps.updateStatus(ctx);
|
|
211
223
|
requestDispatch();
|
|
212
224
|
},
|
|
213
225
|
onSessionShutdown: () => {
|
|
214
226
|
clearFallbackTimer();
|
|
215
|
-
deps.stopTypingLoop?.();
|
|
227
|
+
if (typingStartedByObserver) deps.stopTypingLoop?.();
|
|
228
|
+
typingStartedByObserver = false;
|
|
216
229
|
},
|
|
217
230
|
};
|
|
218
231
|
}
|
|
@@ -329,6 +342,9 @@ export function registerTelegramLifecycleHooks(
|
|
|
329
342
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
330
343
|
await deps.onToolExecutionStart(event, ctx);
|
|
331
344
|
});
|
|
345
|
+
pi.on("tool_execution_update", async (event, ctx) => {
|
|
346
|
+
await deps.onToolExecutionUpdate?.(event, ctx);
|
|
347
|
+
});
|
|
332
348
|
pi.on("tool_execution_end", async (event, ctx) => {
|
|
333
349
|
await deps.onToolExecutionEnd(event, ctx);
|
|
334
350
|
});
|
package/lib/locks.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
|
|
|
16
16
|
import { dirname, join, resolve } from "node:path";
|
|
17
17
|
|
|
18
18
|
export const TELEGRAM_LOCK_KEY = "@llblab/pi-telegram";
|
|
19
|
+
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 5_000;
|
|
19
20
|
|
|
20
21
|
function getAgentDir(): string {
|
|
21
22
|
return process.env.PI_CODING_AGENT_DIR
|
|
@@ -30,6 +31,11 @@ function getLocksPath(): string {
|
|
|
30
31
|
export interface TelegramLockEntry {
|
|
31
32
|
pid: number;
|
|
32
33
|
cwd?: string;
|
|
34
|
+
instanceId?: string;
|
|
35
|
+
heartbeatMs?: number;
|
|
36
|
+
leaderEpoch?: number;
|
|
37
|
+
busSocketPath?: string;
|
|
38
|
+
busSecret?: string;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export interface TelegramLockContext {
|
|
@@ -59,13 +65,18 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
|
|
|
59
65
|
getState: () => TelegramLockState;
|
|
60
66
|
getStatusLabel: () => string;
|
|
61
67
|
owns: (ctx?: TelegramLockContext) => boolean;
|
|
68
|
+
refresh: (ctx?: TelegramLockContext) => boolean;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
export interface TelegramLockOwnershipGuard<
|
|
71
|
+
export interface TelegramLockOwnershipGuard<
|
|
72
|
+
TContext extends TelegramLockContext,
|
|
73
|
+
> {
|
|
65
74
|
ownsContext: (ctx: TContext) => boolean;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
export interface TelegramLockContextStore<
|
|
77
|
+
export interface TelegramLockContextStore<
|
|
78
|
+
TContext extends TelegramLockContext,
|
|
79
|
+
> {
|
|
69
80
|
get: () => TContext | undefined;
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -74,44 +85,11 @@ export interface TelegramLockRuntimeOptions {
|
|
|
74
85
|
locksPath?: string;
|
|
75
86
|
pid?: number;
|
|
76
87
|
isProcessAlive?: (pid: number) => boolean;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export type TelegramLockedPollingStartResult =
|
|
84
|
-
| { ok: true; message: string; canTakeover?: false }
|
|
85
|
-
| { ok: false; message: string; canTakeover?: boolean; owner?: string };
|
|
86
|
-
|
|
87
|
-
export interface TelegramLockedPollingRuntime<
|
|
88
|
-
TContext extends TelegramLockContext,
|
|
89
|
-
> {
|
|
90
|
-
start: (
|
|
91
|
-
ctx: TContext,
|
|
92
|
-
options?: TelegramLockedPollingStartOptions,
|
|
93
|
-
) => Promise<TelegramLockedPollingStartResult>;
|
|
94
|
-
stop: () => Promise<string>;
|
|
95
|
-
suspend: () => Promise<void>;
|
|
96
|
-
onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface TelegramLockedPollingRuntimeDeps<
|
|
100
|
-
TContext extends TelegramLockContext,
|
|
101
|
-
> {
|
|
102
|
-
lock: TelegramLockRuntime<TContext>;
|
|
103
|
-
hasBotToken: () => boolean;
|
|
104
|
-
canStartPolling?: (ctx: TContext) => boolean;
|
|
105
|
-
formatStartBlockedMessage?: (ctx: TContext) => string;
|
|
106
|
-
startPolling: (ctx: TContext) => void | Promise<void>;
|
|
107
|
-
stopPolling: () => Promise<void>;
|
|
108
|
-
updateStatus: (ctx: TContext) => void;
|
|
109
|
-
recordRuntimeEvent?: (
|
|
110
|
-
category: string,
|
|
111
|
-
error: unknown,
|
|
112
|
-
details?: Record<string, unknown>,
|
|
113
|
-
) => void;
|
|
114
|
-
ownershipCheckMs?: number;
|
|
88
|
+
instanceId?: string;
|
|
89
|
+
busSocketPath?: string;
|
|
90
|
+
busSecret?: string;
|
|
91
|
+
getNowMs?: () => number;
|
|
92
|
+
staleHeartbeatMs?: number;
|
|
115
93
|
}
|
|
116
94
|
|
|
117
95
|
export function readLocks(path = getLocksPath()): Record<string, unknown> {
|
|
@@ -155,6 +133,18 @@ export function parseTelegramLockEntry(
|
|
|
155
133
|
return {
|
|
156
134
|
pid: record.pid,
|
|
157
135
|
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
136
|
+
instanceId:
|
|
137
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
138
|
+
heartbeatMs:
|
|
139
|
+
typeof record.heartbeatMs === "number" ? record.heartbeatMs : undefined,
|
|
140
|
+
leaderEpoch:
|
|
141
|
+
typeof record.leaderEpoch === "number" ? record.leaderEpoch : undefined,
|
|
142
|
+
busSocketPath:
|
|
143
|
+
typeof record.busSocketPath === "string"
|
|
144
|
+
? record.busSocketPath
|
|
145
|
+
: undefined,
|
|
146
|
+
busSecret:
|
|
147
|
+
typeof record.busSecret === "string" ? record.busSecret : undefined,
|
|
158
148
|
};
|
|
159
149
|
}
|
|
160
150
|
|
|
@@ -168,7 +158,7 @@ export function isProcessAlive(pid: number): boolean {
|
|
|
168
158
|
}
|
|
169
159
|
}
|
|
170
160
|
|
|
171
|
-
function
|
|
161
|
+
export function formatTelegramLockEntry(lock: TelegramLockEntry): string {
|
|
172
162
|
return lock.cwd ? `pid ${lock.pid}, cwd ${lock.cwd}` : `pid ${lock.pid}`;
|
|
173
163
|
}
|
|
174
164
|
|
|
@@ -176,9 +166,18 @@ function getLockState(
|
|
|
176
166
|
lock: TelegramLockEntry | undefined,
|
|
177
167
|
pid: number,
|
|
178
168
|
isAlive: (pid: number) => boolean,
|
|
169
|
+
options: { nowMs?: number; staleHeartbeatMs?: number } = {},
|
|
179
170
|
): TelegramLockState {
|
|
180
171
|
if (!lock) return { kind: "inactive" };
|
|
181
172
|
if (lock.pid === pid) return { kind: "active-here", lock };
|
|
173
|
+
if (
|
|
174
|
+
typeof lock.heartbeatMs === "number" &&
|
|
175
|
+
typeof options.nowMs === "number" &&
|
|
176
|
+
typeof options.staleHeartbeatMs === "number" &&
|
|
177
|
+
options.nowMs - lock.heartbeatMs > options.staleHeartbeatMs
|
|
178
|
+
) {
|
|
179
|
+
return { kind: "stale", lock };
|
|
180
|
+
}
|
|
182
181
|
if (isAlive(lock.pid)) return { kind: "active-elsewhere", lock };
|
|
183
182
|
return { kind: "stale", lock };
|
|
184
183
|
}
|
|
@@ -192,8 +191,26 @@ function ownsLockContext(
|
|
|
192
191
|
return !lock.cwd || !ctx || lock.cwd === ctx.cwd;
|
|
193
192
|
}
|
|
194
193
|
|
|
195
|
-
function
|
|
196
|
-
|
|
194
|
+
function createLockEntry(
|
|
195
|
+
pid: number,
|
|
196
|
+
ctx: TelegramLockContext,
|
|
197
|
+
options: {
|
|
198
|
+
instanceId?: string;
|
|
199
|
+
busSocketPath?: string;
|
|
200
|
+
busSecret?: string;
|
|
201
|
+
getNowMs?: () => number;
|
|
202
|
+
},
|
|
203
|
+
): TelegramLockEntry {
|
|
204
|
+
const lock: TelegramLockEntry = { pid, cwd: ctx.cwd };
|
|
205
|
+
if (options.instanceId) {
|
|
206
|
+
const nowMs = options.getNowMs?.();
|
|
207
|
+
lock.instanceId = options.instanceId;
|
|
208
|
+
lock.heartbeatMs = nowMs;
|
|
209
|
+
lock.leaderEpoch = nowMs;
|
|
210
|
+
}
|
|
211
|
+
if (options.busSocketPath) lock.busSocketPath = options.busSocketPath;
|
|
212
|
+
if (options.busSecret) lock.busSecret = options.busSecret;
|
|
213
|
+
return lock;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
function formatLockState(state: TelegramLockState): string {
|
|
@@ -203,9 +220,9 @@ function formatLockState(state: TelegramLockState): string {
|
|
|
203
220
|
case "active-here":
|
|
204
221
|
return "active here";
|
|
205
222
|
case "active-elsewhere":
|
|
206
|
-
return `active elsewhere (${
|
|
223
|
+
return `active elsewhere (${formatTelegramLockEntry(state.lock)})`;
|
|
207
224
|
case "stale":
|
|
208
|
-
return `stale (${
|
|
225
|
+
return `stale (${formatTelegramLockEntry(state.lock)})`;
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
228
|
|
|
@@ -216,6 +233,11 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
216
233
|
const locksPath = options.locksPath ?? getLocksPath();
|
|
217
234
|
const pid = options.pid ?? process.pid;
|
|
218
235
|
const isAlive = options.isProcessAlive ?? isProcessAlive;
|
|
236
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
237
|
+
const stateOptions = () => ({
|
|
238
|
+
nowMs: getNowMs(),
|
|
239
|
+
staleHeartbeatMs: options.staleHeartbeatMs,
|
|
240
|
+
});
|
|
219
241
|
const readLock = () => parseTelegramLockEntry(readLocks(locksPath)[key]);
|
|
220
242
|
const writeLock = (lock: TelegramLockEntry) => {
|
|
221
243
|
const locks = readLocks(locksPath);
|
|
@@ -224,15 +246,20 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
224
246
|
};
|
|
225
247
|
return {
|
|
226
248
|
acquire: (ctx, acquireOptions = {}) => {
|
|
227
|
-
const state = getLockState(readLock(), pid, isAlive);
|
|
249
|
+
const state = getLockState(readLock(), pid, isAlive, stateOptions());
|
|
228
250
|
if (state.kind === "active-elsewhere" && !acquireOptions.force)
|
|
229
251
|
return { ok: false, lock: state.lock };
|
|
230
|
-
const lock =
|
|
252
|
+
const lock = createLockEntry(pid, ctx, {
|
|
253
|
+
instanceId: options.instanceId,
|
|
254
|
+
busSocketPath: options.busSocketPath,
|
|
255
|
+
busSecret: options.busSecret,
|
|
256
|
+
getNowMs,
|
|
257
|
+
});
|
|
231
258
|
writeLock(lock);
|
|
232
259
|
return { ok: true, lock, replacedStale: state.kind === "stale" };
|
|
233
260
|
},
|
|
234
261
|
release: () => {
|
|
235
|
-
const state = getLockState(readLock(), pid, isAlive);
|
|
262
|
+
const state = getLockState(readLock(), pid, isAlive, stateOptions());
|
|
236
263
|
if (state.kind === "active-here" || state.kind === "stale") {
|
|
237
264
|
const locks = readLocks(locksPath);
|
|
238
265
|
delete locks[key];
|
|
@@ -240,18 +267,33 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
240
267
|
}
|
|
241
268
|
return state;
|
|
242
269
|
},
|
|
243
|
-
getState: () => getLockState(readLock(), pid, isAlive),
|
|
270
|
+
getState: () => getLockState(readLock(), pid, isAlive, stateOptions()),
|
|
244
271
|
getStatusLabel: () =>
|
|
245
|
-
formatLockState(getLockState(readLock(), pid, isAlive)),
|
|
272
|
+
formatLockState(getLockState(readLock(), pid, isAlive, stateOptions())),
|
|
246
273
|
owns: (ctx) => ownsLockContext(readLock(), pid, ctx),
|
|
274
|
+
refresh: (ctx) => {
|
|
275
|
+
const lock = readLock();
|
|
276
|
+
if (!lock || !ownsLockContext(lock, pid, ctx)) return false;
|
|
277
|
+
if (!options.instanceId) return true;
|
|
278
|
+
writeLock({
|
|
279
|
+
pid: lock.pid,
|
|
280
|
+
...(lock.cwd ? { cwd: lock.cwd } : {}),
|
|
281
|
+
instanceId: options.instanceId,
|
|
282
|
+
heartbeatMs: getNowMs(),
|
|
283
|
+
leaderEpoch: lock.leaderEpoch,
|
|
284
|
+
...(options.busSocketPath
|
|
285
|
+
? { busSocketPath: options.busSocketPath }
|
|
286
|
+
: {}),
|
|
287
|
+
busSecret: options.busSecret ?? lock.busSecret,
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
247
291
|
};
|
|
248
292
|
}
|
|
249
293
|
|
|
250
294
|
export function createTelegramLockOwnershipGuard<
|
|
251
295
|
TContext extends TelegramLockContext,
|
|
252
|
-
>(
|
|
253
|
-
lock: TelegramLockRuntime<TContext>,
|
|
254
|
-
): TelegramLockOwnershipGuard<TContext> {
|
|
296
|
+
>(lock: TelegramLockRuntime<TContext>): TelegramLockOwnershipGuard<TContext> {
|
|
255
297
|
return {
|
|
256
298
|
ownsContext: (ctx) => lock.owns(ctx),
|
|
257
299
|
};
|
|
@@ -269,6 +311,62 @@ export function createTelegramDirectDeliveryOwnershipChecker<
|
|
|
269
311
|
};
|
|
270
312
|
}
|
|
271
313
|
|
|
314
|
+
export interface TelegramLockedPollingStartOptions {
|
|
315
|
+
force?: boolean;
|
|
316
|
+
forceFreshLeaderThread?: boolean;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export type TelegramLockedPollingStartResult =
|
|
320
|
+
| { ok: true; message?: string; canTakeover?: false }
|
|
321
|
+
| { ok: false; message: string; canTakeover?: boolean; owner?: string };
|
|
322
|
+
|
|
323
|
+
export interface TelegramLockedPollingRuntime<
|
|
324
|
+
TContext extends TelegramLockContext,
|
|
325
|
+
> {
|
|
326
|
+
start: (
|
|
327
|
+
ctx: TContext,
|
|
328
|
+
options?: TelegramLockedPollingStartOptions,
|
|
329
|
+
) => Promise<TelegramLockedPollingStartResult>;
|
|
330
|
+
stop: () => Promise<string>;
|
|
331
|
+
suspend: () => Promise<void>;
|
|
332
|
+
onSessionStart: (_event: unknown, ctx: TContext) => Promise<void>;
|
|
333
|
+
registerFollowerWithOwner?: (
|
|
334
|
+
ctx: TContext,
|
|
335
|
+
owner: TelegramLockEntry,
|
|
336
|
+
) => boolean | undefined | Promise<boolean | undefined>;
|
|
337
|
+
stopFollowerRegistration?: () => void;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface TelegramLockedPollingRuntimeDeps<
|
|
341
|
+
TContext extends TelegramLockContext,
|
|
342
|
+
> {
|
|
343
|
+
lock: TelegramLockRuntime<TContext>;
|
|
344
|
+
hasBotToken: () => boolean;
|
|
345
|
+
canStartPolling?: (ctx: TContext) => boolean;
|
|
346
|
+
formatStartBlockedMessage?: (ctx: TContext) => string;
|
|
347
|
+
startPolling: (
|
|
348
|
+
ctx: TContext,
|
|
349
|
+
options?: TelegramLockedPollingStartOptions,
|
|
350
|
+
) => void | Promise<void>;
|
|
351
|
+
stopPolling: () => Promise<void>;
|
|
352
|
+
registerFollowerWithOwner?: (
|
|
353
|
+
ctx: TContext,
|
|
354
|
+
owner: TelegramLockEntry,
|
|
355
|
+
) => boolean | undefined | Promise<boolean | undefined>;
|
|
356
|
+
stopFollowerRegistration?: () => void;
|
|
357
|
+
updateStatus: (ctx: TContext) => void;
|
|
358
|
+
recordRuntimeEvent?: (
|
|
359
|
+
category: string,
|
|
360
|
+
error: unknown,
|
|
361
|
+
details?: Record<string, unknown>,
|
|
362
|
+
) => void;
|
|
363
|
+
ownershipCheckMs?: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function snapshotLockContext(ctx: TelegramLockContext): TelegramLockContext {
|
|
367
|
+
return { cwd: ctx.cwd };
|
|
368
|
+
}
|
|
369
|
+
|
|
272
370
|
export function createTelegramLockedPollingRuntime<
|
|
273
371
|
TContext extends TelegramLockContext,
|
|
274
372
|
>(
|
|
@@ -276,6 +374,8 @@ export function createTelegramLockedPollingRuntime<
|
|
|
276
374
|
): TelegramLockedPollingRuntime<TContext> {
|
|
277
375
|
let ownershipInterval: ReturnType<typeof setInterval> | undefined;
|
|
278
376
|
let ownershipStop: Promise<void> | undefined;
|
|
377
|
+
let sessionAutoStartRun: Promise<void> | undefined;
|
|
378
|
+
let sessionAutoStartGeneration = 0;
|
|
279
379
|
const ownershipCheckMs = deps.ownershipCheckMs ?? 1000;
|
|
280
380
|
const stopOwnershipWatcher = () => {
|
|
281
381
|
if (!ownershipInterval) return;
|
|
@@ -283,7 +383,12 @@ export function createTelegramLockedPollingRuntime<
|
|
|
283
383
|
ownershipInterval = undefined;
|
|
284
384
|
};
|
|
285
385
|
const suspendPolling = async () => {
|
|
386
|
+
sessionAutoStartGeneration += 1;
|
|
387
|
+
deps.stopFollowerRegistration?.();
|
|
286
388
|
stopOwnershipWatcher();
|
|
389
|
+
if (sessionAutoStartRun) {
|
|
390
|
+
await sessionAutoStartRun;
|
|
391
|
+
}
|
|
287
392
|
if (ownershipStop) {
|
|
288
393
|
await ownershipStop;
|
|
289
394
|
return;
|
|
@@ -306,7 +411,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
306
411
|
const owner = snapshotLockContext(ctx);
|
|
307
412
|
stopOwnershipWatcher();
|
|
308
413
|
ownershipInterval = setInterval(() => {
|
|
309
|
-
if (deps.lock.
|
|
414
|
+
if (deps.lock.refresh(owner)) return;
|
|
310
415
|
stopAfterOwnershipLoss();
|
|
311
416
|
}, ownershipCheckMs);
|
|
312
417
|
ownershipInterval.unref?.();
|
|
@@ -315,24 +420,55 @@ export function createTelegramLockedPollingRuntime<
|
|
|
315
420
|
deps.canStartPolling?.(ctx) ?? true;
|
|
316
421
|
const formatStartBlockedMessage = (ctx: TContext): string =>
|
|
317
422
|
deps.formatStartBlockedMessage?.(ctx) ??
|
|
318
|
-
"Telegram polling is unavailable in this
|
|
423
|
+
"Telegram polling is unavailable in this Pi run mode.";
|
|
319
424
|
return {
|
|
320
425
|
start: async (ctx, options = {}) => {
|
|
321
|
-
if (!deps.hasBotToken())
|
|
426
|
+
if (!deps.hasBotToken()) {
|
|
322
427
|
return { ok: false, message: "Telegram bot is not configured." };
|
|
428
|
+
}
|
|
323
429
|
if (!canStartPolling(ctx)) {
|
|
324
430
|
return { ok: false, message: formatStartBlockedMessage(ctx) };
|
|
325
431
|
}
|
|
326
432
|
const acquired = deps.lock.acquire(ctx, options);
|
|
327
433
|
if (!acquired.ok) {
|
|
434
|
+
if (deps.registerFollowerWithOwner) {
|
|
435
|
+
let failureMessage: string | undefined;
|
|
436
|
+
try {
|
|
437
|
+
const registered = await deps.registerFollowerWithOwner(
|
|
438
|
+
ctx,
|
|
439
|
+
acquired.lock,
|
|
440
|
+
);
|
|
441
|
+
if (registered) {
|
|
442
|
+
deps.updateStatus(ctx);
|
|
443
|
+
return { ok: true, canTakeover: false };
|
|
444
|
+
}
|
|
445
|
+
if (registered === false) failureMessage = "not registered";
|
|
446
|
+
} catch (error) {
|
|
447
|
+
failureMessage =
|
|
448
|
+
error instanceof Error ? error.message : String(error);
|
|
449
|
+
deps.recordRuntimeEvent?.("bus", error, {
|
|
450
|
+
phase: "follower-register",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (failureMessage) {
|
|
454
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
canTakeover: false,
|
|
458
|
+
owner,
|
|
459
|
+
message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${failureMessage}.`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
328
464
|
return {
|
|
329
465
|
ok: false,
|
|
330
466
|
canTakeover: true,
|
|
331
|
-
owner
|
|
332
|
-
message: `Telegram bridge is active in another
|
|
467
|
+
owner,
|
|
468
|
+
message: `Telegram bridge is active in another Pi instance (${owner}).`,
|
|
333
469
|
};
|
|
334
470
|
}
|
|
335
|
-
await deps.startPolling(ctx);
|
|
471
|
+
await deps.startPolling(ctx, options);
|
|
336
472
|
startOwnershipWatcher(ctx);
|
|
337
473
|
deps.updateStatus(ctx);
|
|
338
474
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
@@ -342,10 +478,11 @@ export function createTelegramLockedPollingRuntime<
|
|
|
342
478
|
await suspendPolling();
|
|
343
479
|
const state = deps.lock.release();
|
|
344
480
|
if (state.kind === "active-elsewhere") {
|
|
345
|
-
return `Telegram bridge is active in another
|
|
481
|
+
return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
|
|
482
|
+
}
|
|
483
|
+
if (state.kind === "stale") {
|
|
484
|
+
return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
|
|
346
485
|
}
|
|
347
|
-
if (state.kind === "stale")
|
|
348
|
-
return `Removed stale Telegram bridge lock (${formatLock(state.lock)}).`;
|
|
349
486
|
return "Telegram bridge disconnected.";
|
|
350
487
|
},
|
|
351
488
|
suspend: suspendPolling,
|
|
@@ -357,17 +494,44 @@ export function createTelegramLockedPollingRuntime<
|
|
|
357
494
|
const canResumeStaleSameCwd =
|
|
358
495
|
state?.kind === "stale" && state.lock.cwd === ctx.cwd;
|
|
359
496
|
if (!ownsCurrentLock && !canResumeStaleSameCwd) return;
|
|
360
|
-
|
|
497
|
+
sessionAutoStartGeneration += 1;
|
|
498
|
+
const generation = sessionAutoStartGeneration;
|
|
499
|
+
const startedAtMs = Date.now();
|
|
500
|
+
deps.recordRuntimeEvent?.("lock", "Telegram auto-start scheduled", {
|
|
501
|
+
phase: "auto-start-scheduled",
|
|
502
|
+
});
|
|
503
|
+
const run = (async () => {
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
505
|
+
if (generation !== sessionAutoStartGeneration) return;
|
|
361
506
|
if (canResumeStaleSameCwd) {
|
|
362
507
|
const acquired = deps.lock.acquire(ctx);
|
|
363
508
|
if (!acquired.ok) return;
|
|
364
509
|
}
|
|
510
|
+
if (generation !== sessionAutoStartGeneration) return;
|
|
365
511
|
await deps.startPolling(ctx);
|
|
512
|
+
if (generation !== sessionAutoStartGeneration) return;
|
|
366
513
|
startOwnershipWatcher(ctx);
|
|
367
514
|
deps.updateStatus(ctx);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
515
|
+
deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
|
|
516
|
+
phase: "auto-start-complete",
|
|
517
|
+
durationMs: Date.now() - startedAtMs,
|
|
518
|
+
});
|
|
519
|
+
})()
|
|
520
|
+
.catch((error) => {
|
|
521
|
+
deps.recordRuntimeEvent?.("lock", error, { phase: "auto-start" });
|
|
522
|
+
})
|
|
523
|
+
.finally(() => {
|
|
524
|
+
if (sessionAutoStartRun === run) sessionAutoStartRun = undefined;
|
|
525
|
+
});
|
|
526
|
+
sessionAutoStartRun = run;
|
|
371
527
|
},
|
|
528
|
+
registerFollowerWithOwner: deps.registerFollowerWithOwner
|
|
529
|
+
? async (ctx, owner) => {
|
|
530
|
+
const registered = await deps.registerFollowerWithOwner?.(ctx, owner);
|
|
531
|
+
if (registered) deps.updateStatus(ctx);
|
|
532
|
+
return registered === true;
|
|
533
|
+
}
|
|
534
|
+
: undefined,
|
|
535
|
+
stopFollowerRegistration: deps.stopFollowerRegistration,
|
|
372
536
|
};
|
|
373
537
|
}
|
package/lib/media.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface TelegramMediaMessage {
|
|
|
63
63
|
export interface TelegramMediaGroupMessage {
|
|
64
64
|
message_id: number;
|
|
65
65
|
chat: { id: number };
|
|
66
|
+
message_thread_id?: number;
|
|
66
67
|
media_group_id?: string;
|
|
67
68
|
}
|
|
68
69
|
|
|
@@ -232,7 +233,9 @@ function extractTelegramRichBlockText(block: unknown): string {
|
|
|
232
233
|
.map((row) =>
|
|
233
234
|
Array.isArray(row)
|
|
234
235
|
? row
|
|
235
|
-
.map((cell) =>
|
|
236
|
+
.map((cell) =>
|
|
237
|
+
extractTelegramRichText(getObjectField(cell, "text")),
|
|
238
|
+
)
|
|
236
239
|
.filter(Boolean)
|
|
237
240
|
.join(" | ")
|
|
238
241
|
: "",
|
|
@@ -328,6 +331,27 @@ export function extractFirstTelegramMessageText(
|
|
|
328
331
|
return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
|
|
329
332
|
}
|
|
330
333
|
|
|
334
|
+
export function hasTelegramMessagePromptContent(
|
|
335
|
+
message: TelegramMediaMessage,
|
|
336
|
+
): boolean {
|
|
337
|
+
return (
|
|
338
|
+
!!extractTelegramMessageText(message) ||
|
|
339
|
+
(Array.isArray(message.photo) && message.photo.length > 0) ||
|
|
340
|
+
!!message.document ||
|
|
341
|
+
!!message.video ||
|
|
342
|
+
!!message.audio ||
|
|
343
|
+
!!message.voice ||
|
|
344
|
+
!!message.animation ||
|
|
345
|
+
!!message.sticker
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function hasTelegramMessagesPromptContent(
|
|
350
|
+
messages: TelegramMediaMessage[],
|
|
351
|
+
): boolean {
|
|
352
|
+
return messages.some(hasTelegramMessagePromptContent);
|
|
353
|
+
}
|
|
354
|
+
|
|
331
355
|
export function collectTelegramMessageIds(
|
|
332
356
|
messages: TelegramMediaMessage[],
|
|
333
357
|
): number[] {
|
|
@@ -338,7 +362,11 @@ export function getTelegramMediaGroupKey(
|
|
|
338
362
|
message: TelegramMediaGroupMessage,
|
|
339
363
|
): string | undefined {
|
|
340
364
|
if (!message.media_group_id) return undefined;
|
|
341
|
-
|
|
365
|
+
const threadKey =
|
|
366
|
+
typeof message.message_thread_id === "number"
|
|
367
|
+
? `thread:${message.message_thread_id}`
|
|
368
|
+
: "private";
|
|
369
|
+
return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
|
|
342
370
|
}
|
|
343
371
|
|
|
344
372
|
export function removePendingTelegramMediaGroupMessages<
|