@llblab/pi-telegram 0.36.9 → 0.36.11
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 +8 -0
- package/docs/architecture.md +2 -2
- package/index.ts +9 -0
- package/lib/bindings.ts +11 -0
- package/lib/queue.ts +14 -0
- package/lib/runtime.ts +52 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.36.11: At-Most-Once Prompt Dispatch Hotfix
|
|
6
|
+
|
|
7
|
+
- `At-Most-Once Prompt Dispatch`: Commits each exact durable Telegram receipt synchronously before Pi model admission and blocks dispatch when that commit fails, closing the session/process-replacement replay window that could deliver an already-processed old prompt again; the narrow commit-before-admission crash boundary now favors no duplicate over retry.
|
|
8
|
+
|
|
9
|
+
## 0.36.10: Transport-Fenced Typing Hotfix
|
|
10
|
+
|
|
11
|
+
- `Typing Authority Fence`: Starts and continues Telegram typing activity only while the Pi instance has direct ownership or a live follower registration, stopping quietly when authority disappears so classic takeover cannot re-arm a stale loop or flood diagnostics with expected follower-registration errors.
|
|
12
|
+
|
|
5
13
|
## 0.36.9: Telegram Status And Generated Controls Hotfix
|
|
6
14
|
|
|
7
15
|
- `Classic Takeover Status`: Keeps a classic-mode client visibly `disconnected` after another Pi instance takes Telegram ownership, preventing stale transport-side activity errors from overriding the authoritative connection state.
|
package/docs/architecture.md
CHANGED
|
@@ -198,7 +198,7 @@ All inbound updates are gated by the configured authorized user id.
|
|
|
198
198
|
5. Coalesce media groups, likely split long text, and one adjacent forward-plus-comment pair in either order when needed.
|
|
199
199
|
6. Download files with size limits and partial-download cleanup, then run configured/programmatic inbound handlers.
|
|
200
200
|
7. Build a prompt or control queue item carrying an exact durable receipt for every contributing update id.
|
|
201
|
-
8. Remove journal authority
|
|
201
|
+
8. Remove local prompt journal authority synchronously immediately before `sendUserMessage`, so session/process replacement can lose an unstarted prompt at that narrow crash boundary but can never replay a prompt already admitted to Pi; controls and foreign forwarding retain their explicit settlement boundaries.
|
|
202
202
|
9. Handle `edited_message` updates separately while the original turn is still queued and dispatch only when all safety gates are clear.
|
|
203
203
|
|
|
204
204
|
#### Durable Admission And Recovery
|
|
@@ -286,7 +286,7 @@ Dispatch requires:
|
|
|
286
286
|
|
|
287
287
|
A dispatched prompt remains queued until `agent_start` consumes it. This keeps the active Telegram turn bound for previews, attachments, aborts, and final replies. A low-level `agent_end` error also retains that active turn because Pi may retry automatically; a later successful `agent_end` delivers through the original target and metadata, while `agent_settled` proves that an unrecovered error can be finalized once before queue dispatch resumes.
|
|
288
288
|
|
|
289
|
-
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,
|
|
289
|
+
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, the bridge synchronously commits its exact durable receipt before calling normal `sendUserMessage(content)`; failed receipt commitment blocks dispatch, while the unavoidable crash window between commitment and Pi admission favors at-most-once execution over replay. It does not use Pi's `followUp` delivery option or inject terminal input.
|
|
290
290
|
|
|
291
291
|
One monotonic session generation also fences agent/tool/message events, compaction callbacks, preview state, scheduled final delivery, controls, and shutdown. Distinct Pi context objects observed within one session adopt that generation; contexts already observed under an older generation remain stale after replacement. Session start invalidates pending preview work, delayed finals check their captured context before delivery, and shutdown rechecks after asynchronous polling/preview boundaries with a bounded preview-clear wait.
|
|
292
292
|
|
package/index.ts
CHANGED
|
@@ -465,6 +465,15 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
465
465
|
BusApi.createTelegramAggregateTypingActionSender(telegramApiRuntime),
|
|
466
466
|
updateStatus,
|
|
467
467
|
isContextActive: telegramSessionContextStore.isCurrent,
|
|
468
|
+
getTransportAuthority() {
|
|
469
|
+
if (ownsTelegramDirectDelivery()) {
|
|
470
|
+
const epoch = getCurrentLeaderEpoch();
|
|
471
|
+
return epoch === undefined ? undefined : `direct:${epoch}`;
|
|
472
|
+
}
|
|
473
|
+
if (!telegramBusFollowerRegistrationState.isRegistered()) return undefined;
|
|
474
|
+
const generation = telegramBusFollowerRegistrationState.getGeneration();
|
|
475
|
+
return generation ? `follower:${generation}` : undefined;
|
|
476
|
+
},
|
|
468
477
|
recordRuntimeEvent,
|
|
469
478
|
});
|
|
470
479
|
const currentModelRuntime = Model.createCurrentModelRuntime({
|
package/lib/bindings.ts
CHANGED
|
@@ -68,6 +68,10 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
|
|
|
68
68
|
ctx: TContext,
|
|
69
69
|
) => void;
|
|
70
70
|
isItemReady: (item: Queue.TelegramQueueItem<TContext>) => boolean;
|
|
71
|
+
onPromptHandedOff?: (
|
|
72
|
+
item: Queue.PendingTelegramTurn,
|
|
73
|
+
ctx: TContext,
|
|
74
|
+
) => void;
|
|
71
75
|
onControlSettled: (
|
|
72
76
|
item: Queue.PendingTelegramControlItem<TContext>,
|
|
73
77
|
ctx: TContext,
|
|
@@ -122,6 +126,13 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
|
|
|
122
126
|
(item.admissionReceipts?.length ?? 0) === 0
|
|
123
127
|
);
|
|
124
128
|
},
|
|
129
|
+
commitPromptDispatch(item, ctx) {
|
|
130
|
+
if ((item.admissionReceipts?.length ?? 0) === 0) return true;
|
|
131
|
+
const settlement = deps.admission.getSettlement();
|
|
132
|
+
if (!settlement?.onPromptHandedOff) return false;
|
|
133
|
+
settlement.onPromptHandedOff(item, ctx);
|
|
134
|
+
return !settlement.isItemReady(item);
|
|
135
|
+
},
|
|
125
136
|
onControlSettled(item, ctx) {
|
|
126
137
|
deps.admission.getSettlement()?.onControlSettled(item, ctx);
|
|
127
138
|
},
|
package/lib/queue.ts
CHANGED
|
@@ -2836,6 +2836,9 @@ export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
|
|
|
2836
2836
|
>["item"],
|
|
2837
2837
|
) => void;
|
|
2838
2838
|
onPromptDispatchStart: (chatId: number) => void;
|
|
2839
|
+
commitPromptDispatch?: (
|
|
2840
|
+
item: Extract<TelegramQueueDispatchAction<TContext>, { kind: "prompt" }>["item"],
|
|
2841
|
+
) => boolean;
|
|
2839
2842
|
sendUserMessage: (
|
|
2840
2843
|
content: Extract<
|
|
2841
2844
|
TelegramQueueDispatchAction,
|
|
@@ -2859,6 +2862,10 @@ export interface TelegramQueueDispatchControllerDeps<
|
|
|
2859
2862
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
2860
2863
|
sendTextReply: TelegramControlRuntimeDeps<TContext>["sendTextReply"];
|
|
2861
2864
|
onPromptDispatchStart: (ctx: TContext, chatId: number) => void;
|
|
2865
|
+
commitPromptDispatch?: (
|
|
2866
|
+
item: Extract<TelegramQueueDispatchAction<TContext>, { kind: "prompt" }>["item"],
|
|
2867
|
+
ctx: TContext,
|
|
2868
|
+
) => boolean;
|
|
2862
2869
|
sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
|
|
2863
2870
|
onPromptDispatchFailure: (ctx: TContext, message: string) => void;
|
|
2864
2871
|
isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
|
|
@@ -2890,6 +2897,9 @@ export function executeTelegramQueueDispatchPlan<TContext = unknown>(
|
|
|
2890
2897
|
}
|
|
2891
2898
|
deps.onPromptDispatchStart(plan.item.chatId);
|
|
2892
2899
|
try {
|
|
2900
|
+
if (deps.commitPromptDispatch && !deps.commitPromptDispatch(plan.item)) {
|
|
2901
|
+
throw new Error("Telegram prompt dispatch could not be committed durably.");
|
|
2902
|
+
}
|
|
2893
2903
|
deps.sendUserMessage(plan.item.content);
|
|
2894
2904
|
} catch (error) {
|
|
2895
2905
|
const message = getTelegramQueueErrorMessage(error);
|
|
@@ -2922,6 +2932,7 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
|
|
|
2922
2932
|
updateStatus: deps.updateStatus,
|
|
2923
2933
|
sendTextReply: deps.sendTextReply,
|
|
2924
2934
|
onPromptDispatchStart: deps.onPromptDispatchStart,
|
|
2935
|
+
commitPromptDispatch: deps.commitPromptDispatch,
|
|
2925
2936
|
sendUserMessage: deps.sendUserMessage,
|
|
2926
2937
|
onPromptDispatchFailure: deps.onPromptDispatchFailure,
|
|
2927
2938
|
isQueueItemTransportActive: deps.isQueueItemTransportActive,
|
|
@@ -3061,6 +3072,9 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
|
|
|
3061
3072
|
onPromptDispatchStart: (chatId) => {
|
|
3062
3073
|
deps.onPromptDispatchStart(ctx, chatId);
|
|
3063
3074
|
},
|
|
3075
|
+
commitPromptDispatch: deps.commitPromptDispatch
|
|
3076
|
+
? (item) => deps.commitPromptDispatch!(item, ctx)
|
|
3077
|
+
: undefined,
|
|
3064
3078
|
sendUserMessage: deps.sendUserMessage,
|
|
3065
3079
|
onPromptDispatchFailure: (message) => {
|
|
3066
3080
|
deps.onPromptDispatchFailure(ctx, message);
|
package/lib/runtime.ts
CHANGED
|
@@ -320,6 +320,8 @@ export interface TelegramTypingLoopDeps {
|
|
|
320
320
|
options?: { message_thread_id?: number },
|
|
321
321
|
) => Promise<unknown>;
|
|
322
322
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
323
|
+
shouldContinue?: () => boolean;
|
|
324
|
+
onStopped?: () => void;
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
export interface TelegramRuntimeEventRecorderPort {
|
|
@@ -365,6 +367,8 @@ export interface TelegramTypingLoopStarterDeps<
|
|
|
365
367
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
366
368
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
367
369
|
isContextActive?: (ctx: TContext) => boolean;
|
|
370
|
+
isTransportAvailable?: () => boolean;
|
|
371
|
+
getTransportAuthority?: () => string | number | undefined;
|
|
368
372
|
intervalMs?: number;
|
|
369
373
|
}
|
|
370
374
|
|
|
@@ -376,15 +380,33 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
376
380
|
options?: { target?: TelegramTypingLoopTarget },
|
|
377
381
|
) => void {
|
|
378
382
|
return (ctx, chatId, options) => {
|
|
383
|
+
const transportAuthority = deps.getTransportAuthority?.();
|
|
384
|
+
const hasTransport = (): boolean =>
|
|
385
|
+
deps.getTransportAuthority
|
|
386
|
+
? transportAuthority !== undefined &&
|
|
387
|
+
Object.is(deps.getTransportAuthority(), transportAuthority)
|
|
388
|
+
: deps.isTransportAvailable?.() !== false;
|
|
389
|
+
if (!hasTransport()) return;
|
|
390
|
+
let active = true;
|
|
379
391
|
deps.typing.start({
|
|
380
392
|
chatId: chatId ?? deps.getDefaultChatId(),
|
|
381
393
|
target: options?.target,
|
|
382
394
|
intervalMs: deps.intervalMs ?? TELEGRAM_TYPING_ACTION_INTERVAL_MS,
|
|
383
395
|
sendTypingAction: async (targetChatId, actionOptions) => {
|
|
396
|
+
if (!active) return;
|
|
397
|
+
if (!hasTransport()) {
|
|
398
|
+
deps.typing.stop();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
384
401
|
try {
|
|
385
402
|
await deps.sendTypingAction(targetChatId, actionOptions);
|
|
386
403
|
} catch (error) {
|
|
387
404
|
if (deps.isContextActive?.(ctx) === false) return;
|
|
405
|
+
if (!active) return;
|
|
406
|
+
if (!hasTransport()) {
|
|
407
|
+
deps.typing.stop();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
388
410
|
const message =
|
|
389
411
|
error instanceof Error ? error.message : String(error);
|
|
390
412
|
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
@@ -404,10 +426,20 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
404
426
|
},
|
|
405
427
|
sendAggregateTypingAction: deps.sendAggregateTypingAction
|
|
406
428
|
? async (targetChatId) => {
|
|
429
|
+
if (!active) return;
|
|
430
|
+
if (!hasTransport()) {
|
|
431
|
+
deps.typing.stop();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
407
434
|
try {
|
|
408
435
|
await deps.sendAggregateTypingAction?.(targetChatId);
|
|
409
436
|
} catch (error) {
|
|
410
437
|
if (deps.isContextActive?.(ctx) === false) return;
|
|
438
|
+
if (!active) return;
|
|
439
|
+
if (!hasTransport()) {
|
|
440
|
+
deps.typing.stop();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
411
443
|
const message =
|
|
412
444
|
error instanceof Error ? error.message : String(error);
|
|
413
445
|
updateTelegramRuntimeStatusSafely(deps.updateStatus, ctx, {
|
|
@@ -427,6 +459,10 @@ export function createTelegramTypingLoopStarter<TContext>(
|
|
|
427
459
|
}
|
|
428
460
|
}
|
|
429
461
|
: undefined,
|
|
462
|
+
shouldContinue: hasTransport,
|
|
463
|
+
onStopped: () => {
|
|
464
|
+
active = false;
|
|
465
|
+
},
|
|
430
466
|
});
|
|
431
467
|
};
|
|
432
468
|
}
|
|
@@ -442,7 +478,12 @@ export function startTelegramTypingLoop(
|
|
|
442
478
|
): boolean {
|
|
443
479
|
if (deps.chatId === undefined || deps.chatId === 0) return false;
|
|
444
480
|
const previousKey = state.typingLoopKey;
|
|
481
|
+
const previousDeps = state.typingLoopDeps;
|
|
445
482
|
const nextKey = getTelegramTypingLoopKey(deps);
|
|
483
|
+
if (previousDeps && previousDeps !== deps) {
|
|
484
|
+
previousDeps.onStopped?.();
|
|
485
|
+
state.typingInFlight = undefined;
|
|
486
|
+
}
|
|
446
487
|
state.typingLoopDeps = deps;
|
|
447
488
|
state.typingLoopKey = nextKey;
|
|
448
489
|
const sendTyping = (): void => {
|
|
@@ -450,10 +491,14 @@ export function startTelegramTypingLoop(
|
|
|
450
491
|
if (
|
|
451
492
|
!activeDeps ||
|
|
452
493
|
activeDeps.chatId === undefined ||
|
|
453
|
-
activeDeps.chatId === 0
|
|
454
|
-
state.typingInFlight
|
|
494
|
+
activeDeps.chatId === 0
|
|
455
495
|
)
|
|
456
496
|
return;
|
|
497
|
+
if (activeDeps.shouldContinue?.() === false) {
|
|
498
|
+
stopTelegramTypingLoop(state);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (state.typingInFlight) return;
|
|
457
502
|
const targetChatId = activeDeps.chatId;
|
|
458
503
|
const threadParams = getTelegramTypingLoopThreadParams(activeDeps.target);
|
|
459
504
|
const typing = Promise.resolve()
|
|
@@ -486,9 +531,12 @@ export function stopTelegramTypingLoop(
|
|
|
486
531
|
): boolean {
|
|
487
532
|
if (!state.typingInterval) return false;
|
|
488
533
|
clearInterval(state.typingInterval);
|
|
534
|
+
const activeDeps = state.typingLoopDeps;
|
|
489
535
|
state.typingInterval = undefined;
|
|
490
536
|
state.typingLoopDeps = undefined;
|
|
491
537
|
state.typingLoopKey = undefined;
|
|
538
|
+
state.typingInFlight = undefined;
|
|
539
|
+
activeDeps?.onStopped?.();
|
|
492
540
|
return true;
|
|
493
541
|
}
|
|
494
542
|
|
|
@@ -571,6 +619,8 @@ export interface TelegramPromptDispatchRuntimeDeps<
|
|
|
571
619
|
sendAggregateTypingAction?: (chatId: number) => Promise<unknown>;
|
|
572
620
|
updateStatus: (ctx: TContext, error?: string) => void;
|
|
573
621
|
isContextActive?: (ctx: TContext) => boolean;
|
|
622
|
+
isTransportAvailable?: () => boolean;
|
|
623
|
+
getTransportAuthority?: () => string | number | undefined;
|
|
574
624
|
intervalMs?: number;
|
|
575
625
|
}
|
|
576
626
|
|