@llblab/pi-telegram 0.16.2 → 0.16.4
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/CHANGELOG.md +11 -2
- package/README.md +2 -2
- package/docs/architecture.md +16 -3
- package/docs/locks.md +1 -1
- package/index.ts +2 -1
- package/lib/bindings.ts +2 -6
- package/lib/locks.ts +11 -0
- package/lib/media.ts +1 -0
- package/lib/pi.ts +39 -1
- package/lib/polling.ts +30 -9
- package/lib/preview.ts +1 -0
- package/lib/queue.ts +34 -14
- package/lib/routing.ts +8 -2
- package/lib/runtime.ts +3 -1
- package/lib/status.ts +10 -1
- package/lib/text-groups.ts +1 -0
- package/package.json +1 -1
- package/screenshot.png +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.16.4: Follow-Up And Runtime Mode Hotfix
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- `[Runtime]` Feature-detect Pi `ctx.mode` and keep `print`/`json` runs passive by blocking polling start/resume in those modes. Impact: CLI/headless sessions can finish local work without inheriting Telegram polling, while `tui`/`rpc` and older Pi runtimes keep existing behavior.
|
|
6
|
+
- `[Queue]` Forward queued Telegram prompts and unknown callback fallbacks to Pi with explicit `followUp` delivery semantics. Impact: Telegram input keeps the existing non-steering queue contract even when Pi's native streaming-message API requires an explicit busy-run policy.
|
|
7
|
+
|
|
8
|
+
## 0.16.3: Ownership And Shutdown Hotfix
|
|
9
|
+
|
|
10
|
+
- `[Ownership]` Lock-gated proactive local/headless final-result push so only the current `/telegram-connect` owner can send non-Telegram agent-end replies to the paired chat, while accepted Telegram turns and queued work still finalize session-locally after polling ownership moves away. Impact: child/headless/non-owner instances no longer leak unrelated local results into Telegram.
|
|
11
|
+
- `[Shutdown]` Made polling retry sleep abort-aware, `unref()`ed non-critical Telegram housekeeping timers, and routed registered session shutdown through the composed lifecycle runtime so session context cleanup runs with queue and polling cleanup. Impact: shutdown and print/headless runs are less likely to stay alive on retry/debounce/typing/preview timers, and direct-delivery ownership context is cleared at the session boundary.
|
|
12
|
+
- `[Tests]` Added regressions for inherited child lock refusal, child-process no-poll ownership, child-process direct-tool non-owner refusal, proactive owner/non-owner/stale/off states, queued dispatch after lock movement, abort-during-retry polling sleep, process-level session shutdown, and real `pi -p` exit/no-leak paths with a local custom provider. Impact: the cross-instance ownership, queue, and shutdown contracts are pinned from unit coverage through CLI smoke coverage.
|
|
13
|
+
- `[Status]` Changed `/telegram-status` bot identity fallback from `not configured` to `unknown` when a bot token exists but the bot username is absent. Impact: live sessions that are paired and polling no longer look unconfigured only because identity metadata is missing.
|
|
14
|
+
- `[Docs]` Documented ownership, proactive-push, timer/shutdown, and multi-extension prompt-boundary contracts in README and `/docs`. Impact: companion extensions get clearer boundaries without adding a core question/prompt-mirroring API.
|
|
6
15
|
|
|
7
16
|
## 0.16.2: Screenshot Refresh Hotfix
|
|
8
17
|
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Paste your bot token when prompted. If a bot token is already saved in `~/.pi/ag
|
|
|
47
47
|
/telegram-connect
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation,
|
|
50
|
+
The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, inherited child sessions do not start polling unless they take ownership, and `/new` plus same-`cwd` restarts resume automatically. Local queue and reply state stay per Pi instance, so an instance that loses Telegram control still finishes work it already accepted.
|
|
51
51
|
|
|
52
52
|
### 4. Pair your Telegram account
|
|
53
53
|
|
|
@@ -215,7 +215,7 @@ Import `registerTelegramSection()` from `@llblab/pi-telegram/sections` and retur
|
|
|
215
215
|
|
|
216
216
|
### Proactive push
|
|
217
217
|
|
|
218
|
-
`telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active. Local prompt text is not mirrored because the bot does not own terminal user messages. The mode is off by default and can be toggled from settings.
|
|
218
|
+
`telegram.json` can set `proactivePush: true` to send successful local non-Telegram final replies to the paired Telegram chat when no Telegram turn is active and this π instance currently owns `/telegram-connect`. Non-owners skip proactive delivery instead of pushing unrelated local/headless results through a bot they no longer control. Local prompt text is not mirrored because the bot does not own terminal user messages. The mode is off by default and can be toggled from settings.
|
|
219
219
|
|
|
220
220
|
### Time context
|
|
221
221
|
|
package/docs/architecture.md
CHANGED
|
@@ -88,10 +88,13 @@ Telegram configuration lives in `~/.pi/agent/telegram.json`. Polling ownership l
|
|
|
88
88
|
- `/telegram-connect` acquires or moves singleton polling ownership before polling starts.
|
|
89
89
|
- `/telegram-disconnect` stops polling and releases ownership.
|
|
90
90
|
- Session start resumes polling only when the existing lock already points at the current `pid`/`cwd`, or when a stale same-`cwd` lock can be safely replaced after process restart.
|
|
91
|
+
- Pi `print`/`json` run modes stay passive: they do not start or resume Telegram polling even if a lock is present. Older Pi runtimes without `ctx.mode` keep the previous compatibility behavior.
|
|
92
|
+
- Inherited child sessions that see the same `telegram.json` but do not own the `pid`/`cwd` lock must not auto-start polling or call `getUpdates` unless the operator force-takes ownership.
|
|
91
93
|
- Session replacement suspends polling/watchers without releasing ownership so the next session-start hook in the same process can resume.
|
|
92
94
|
- Live polling owners require explicit takeover confirmation.
|
|
93
95
|
- Long-lived polling timers use snapshotted ownership context and stop local polling when the lock no longer points at their own process.
|
|
94
96
|
- `locks.json` owns only external Telegram control/polling. Local extension and queue state are per Pi instance: losing the lock stops live Telegram control here, but does not drain or silence this instance's accepted queue, previews, final delivery, or dispatch.
|
|
97
|
+
- Proactive local/headless final-result push is not accepted-turn delivery. It is allowed only when proactive push is enabled and this instance currently owns the Telegram lock.
|
|
95
98
|
|
|
96
99
|
Deleting `locks.json` resets runtime ownership without deleting Telegram configuration.
|
|
97
100
|
|
|
@@ -138,7 +141,7 @@ Dispatch requires:
|
|
|
138
141
|
|
|
139
142
|
A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies.
|
|
140
143
|
|
|
141
|
-
Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere.
|
|
144
|
+
Post-agent-end queue dispatch uses a session-bound deferred dispatcher. It is activated on session start, clears timers on shutdown, and skips callbacks from older generations before touching `ExtensionContext`. Dispatch stays session-bound after polling ownership moves elsewhere. When a queued Telegram prompt is forwarded into Pi, it uses Pi's explicit `followUp` delivery option so Telegram input preserves the existing non-steering queue contract even if Pi is still settling active work.
|
|
142
145
|
|
|
143
146
|
### Controls And Menus
|
|
144
147
|
|
|
@@ -159,7 +162,7 @@ Queued controls:
|
|
|
159
162
|
- Prompt-template commands expand Telegram-safe π template aliases before entering the prompt queue.
|
|
160
163
|
- Model-switch continuation uses the control lane when an in-flight Telegram-owned run must be stopped and resumed.
|
|
161
164
|
|
|
162
|
-
UI label, navigation, tab, toggle, card, and dialog rules are defined in [UI Style](./ui-style.md). Callback prefix ownership is defined in [Callback Namespaces](./callback-namespaces.md).
|
|
165
|
+
Queue and menu mutations are reachable through Telegram updates handled by the current polling owner. After ownership moves, the old instance keeps processing its accepted local queue, but it no longer receives new menu callbacks or control updates for remote mutation. UI label, navigation, tab, toggle, card, and dialog rules are defined in [UI Style](./ui-style.md). Callback prefix ownership is defined in [Callback Namespaces](./callback-namespaces.md).
|
|
163
166
|
|
|
164
167
|
### Compaction And Typing Status
|
|
165
168
|
|
|
@@ -215,6 +218,8 @@ Unknown callback data outside owned prefixes is forwarded as `[callback] <data>`
|
|
|
215
218
|
|
|
216
219
|
Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
|
|
217
220
|
|
|
221
|
+
The bridge does not mirror arbitrary `ctx.ui.confirm/input/select/custom` prompts from other extensions into Telegram. Companion extensions that need Telegram operation should expose a Telegram-native command, section, settings row, callback, status line, inbound/update handler, or assistant action-markup path instead of relying on hidden TUI-only prompts.
|
|
222
|
+
|
|
218
223
|
## Diagnostics And Operational Behavior
|
|
219
224
|
|
|
220
225
|
Status rendering distinguishes connected, active, dispatching, queued, tool-running, model-switching, and compacting states. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, status remains active instead of degrading to connected.
|
|
@@ -223,7 +228,7 @@ Queue reactions are shortcut controls for waiting turns. Promotion reactions (`
|
|
|
223
228
|
|
|
224
229
|
`/telegram-status` records grouped diagnostics for transport/API, polling/update, prompt dispatch, controls, typing, compaction, setup, session lifecycle, attachment queue/delivery, and recent redacted runtime events. Expected preview noise such as unchanged edit responses is filtered out.
|
|
225
230
|
|
|
226
|
-
When proactive push is enabled, successful local non-Telegram final replies are sent to the paired chat. Local prompt text is not mirrored because the bot does not own terminal user messages.
|
|
231
|
+
When proactive push is enabled and this instance owns the Telegram lock, successful local non-Telegram final replies are sent to the paired chat. Non-owners skip proactive delivery and record a runtime diagnostic. Local prompt text is not mirrored because the bot does not own terminal user messages.
|
|
227
232
|
|
|
228
233
|
Telegram prompt guidance asks assistants to keep dense mobile-visible text around 37 display cells where possible, because emoji and wide Unicode make raw character counts misleading.
|
|
229
234
|
|
|
@@ -238,6 +243,14 @@ When `/model` is used during an active Telegram-owned run, the bridge can emulat
|
|
|
238
243
|
|
|
239
244
|
This is limited to Telegram-owned runs. If π is busy with non-Telegram work, the bridge refuses the switch instead of hijacking unrelated activity.
|
|
240
245
|
|
|
246
|
+
## Shutdown And Timer Lifecycle
|
|
247
|
+
|
|
248
|
+
`session_shutdown` is the hard boundary for session-bound runtime work. It suspends Telegram polling through the locked polling runtime, aborts the poll controller, stops native typing, unbinds deferred queue dispatch, clears pending media/text-group input, clears preview state, clears active turns, and drops the active abort handler.
|
|
249
|
+
|
|
250
|
+
Non-critical timers are `unref()`ed so print/headless processes are not kept alive only by Telegram housekeeping. This includes typing keepalive intervals, bounded typing-idle waits, deferred queue dispatch, media/text-group debounce windows, preview flush timers, and polling retry sleeps. Polling retry sleep is abort-aware, so shutdown does not wait for the normal retry delay after a polling error.
|
|
251
|
+
|
|
252
|
+
Non-interactive `pi -p` runs must remain passive unless π provides a live Telegram session lifecycle. Loading the extension with `telegram.json`, proactive push settings, or existing lock state must not by itself keep the print-mode process alive or let a non-owner send proactive Telegram output.
|
|
253
|
+
|
|
241
254
|
## Related
|
|
242
255
|
|
|
243
256
|
- [README.md](../README.md)
|
package/docs/locks.md
CHANGED
|
@@ -123,7 +123,7 @@ Avoid repeating the extension name in the body. Color is encouraged: extension t
|
|
|
123
123
|
|
|
124
124
|
The previous owner may use `fs.watch`, mtime polling, or an existing status/timer tick. Long-lived watchers should compare against a snapshotted `pid`/`cwd` identity rather than a live pi context object, because session replacement such as `/new` makes captured contexts stale. The important contract is graceful singleton-runtime shutdown after ownership mismatch while session-local state that does not require polling remains owned by its original instance.
|
|
125
125
|
|
|
126
|
-
For `pi-telegram`, direct local/TUI delivery tools (`telegram_message` and no-active-turn `telegram_attach`) are singleton-controlled and require current `/telegram-connect` ownership. They must fail when the lock is inactive or active elsewhere. Already accepted Telegram-turn reply delivery, previews, queued attachments, and queue finalization remain session-local and may complete after polling ownership moves away.
|
|
126
|
+
For `pi-telegram`, direct local/TUI delivery tools (`telegram_message` and no-active-turn `telegram_attach`) and proactive local/headless final-result push are singleton-controlled and require current `/telegram-connect` ownership. They must fail or skip delivery when the lock is inactive or active elsewhere. Already accepted Telegram-turn reply delivery, previews, queued attachments, and queue finalization remain session-local and may complete after polling ownership moves away.
|
|
127
127
|
|
|
128
128
|
## Reset
|
|
129
129
|
|
package/index.ts
CHANGED
|
@@ -386,6 +386,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
386
386
|
const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
|
|
387
387
|
lock: lockRuntime,
|
|
388
388
|
hasBotToken: configStore.hasBotToken,
|
|
389
|
+
canStartPolling: Pi.canStartPollingInExtensionContext,
|
|
390
|
+
formatStartBlockedMessage: Pi.formatPollingStartBlockedByRunMode,
|
|
389
391
|
startPolling: pollingRuntime.start,
|
|
390
392
|
stopPolling: pollingRuntime.stop,
|
|
391
393
|
updateStatus,
|
|
@@ -452,7 +454,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
452
454
|
...sessionLifecycleRuntime,
|
|
453
455
|
onModelSelect: currentModelRuntime.onModelSelect,
|
|
454
456
|
},
|
|
455
|
-
queueSessionLifecycle,
|
|
456
457
|
configStore,
|
|
457
458
|
abort,
|
|
458
459
|
typing,
|
package/lib/bindings.ts
CHANGED
|
@@ -113,10 +113,6 @@ interface TelegramLifecycleBindingDeps {
|
|
|
113
113
|
Lifecycle.TelegramLifecycleRegistrationDeps,
|
|
114
114
|
"onSessionStart" | "onSessionShutdown" | "onModelSelect"
|
|
115
115
|
>;
|
|
116
|
-
queueSessionLifecycle: Pick<
|
|
117
|
-
Lifecycle.TelegramLifecycleRegistrationDeps,
|
|
118
|
-
"onSessionShutdown"
|
|
119
|
-
>;
|
|
120
116
|
configStore: Pick<Config.TelegramConfigStore, "getOutboundHandlers">;
|
|
121
117
|
abort: Runtime.TelegramRuntimeAbortPort;
|
|
122
118
|
typing: Runtime.TelegramRuntimeTypingPort;
|
|
@@ -190,7 +186,6 @@ interface TelegramLifecycleBindingDeps {
|
|
|
190
186
|
export function registerTelegramLifecycleRuntimeHooks({
|
|
191
187
|
pi,
|
|
192
188
|
sessionLifecycleRuntime,
|
|
193
|
-
queueSessionLifecycle,
|
|
194
189
|
configStore,
|
|
195
190
|
abort,
|
|
196
191
|
typing,
|
|
@@ -283,6 +278,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
283
278
|
sendOutboundReplyArtifacts: outboundReplyArtifactSender,
|
|
284
279
|
getDefaultChatId: proactivePushChatIdGetter,
|
|
285
280
|
isProactivePushEnabled,
|
|
281
|
+
canSendProactivePush: lockOwnershipGuard.ownsContext,
|
|
286
282
|
recordRuntimeEvent,
|
|
287
283
|
getActiveToolExecutions: lifecycle.getActiveToolExecutions,
|
|
288
284
|
setActiveToolExecutions: lifecycle.setActiveToolExecutions,
|
|
@@ -315,7 +311,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
315
311
|
...agentLifecycleHooks,
|
|
316
312
|
async onSessionShutdown(event, ctx) {
|
|
317
313
|
compactionObserver.onSessionShutdown();
|
|
318
|
-
await
|
|
314
|
+
await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
|
|
319
315
|
},
|
|
320
316
|
onSessionBeforeCompact: compactionObserver.onSessionBeforeCompact,
|
|
321
317
|
onSessionCompact: compactionObserver.onSessionCompact,
|
package/lib/locks.ts
CHANGED
|
@@ -101,6 +101,8 @@ export interface TelegramLockedPollingRuntimeDeps<
|
|
|
101
101
|
> {
|
|
102
102
|
lock: TelegramLockRuntime<TContext>;
|
|
103
103
|
hasBotToken: () => boolean;
|
|
104
|
+
canStartPolling?: (ctx: TContext) => boolean;
|
|
105
|
+
formatStartBlockedMessage?: (ctx: TContext) => string;
|
|
104
106
|
startPolling: (ctx: TContext) => void | Promise<void>;
|
|
105
107
|
stopPolling: () => Promise<void>;
|
|
106
108
|
updateStatus: (ctx: TContext) => void;
|
|
@@ -309,10 +311,18 @@ export function createTelegramLockedPollingRuntime<
|
|
|
309
311
|
}, ownershipCheckMs);
|
|
310
312
|
ownershipInterval.unref?.();
|
|
311
313
|
};
|
|
314
|
+
const canStartPolling = (ctx: TContext): boolean =>
|
|
315
|
+
deps.canStartPolling?.(ctx) ?? true;
|
|
316
|
+
const formatStartBlockedMessage = (ctx: TContext): string =>
|
|
317
|
+
deps.formatStartBlockedMessage?.(ctx) ??
|
|
318
|
+
"Telegram polling is unavailable in this π run mode.";
|
|
312
319
|
return {
|
|
313
320
|
start: async (ctx, options = {}) => {
|
|
314
321
|
if (!deps.hasBotToken())
|
|
315
322
|
return { ok: false, message: "Telegram bot is not configured." };
|
|
323
|
+
if (!canStartPolling(ctx)) {
|
|
324
|
+
return { ok: false, message: formatStartBlockedMessage(ctx) };
|
|
325
|
+
}
|
|
316
326
|
const acquired = deps.lock.acquire(ctx, options);
|
|
317
327
|
if (!acquired.ok) {
|
|
318
328
|
return {
|
|
@@ -341,6 +351,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
341
351
|
suspend: suspendPolling,
|
|
342
352
|
onSessionStart: async (_event, ctx) => {
|
|
343
353
|
if (!deps.hasBotToken()) return;
|
|
354
|
+
if (!canStartPolling(ctx)) return;
|
|
344
355
|
const ownsCurrentLock = deps.lock.owns(ctx);
|
|
345
356
|
const state = ownsCurrentLock ? undefined : deps.lock.getState();
|
|
346
357
|
const canResumeStaleSameCwd =
|
package/lib/media.ts
CHANGED
|
@@ -299,6 +299,7 @@ export function queueTelegramMediaGroupMessage<
|
|
|
299
299
|
if (!state) return;
|
|
300
300
|
options.dispatchMessages(state.messages, state.context);
|
|
301
301
|
}, options.debounceMs);
|
|
302
|
+
existing.flushTimer.unref?.();
|
|
302
303
|
options.groups.set(key, existing);
|
|
303
304
|
return true;
|
|
304
305
|
}
|
package/lib/pi.ts
CHANGED
|
@@ -41,6 +41,44 @@ export interface PiSettingsManager {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export type PiSlashCommandInfo = SlashCommandInfo;
|
|
44
|
+
export type PiRunMode = "tui" | "rpc" | "json" | "print";
|
|
45
|
+
|
|
46
|
+
function isPiRunMode(value: unknown): value is PiRunMode {
|
|
47
|
+
return (
|
|
48
|
+
value === "tui" ||
|
|
49
|
+
value === "rpc" ||
|
|
50
|
+
value === "json" ||
|
|
51
|
+
value === "print"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getExtensionContextMode(ctx: unknown): PiRunMode | undefined {
|
|
56
|
+
const mode =
|
|
57
|
+
typeof ctx === "object" && ctx !== null
|
|
58
|
+
? (ctx as { mode?: unknown }).mode
|
|
59
|
+
: undefined;
|
|
60
|
+
return isPiRunMode(mode) ? mode : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isExtensionContextPassiveRunMode(ctx: unknown): boolean {
|
|
64
|
+
const mode = getExtensionContextMode(ctx);
|
|
65
|
+
return mode === "print" || mode === "json";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function canStartPollingInExtensionContext(ctx: unknown): boolean {
|
|
69
|
+
return !isExtensionContextPassiveRunMode(ctx);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatPollingStartBlockedByRunMode(ctx: unknown): string {
|
|
73
|
+
const mode = getExtensionContextMode(ctx);
|
|
74
|
+
return mode
|
|
75
|
+
? `Telegram polling is unavailable in π ${mode} mode. Use /telegram-connect from a long-lived π session.`
|
|
76
|
+
: "Telegram polling is unavailable in this π run mode.";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type PiSendUserMessageOptions = NonNullable<
|
|
80
|
+
Parameters<ExtensionAPI["sendUserMessage"]>[1]
|
|
81
|
+
>;
|
|
44
82
|
|
|
45
83
|
export interface PiExtensionApiRuntimePorts {
|
|
46
84
|
sendUserMessage: ExtensionAPI["sendUserMessage"];
|
|
@@ -63,7 +101,7 @@ export function createExtensionApiRuntimePorts(
|
|
|
63
101
|
>,
|
|
64
102
|
): PiExtensionApiRuntimePorts {
|
|
65
103
|
return {
|
|
66
|
-
sendUserMessage: (content) => api.sendUserMessage(content),
|
|
104
|
+
sendUserMessage: (content, options) => api.sendUserMessage(content, options),
|
|
67
105
|
exec: (command, args, options) => api.exec(command, args, options),
|
|
68
106
|
getCommands: () => api.getCommands(),
|
|
69
107
|
getThinkingLevel: () => api.getThinkingLevel(),
|
package/lib/polling.ts
CHANGED
|
@@ -261,7 +261,7 @@ export interface TelegramPollLoopDeps<
|
|
|
261
261
|
handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
262
262
|
onErrorStatus: (message: string) => void;
|
|
263
263
|
onStatusReset: () => void;
|
|
264
|
-
sleep: (ms: number) => Promise<void>;
|
|
264
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
265
265
|
maxUpdateFailures?: number;
|
|
266
266
|
}
|
|
267
267
|
|
|
@@ -278,22 +278,42 @@ export interface TelegramPollLoopRunnerDeps<
|
|
|
278
278
|
persistConfig: () => Promise<void>;
|
|
279
279
|
handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
280
280
|
updateStatus: (ctx: TContext, message?: string) => void;
|
|
281
|
-
sleep?: (ms: number) => Promise<void>;
|
|
281
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
282
282
|
maxUpdateFailures?: number;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
export function sleepTelegramPollingRetry(
|
|
286
|
+
ms: number,
|
|
287
|
+
signal?: AbortSignal,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
if (ms <= 0 || signal?.aborted) return Promise.resolve();
|
|
290
|
+
return new Promise<void>((resolve) => {
|
|
291
|
+
let settled = false;
|
|
292
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
293
|
+
const finish = () => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
signal?.removeEventListener("abort", onAbort);
|
|
297
|
+
resolve();
|
|
298
|
+
};
|
|
299
|
+
const onAbort = () => {
|
|
300
|
+
clearTimeout(timer);
|
|
301
|
+
finish();
|
|
302
|
+
};
|
|
303
|
+
timer = setTimeout(finish, ms);
|
|
304
|
+
timer.unref?.();
|
|
305
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
306
|
+
if (signal?.aborted) onAbort();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
285
310
|
export function createTelegramPollLoopRunner<
|
|
286
311
|
TUpdate extends TelegramUpdate,
|
|
287
312
|
TContext = unknown,
|
|
288
313
|
>(
|
|
289
314
|
deps: TelegramPollLoopRunnerDeps<TUpdate, TContext>,
|
|
290
315
|
): (ctx: TContext, signal: AbortSignal) => Promise<void> {
|
|
291
|
-
const sleep =
|
|
292
|
-
deps.sleep ??
|
|
293
|
-
((ms: number) =>
|
|
294
|
-
new Promise<void>((resolve) => {
|
|
295
|
-
setTimeout(resolve, ms);
|
|
296
|
-
}));
|
|
316
|
+
const sleep = deps.sleep ?? sleepTelegramPollingRetry;
|
|
297
317
|
return (ctx, signal) =>
|
|
298
318
|
runTelegramPollLoop({
|
|
299
319
|
ctx,
|
|
@@ -393,7 +413,8 @@ export async function runTelegramPollLoop<
|
|
|
393
413
|
deps.recordRuntimeEvent?.("polling", error, { phase: "loop" });
|
|
394
414
|
}
|
|
395
415
|
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
396
|
-
await deps.sleep(3000);
|
|
416
|
+
await deps.sleep(3000, deps.signal);
|
|
417
|
+
if (deps.signal.aborted) return;
|
|
397
418
|
deps.onStatusReset();
|
|
398
419
|
}
|
|
399
420
|
}
|
package/lib/preview.ts
CHANGED
|
@@ -467,6 +467,7 @@ export function createTelegramPreviewController<
|
|
|
467
467
|
state.flushTimer = setTimer(() => {
|
|
468
468
|
void flushTelegramPreview(chatId, getRuntimeDeps());
|
|
469
469
|
}, throttleMs);
|
|
470
|
+
state.flushTimer.unref?.();
|
|
470
471
|
},
|
|
471
472
|
finalize: (chatId, replyToMessageId) =>
|
|
472
473
|
finalizeTelegramPreview(chatId, getRuntimeDeps(replyToMessageId)),
|
package/lib/queue.ts
CHANGED
|
@@ -842,6 +842,7 @@ export interface TelegramAgentEndRuntimeDeps<
|
|
|
842
842
|
) => Promise<void>;
|
|
843
843
|
getDefaultChatId?: () => number | undefined;
|
|
844
844
|
isProactivePushEnabled?: () => boolean;
|
|
845
|
+
canSendProactivePush?: () => boolean;
|
|
845
846
|
recordRuntimeEvent?: (
|
|
846
847
|
category: string,
|
|
847
848
|
error: unknown,
|
|
@@ -888,6 +889,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
|
|
|
888
889
|
sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
|
|
889
890
|
getDefaultChatId?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultChatId"];
|
|
890
891
|
isProactivePushEnabled?: TelegramAgentEndRuntimeDeps<TTurn>["isProactivePushEnabled"];
|
|
892
|
+
canSendProactivePush?: (ctx: TContext) => boolean;
|
|
891
893
|
recordRuntimeEvent?: TelegramAgentEndRuntimeDeps<TTurn>["recordRuntimeEvent"];
|
|
892
894
|
}
|
|
893
895
|
|
|
@@ -980,6 +982,7 @@ export function createTelegramAgentEndHook<
|
|
|
980
982
|
): Promise<void> {
|
|
981
983
|
const turn = deps.getActiveTurn();
|
|
982
984
|
const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
|
|
985
|
+
const canProactivePush = deps.canSendProactivePush?.(ctx) ?? false;
|
|
983
986
|
await handleTelegramAgentEndRuntime({
|
|
984
987
|
turn,
|
|
985
988
|
assistant:
|
|
@@ -1005,6 +1008,7 @@ export function createTelegramAgentEndHook<
|
|
|
1005
1008
|
sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
|
|
1006
1009
|
getDefaultChatId: deps.getDefaultChatId,
|
|
1007
1010
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
|
1011
|
+
canSendProactivePush: () => canProactivePush,
|
|
1008
1012
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1009
1013
|
});
|
|
1010
1014
|
};
|
|
@@ -1057,20 +1061,26 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
1057
1061
|
foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
|
|
1058
1062
|
});
|
|
1059
1063
|
if (!turn) {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
}
|
|
1064
|
+
const proactiveEnabled = deps.isProactivePushEnabled?.() ?? false;
|
|
1065
|
+
const canProactivePush = deps.canSendProactivePush?.() ?? false;
|
|
1066
|
+
if (proactiveEnabled && finalText && !assistant.errorMessage) {
|
|
1067
|
+
if (canProactivePush) {
|
|
1068
|
+
const defaultChatId = deps.getDefaultChatId?.();
|
|
1069
|
+
if (defaultChatId !== undefined) {
|
|
1070
|
+
try {
|
|
1071
|
+
await deps.sendMarkdownReply(defaultChatId, undefined, finalText);
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
deps.recordRuntimeEvent?.("proactive-push", error, {
|
|
1074
|
+
chatId: defaultChatId,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1073
1077
|
}
|
|
1078
|
+
} else {
|
|
1079
|
+
deps.recordRuntimeEvent?.(
|
|
1080
|
+
"proactive-push",
|
|
1081
|
+
new Error("Proactive push skipped because this instance does not own Telegram polling."),
|
|
1082
|
+
{ phase: "ownership" },
|
|
1083
|
+
);
|
|
1074
1084
|
}
|
|
1075
1085
|
}
|
|
1076
1086
|
if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
|
|
@@ -1802,6 +1812,7 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
|
|
|
1802
1812
|
return;
|
|
1803
1813
|
dispatchNextQueuedTelegramTurn(boundContext);
|
|
1804
1814
|
}, delayMs);
|
|
1815
|
+
timer.unref?.();
|
|
1805
1816
|
timers.add(timer);
|
|
1806
1817
|
},
|
|
1807
1818
|
};
|
|
@@ -1809,6 +1820,14 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
|
|
|
1809
1820
|
|
|
1810
1821
|
// --- Dispatch Runtime ---
|
|
1811
1822
|
|
|
1823
|
+
export interface TelegramPromptDeliveryOptions {
|
|
1824
|
+
deliverAs: "followUp";
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
export const TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY = {
|
|
1828
|
+
deliverAs: "followUp",
|
|
1829
|
+
} as const satisfies TelegramPromptDeliveryOptions;
|
|
1830
|
+
|
|
1812
1831
|
export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
|
|
1813
1832
|
executeControlItem: (
|
|
1814
1833
|
item: Extract<
|
|
@@ -1822,6 +1841,7 @@ export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
|
|
|
1822
1841
|
TelegramQueueDispatchAction,
|
|
1823
1842
|
{ kind: "prompt" }
|
|
1824
1843
|
>["item"]["content"],
|
|
1844
|
+
options?: TelegramPromptDeliveryOptions,
|
|
1825
1845
|
) => void;
|
|
1826
1846
|
onPromptDispatchFailure: (message: string) => void;
|
|
1827
1847
|
onIdle: () => void;
|
|
@@ -1859,7 +1879,7 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
|
|
|
1859
1879
|
}
|
|
1860
1880
|
deps.onPromptDispatchStart(plan.item.chatId);
|
|
1861
1881
|
try {
|
|
1862
|
-
deps.sendUserMessage(plan.item.content);
|
|
1882
|
+
deps.sendUserMessage(plan.item.content, TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY);
|
|
1863
1883
|
} catch (error) {
|
|
1864
1884
|
const message = getTelegramQueueErrorMessage(error);
|
|
1865
1885
|
deps.onPromptDispatchFailure(message);
|
package/lib/routing.ts
CHANGED
|
@@ -122,7 +122,10 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
122
122
|
ctx: TContext,
|
|
123
123
|
) => Promise<void>;
|
|
124
124
|
setModel: (model: TModel) => Promise<boolean>;
|
|
125
|
-
sendUserMessage?: (
|
|
125
|
+
sendUserMessage?: (
|
|
126
|
+
message: string,
|
|
127
|
+
options?: Queue.TelegramPromptDeliveryOptions,
|
|
128
|
+
) => void;
|
|
126
129
|
isIdle: (ctx: TContext) => boolean;
|
|
127
130
|
hasPendingMessages: (ctx: TContext) => boolean;
|
|
128
131
|
compact: (
|
|
@@ -315,7 +318,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
315
318
|
callbackData &&
|
|
316
319
|
!isTelegramOwnedCallbackData(callbackData)
|
|
317
320
|
) {
|
|
318
|
-
deps.sendUserMessage(
|
|
321
|
+
deps.sendUserMessage(
|
|
322
|
+
`[callback] ${callbackData}`,
|
|
323
|
+
Queue.TELEGRAM_PROMPT_FOLLOW_UP_DELIVERY,
|
|
324
|
+
);
|
|
319
325
|
await deps.answerCallbackQuery(query.id);
|
|
320
326
|
return;
|
|
321
327
|
}
|
package/lib/runtime.ts
CHANGED
|
@@ -406,6 +406,7 @@ export function startTelegramTypingLoop(
|
|
|
406
406
|
};
|
|
407
407
|
sendTyping();
|
|
408
408
|
state.typingInterval = setInterval(sendTyping, deps.intervalMs);
|
|
409
|
+
state.typingInterval.unref?.();
|
|
409
410
|
return true;
|
|
410
411
|
}
|
|
411
412
|
|
|
@@ -431,7 +432,8 @@ export async function waitForTelegramTypingLoopIdle(
|
|
|
431
432
|
await Promise.race([
|
|
432
433
|
inFlight,
|
|
433
434
|
new Promise<void>((resolve) => {
|
|
434
|
-
setTimeout(resolve, timeoutMs);
|
|
435
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
436
|
+
timer.unref?.();
|
|
435
437
|
}),
|
|
436
438
|
]);
|
|
437
439
|
}
|
package/lib/status.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface TelegramRuntimeEventRecorderOptions {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export interface TelegramBridgeStatusLineState {
|
|
107
|
+
hasBotToken?: boolean;
|
|
107
108
|
botUsername?: string;
|
|
108
109
|
allowedUserId?: number;
|
|
109
110
|
lockState?: string;
|
|
@@ -488,6 +489,7 @@ export function createTelegramBridgeStatusRuntime<
|
|
|
488
489
|
getBridgeStatusLineState: () => {
|
|
489
490
|
const config = deps.getConfig();
|
|
490
491
|
return {
|
|
492
|
+
hasBotToken: Boolean(config.botToken),
|
|
491
493
|
botUsername: config.botUsername,
|
|
492
494
|
allowedUserId: config.allowedUserId,
|
|
493
495
|
lockState: deps.getRuntimeLockState?.(),
|
|
@@ -552,6 +554,13 @@ export function buildTelegramStatusBarText(
|
|
|
552
554
|
return `${label} ${theme.fg("success", "connected")}`;
|
|
553
555
|
}
|
|
554
556
|
|
|
557
|
+
function formatTelegramBridgeBotStatus(
|
|
558
|
+
state: Pick<TelegramBridgeStatusLineState, "hasBotToken" | "botUsername">,
|
|
559
|
+
): string {
|
|
560
|
+
if (state.botUsername) return `@${state.botUsername}`;
|
|
561
|
+
return state.hasBotToken ? "unknown" : "not configured";
|
|
562
|
+
}
|
|
563
|
+
|
|
555
564
|
export function buildTelegramBridgeStatusLines(
|
|
556
565
|
state: TelegramBridgeStatusLineState,
|
|
557
566
|
): string[] {
|
|
@@ -566,7 +575,7 @@ export function buildTelegramBridgeStatusLines(
|
|
|
566
575
|
).length;
|
|
567
576
|
return [
|
|
568
577
|
"connection:",
|
|
569
|
-
`- bot: ${state
|
|
578
|
+
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
|
570
579
|
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
|
571
580
|
...(state.lockState ? [`- owner: ${state.lockState}`] : []),
|
|
572
581
|
"",
|
package/lib/text-groups.ts
CHANGED
|
@@ -132,6 +132,7 @@ export function queueTelegramTextGroupMessage<
|
|
|
132
132
|
if (!queued || queued.context === undefined) return;
|
|
133
133
|
options.dispatchMessages(queued.messages, queued.context);
|
|
134
134
|
}, options.debounceMs);
|
|
135
|
+
state.flushTimer.unref?.();
|
|
135
136
|
options.groups.set(key, state);
|
|
136
137
|
return true;
|
|
137
138
|
}
|
package/package.json
CHANGED
package/screenshot.png
CHANGED
|
Binary file
|