@llblab/pi-kit 0.35.0 → 0.35.2
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/BACKLOG.md +2 -0
- package/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
- package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
- package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +2 -2
|
@@ -471,6 +471,7 @@ export function createTelegramThreadCapabilityOrchestration(deps) {
|
|
|
471
471
|
isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
|
|
472
472
|
updateStatus: deps.updateStatus,
|
|
473
473
|
recordEvent: deps.recordEvent,
|
|
474
|
+
failureLog: deps.failureLog,
|
|
474
475
|
};
|
|
475
476
|
return {
|
|
476
477
|
monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
|
|
@@ -813,8 +814,37 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
813
814
|
let generation = 0;
|
|
814
815
|
let transitionPromise;
|
|
815
816
|
let consecutiveDisabledProbes = 0;
|
|
817
|
+
// An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
|
|
818
|
+
// Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
|
|
819
|
+
const capabilityFailureLog = {
|
|
820
|
+
record(error, details) {
|
|
821
|
+
try {
|
|
822
|
+
deps.failureLog.record(error, details);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
settled() {
|
|
829
|
+
try {
|
|
830
|
+
deps.failureLog.settled();
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// Monitor diagnostics cannot fail a completed probe.
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
reset() {
|
|
837
|
+
try {
|
|
838
|
+
deps.failureLog.reset();
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
// Monitor diagnostics cannot fail a monitor stop.
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
};
|
|
816
845
|
const stop = () => {
|
|
817
846
|
generation += 1;
|
|
847
|
+
capabilityFailureLog.reset();
|
|
818
848
|
if (interval)
|
|
819
849
|
clearInterval(interval);
|
|
820
850
|
interval = undefined;
|
|
@@ -832,6 +862,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
832
862
|
.then(async (threadModeEnabled) => {
|
|
833
863
|
if (!isCurrent())
|
|
834
864
|
return;
|
|
865
|
+
capabilityFailureLog.settled();
|
|
835
866
|
if (threadModeEnabled === undefined) {
|
|
836
867
|
if (deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
|
|
837
868
|
!deps.getPollingStartedWithTelegramBus() &&
|
|
@@ -879,12 +910,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
879
910
|
.catch((error) => {
|
|
880
911
|
if (!isCurrent())
|
|
881
912
|
return;
|
|
882
|
-
|
|
883
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
884
|
-
}
|
|
885
|
-
catch {
|
|
886
|
-
// Monitor diagnostics cannot create an unhandled interval rejection.
|
|
887
|
-
}
|
|
913
|
+
capabilityFailureLog.record(error);
|
|
888
914
|
})
|
|
889
915
|
.finally(() => {
|
|
890
916
|
if (transitionPromise === tracked)
|
|
@@ -911,12 +937,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
|
|
|
911
937
|
catch {
|
|
912
938
|
/* Timer shutdown must not escape the callback. */
|
|
913
939
|
}
|
|
914
|
-
|
|
915
|
-
deps.recordEvent("bus", error, { phase: "capability-monitor" });
|
|
916
|
-
}
|
|
917
|
-
catch {
|
|
918
|
-
// Monitor diagnostics cannot create an uncaught interval exception.
|
|
919
|
-
}
|
|
940
|
+
capabilityFailureLog.record(error);
|
|
920
941
|
}
|
|
921
942
|
}, intervalMs);
|
|
922
943
|
interval.unref?.();
|
|
@@ -506,10 +506,14 @@ export interface TelegramAgentEndRuntimeDeps<TTurn extends PendingTelegramTurn,
|
|
|
506
506
|
}) => Promise<void>;
|
|
507
507
|
sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
|
|
508
508
|
/** Replaces the early guest ACK with the final text. */
|
|
509
|
-
editGuestReply?: (inlineMessageId: string, markdown: string
|
|
509
|
+
editGuestReply?: (inlineMessageId: string, markdown: string, options?: {
|
|
510
|
+
assertAuthority?: () => void;
|
|
511
|
+
}) => Promise<void>;
|
|
510
512
|
/** Cancels the animated guest placeholder before the final edit. */
|
|
511
513
|
stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
|
|
512
|
-
sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string
|
|
514
|
+
sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string, options?: {
|
|
515
|
+
isDeliveryActive?: () => boolean;
|
|
516
|
+
}) => Promise<void>;
|
|
513
517
|
sendGuestVoiceReply?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>, caption?: string) => Promise<void>;
|
|
514
518
|
planOutboundReply?: (markdown: string) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
|
|
515
519
|
sendOutboundReplyArtifacts?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan, options?: {
|
|
@@ -1377,13 +1377,55 @@ export async function handleTelegramAgentEndRuntime(deps) {
|
|
|
1377
1377
|
});
|
|
1378
1378
|
}
|
|
1379
1379
|
}
|
|
1380
|
-
if (
|
|
1381
|
-
|
|
1380
|
+
if (!isDeliveryActive())
|
|
1381
|
+
return;
|
|
1382
|
+
if (turn.guestInlineMessageId) {
|
|
1383
|
+
let experimentText = assistant.errorMessage
|
|
1382
1384
|
? "Telegram bridge: Pi failed while processing the request."
|
|
1383
1385
|
: finalText;
|
|
1386
|
+
const [attachment] = turn.queuedAttachments;
|
|
1387
|
+
if (!assistant.errorMessage && attachment) {
|
|
1388
|
+
try {
|
|
1389
|
+
if (!deps.sendGuestAttachment)
|
|
1390
|
+
throw new Error("Guest attachment delivery is unavailable.");
|
|
1391
|
+
await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
|
|
1392
|
+
if (isDeliveryActive() && endPlan.shouldDispatchNext)
|
|
1393
|
+
deps.dispatchNextQueuedTelegramTurn();
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
catch (error) {
|
|
1397
|
+
deps.recordRuntimeEvent?.("delivery", error, {
|
|
1398
|
+
phase: "guest-attachment", guestQueryId: turn.guestQueryId,
|
|
1399
|
+
});
|
|
1400
|
+
if (!isDeliveryActive())
|
|
1401
|
+
return;
|
|
1402
|
+
if (isTelegramApiCommitUnknownError(error)) {
|
|
1403
|
+
if (endPlan.shouldDispatchNext)
|
|
1404
|
+
deps.dispatchNextQueuedTelegramTurn();
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
else if (!assistant.errorMessage && hasOutboundArtifacts) {
|
|
1411
|
+
experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
|
|
1412
|
+
}
|
|
1413
|
+
if (!deps.editGuestReply) {
|
|
1414
|
+
deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
|
|
1415
|
+
phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
|
|
1416
|
+
});
|
|
1417
|
+
if (endPlan.shouldDispatchNext)
|
|
1418
|
+
deps.dispatchNextQueuedTelegramTurn();
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1384
1421
|
if (experimentText) {
|
|
1385
1422
|
try {
|
|
1386
|
-
await deps.editGuestReply(turn.guestInlineMessageId, experimentText
|
|
1423
|
+
await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
|
|
1424
|
+
assertAuthority() {
|
|
1425
|
+
if (!isDeliveryActive())
|
|
1426
|
+
throw new Error("Guest reply edit lost authority.");
|
|
1427
|
+
},
|
|
1428
|
+
});
|
|
1387
1429
|
deps.recordRuntimeEvent?.("guest", new Error("Guest ACK edited the guest answer"), { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId });
|
|
1388
1430
|
}
|
|
1389
1431
|
catch (error) {
|
|
@@ -1419,7 +1461,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
|
|
|
1419
1461
|
const [guestAttachment] = turn.queuedAttachments;
|
|
1420
1462
|
if (guestAttachment && deps.sendGuestAttachment) {
|
|
1421
1463
|
try {
|
|
1422
|
-
await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined);
|
|
1464
|
+
await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined, { isDeliveryActive });
|
|
1423
1465
|
}
|
|
1424
1466
|
catch (error) {
|
|
1425
1467
|
deps.recordRuntimeEvent?.("delivery", error, {
|
|
@@ -163,8 +163,12 @@ export declare function createGuestMarkdownReplyEditor(deps: {
|
|
|
163
163
|
editGuestInlineMessage: (inlineMessageId: string, content: {
|
|
164
164
|
richMessage?: TelegramInputRichMessage;
|
|
165
165
|
text?: string;
|
|
166
|
+
}, options?: {
|
|
167
|
+
assertAuthority?: () => void;
|
|
166
168
|
}) => Promise<void>;
|
|
167
|
-
}): (inlineMessageId: string, markdown: string
|
|
169
|
+
}): (inlineMessageId: string, markdown: string, options?: {
|
|
170
|
+
assertAuthority?: () => void;
|
|
171
|
+
}) => Promise<void>;
|
|
168
172
|
/**
|
|
169
173
|
* Guest Mode placeholder rotation: the early ACK is the first placeholder
|
|
170
174
|
* frame and the runtime steps the remaining frames once per interval, moving
|
|
@@ -578,11 +578,11 @@ export function createGuestMarkdownReplySender(deps) {
|
|
|
578
578
|
* chat/message pair.
|
|
579
579
|
*/
|
|
580
580
|
export function createGuestMarkdownReplyEditor(deps) {
|
|
581
|
-
return async (inlineMessageId, markdown) => {
|
|
581
|
+
return async (inlineMessageId, markdown, options) => {
|
|
582
582
|
const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
|
|
583
583
|
await deps.editGuestInlineMessage(inlineMessageId, {
|
|
584
584
|
richMessage: { markdown: richMarkdown },
|
|
585
|
-
});
|
|
585
|
+
}, options);
|
|
586
586
|
};
|
|
587
587
|
}
|
|
588
588
|
/**
|
|
@@ -211,6 +211,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
211
211
|
canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
|
|
212
212
|
/** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
|
|
213
213
|
inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
|
|
214
|
+
/** Explicit cleanup-only recovery; never called by a read-only protection observer. */
|
|
215
|
+
recoverTemporaryThreadDeadQueueOwners?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs: readonly Threads.TelegramTemporaryThreadInput[], isCurrent: () => boolean) => number;
|
|
214
216
|
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
|
|
215
217
|
temporaryThreadCleanupDelayMs?: number;
|
|
216
218
|
/** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
|
|
@@ -4574,6 +4574,100 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4574
4574
|
}
|
|
4575
4575
|
await promptEnqueue(messages, ctx);
|
|
4576
4576
|
};
|
|
4577
|
+
/** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
|
|
4578
|
+
const stageTemporaryRerouteMenu = async (pending, command, messages, ctx, assertRecipientCurrent, completion) => {
|
|
4579
|
+
const original = pending.messages[0];
|
|
4580
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
4581
|
+
const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
4582
|
+
if (!cap?.isCurrent() || !temporary)
|
|
4583
|
+
return undefined;
|
|
4584
|
+
let accept, resume;
|
|
4585
|
+
const accepted = new Promise(resolve => { accept = resolve; });
|
|
4586
|
+
const released = new Promise(resolve => { resume = resolve; });
|
|
4587
|
+
const admission = {
|
|
4588
|
+
assertSourceCurrent() {
|
|
4589
|
+
if (!completion.isCurrent())
|
|
4590
|
+
throw new Error("Rerouted menu source authority changed.");
|
|
4591
|
+
},
|
|
4592
|
+
assertRecipientCurrent,
|
|
4593
|
+
reportCompleted() {
|
|
4594
|
+
const result = completion.reportCompleted();
|
|
4595
|
+
accept(result);
|
|
4596
|
+
return result;
|
|
4597
|
+
},
|
|
4598
|
+
async beforeEffects() {
|
|
4599
|
+
if (!(await released))
|
|
4600
|
+
throw new Error("Rerouted menu execution was not released.");
|
|
4601
|
+
},
|
|
4602
|
+
};
|
|
4603
|
+
const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
|
|
4604
|
+
: command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
|
|
4605
|
+
const dispatch = prepare(command, messages, ctx, admission);
|
|
4606
|
+
if (!dispatch)
|
|
4607
|
+
return undefined;
|
|
4608
|
+
const execution = dispatch().catch(error => {
|
|
4609
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
|
|
4610
|
+
return false;
|
|
4611
|
+
});
|
|
4612
|
+
if (!(await Promise.race([accepted, execution.then(() => false)]))) {
|
|
4613
|
+
resume(false);
|
|
4614
|
+
await execution;
|
|
4615
|
+
return undefined;
|
|
4616
|
+
}
|
|
4617
|
+
return async () => {
|
|
4618
|
+
let allowEffects = false;
|
|
4619
|
+
try {
|
|
4620
|
+
const confirmed = await new Promise(resolve => {
|
|
4621
|
+
let done = false, unsubscribe;
|
|
4622
|
+
const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
|
|
4623
|
+
const finish = (result) => {
|
|
4624
|
+
if (done)
|
|
4625
|
+
return;
|
|
4626
|
+
done = true;
|
|
4627
|
+
clearTimeout(timer);
|
|
4628
|
+
unsubscribe?.();
|
|
4629
|
+
signal?.removeEventListener("abort", abort);
|
|
4630
|
+
resolve(result);
|
|
4631
|
+
};
|
|
4632
|
+
const abort = () => finish(false);
|
|
4633
|
+
const observe = () => {
|
|
4634
|
+
if (!cap.isCurrent() || !completion.isCurrent())
|
|
4635
|
+
finish(false);
|
|
4636
|
+
else if (completion.inspectCompletion())
|
|
4637
|
+
finish(true);
|
|
4638
|
+
};
|
|
4639
|
+
const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
|
|
4640
|
+
timer.unref?.();
|
|
4641
|
+
unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
|
|
4642
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
4643
|
+
if (signal?.aborted)
|
|
4644
|
+
abort();
|
|
4645
|
+
else
|
|
4646
|
+
observe();
|
|
4647
|
+
});
|
|
4648
|
+
if (!confirmed)
|
|
4649
|
+
throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
|
|
4650
|
+
assertRecipientCurrent();
|
|
4651
|
+
cancelTemporaryThreadCleanup(temporary.token);
|
|
4652
|
+
try {
|
|
4653
|
+
await runTemporaryThreadCleanup(temporary.token, cap, false);
|
|
4654
|
+
}
|
|
4655
|
+
catch (error) {
|
|
4656
|
+
// Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
|
|
4657
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
|
|
4658
|
+
}
|
|
4659
|
+
assertRecipientCurrent();
|
|
4660
|
+
allowEffects = true;
|
|
4661
|
+
}
|
|
4662
|
+
catch (error) {
|
|
4663
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
|
|
4664
|
+
}
|
|
4665
|
+
finally {
|
|
4666
|
+
resume(allowEffects);
|
|
4667
|
+
await execution;
|
|
4668
|
+
}
|
|
4669
|
+
};
|
|
4670
|
+
};
|
|
4577
4671
|
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
|
|
4578
4672
|
assertExecutionCurrent();
|
|
4579
4673
|
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
@@ -4888,9 +4982,17 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4888
4982
|
if (!cancelledCurrent())
|
|
4889
4983
|
return;
|
|
4890
4984
|
entry = cap.adopt(entry);
|
|
4891
|
-
if (!entry ||
|
|
4892
|
-
|
|
4893
|
-
|
|
4985
|
+
if (!entry || !cap.isCurrent())
|
|
4986
|
+
return;
|
|
4987
|
+
if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
|
|
4988
|
+
store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
|
|
4989
|
+
const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
|
|
4990
|
+
const removed = deps.recoverTemporaryThreadDeadQueueOwners(target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)], inputs, cap.isCurrent);
|
|
4991
|
+
if (removed > 0)
|
|
4992
|
+
deps.recordRuntimeEvent?.("routing", "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.", { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
|
|
4993
|
+
}
|
|
4994
|
+
// Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
|
|
4995
|
+
if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
|
|
4894
4996
|
return;
|
|
4895
4997
|
const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
|
|
4896
4998
|
if (!issued || !cap.isCurrent())
|
|
@@ -5911,7 +6013,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5911
6013
|
await deps.answerCallbackQuery(query.id);
|
|
5912
6014
|
return true;
|
|
5913
6015
|
};
|
|
5914
|
-
const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
|
|
6016
|
+
const executeUnboundRerouteCallbackOperation = async (query, ctx, afterAdmission) => {
|
|
5915
6017
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
5916
6018
|
if (!parsed)
|
|
5917
6019
|
return false;
|
|
@@ -6432,7 +6534,21 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6432
6534
|
const localMessages = pending.rerouteCopies
|
|
6433
6535
|
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
6434
6536
|
: reroutedMessages;
|
|
6435
|
-
|
|
6537
|
+
const command = pending.dispatchKind === "command"
|
|
6538
|
+
? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
|
|
6539
|
+
const completion = pending.rerouteCopies && command
|
|
6540
|
+
? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
|
|
6541
|
+
if (command && completion) {
|
|
6542
|
+
const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
|
|
6543
|
+
if (!release) {
|
|
6544
|
+
await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
|
|
6545
|
+
return true;
|
|
6546
|
+
}
|
|
6547
|
+
afterAdmission(release);
|
|
6548
|
+
}
|
|
6549
|
+
else {
|
|
6550
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
6551
|
+
}
|
|
6436
6552
|
assertRerouteCurrent();
|
|
6437
6553
|
pending.messages = [];
|
|
6438
6554
|
return completeRoutedReroute();
|
|
@@ -6441,14 +6557,22 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6441
6557
|
const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
|
|
6442
6558
|
if (!parsed)
|
|
6443
6559
|
return false;
|
|
6444
|
-
return withPendingRerouteSelection(parsed.rerouteId, query, () => {
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6560
|
+
return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
|
|
6561
|
+
let release;
|
|
6562
|
+
const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
|
|
6563
|
+
try {
|
|
6564
|
+
if (!deps.runWorkspaceOperation)
|
|
6565
|
+
return await operation();
|
|
6566
|
+
return await deps.runWorkspaceOperation({
|
|
6567
|
+
operationId: `workspace-reroute:${query.id}`,
|
|
6568
|
+
operationKind: "workspace.route-unbound-thread",
|
|
6569
|
+
scopes: [{ kind: "profile" }],
|
|
6570
|
+
}, operation);
|
|
6571
|
+
}
|
|
6572
|
+
finally {
|
|
6573
|
+
// Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
|
|
6574
|
+
await release?.();
|
|
6575
|
+
}
|
|
6452
6576
|
});
|
|
6453
6577
|
};
|
|
6454
6578
|
const handleUnboundRerouteCallback = async (query, ctx) => {
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
* Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
|
|
5
5
|
*/
|
|
6
6
|
import * as Machine from "./machine.ts";
|
|
7
|
+
/**
|
|
8
|
+
* Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
|
|
9
|
+
* again, and the closing success records one outcome. The composition root owns the collapsed implementation.
|
|
10
|
+
*/
|
|
11
|
+
export interface TelegramRepeatedFailureLogPort {
|
|
12
|
+
record: (error: unknown, details?: Record<string, unknown>) => void;
|
|
13
|
+
settled: () => void;
|
|
14
|
+
reset: () => void;
|
|
15
|
+
}
|
|
7
16
|
export interface TelegramRuntimeQueueCounters {
|
|
8
17
|
nextQueuedTelegramItemOrder: number;
|
|
9
18
|
nextQueuedTelegramControlOrder: number;
|
|
@@ -103,6 +112,8 @@ export interface TelegramRuntimeEventRecorderPort {
|
|
|
103
112
|
recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
|
|
104
113
|
}
|
|
105
114
|
export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntimeEventRecorderPort {
|
|
115
|
+
/** Collapses one sustained presence failure run; the caller supplies its owner. */
|
|
116
|
+
failureLog: TelegramRepeatedFailureLogPort;
|
|
106
117
|
typing: TelegramRuntimeTypingPort;
|
|
107
118
|
getDefaultChatId: () => number | undefined;
|
|
108
119
|
sendTypingAction: (chatId: number, options?: {
|
|
@@ -162,6 +162,34 @@ function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
|
|
|
162
162
|
}
|
|
163
163
|
export function createTelegramTypingLoopStarter(deps) {
|
|
164
164
|
let currentOwner;
|
|
165
|
+
// An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
|
|
166
|
+
// Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
|
|
167
|
+
const typingFailureLog = {
|
|
168
|
+
record(error, details) {
|
|
169
|
+
try {
|
|
170
|
+
deps.failureLog.record(error, details);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Typing diagnostics cannot escape the in-flight action owner.
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
settled() {
|
|
177
|
+
try {
|
|
178
|
+
deps.failureLog.settled();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// A successful presence outranks its own diagnostic.
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
reset() {
|
|
185
|
+
try {
|
|
186
|
+
deps.failureLog.reset();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Typing diagnostics cannot fail a loop stop.
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
165
193
|
return (ctx, chatId, options) => {
|
|
166
194
|
const sessionGeneration = deps.getSessionGeneration?.();
|
|
167
195
|
const transportAuthority = deps.getTransportAuthority?.();
|
|
@@ -214,6 +242,7 @@ export function createTelegramTypingLoopStarter(deps) {
|
|
|
214
242
|
throw new Error("Typing context authority changed.");
|
|
215
243
|
},
|
|
216
244
|
});
|
|
245
|
+
typingFailureLog.settled();
|
|
217
246
|
}
|
|
218
247
|
catch (error) {
|
|
219
248
|
if (!active)
|
|
@@ -222,19 +251,13 @@ export function createTelegramTypingLoopStarter(deps) {
|
|
|
222
251
|
deps.typing.stop();
|
|
223
252
|
return;
|
|
224
253
|
}
|
|
225
|
-
|
|
226
|
-
deps.recordRuntimeEvent?.("typing", error, {
|
|
227
|
-
chatId: targetChatId,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
// Typing diagnostics cannot escape the in-flight action owner.
|
|
232
|
-
}
|
|
254
|
+
typingFailureLog.record(error, { chatId: targetChatId });
|
|
233
255
|
}
|
|
234
256
|
},
|
|
235
257
|
shouldContinue: canContinue,
|
|
236
258
|
onStopped: () => {
|
|
237
259
|
active = false;
|
|
260
|
+
typingFailureLog.reset();
|
|
238
261
|
if (currentOwner === owner)
|
|
239
262
|
currentOwner = undefined;
|
|
240
263
|
},
|
|
@@ -299,6 +299,8 @@ export interface TelegramApiCallOptions {
|
|
|
299
299
|
sleep?: (ms: number) => Promise<void>;
|
|
300
300
|
/** Observability hook fired before a 429 retry wait, never for 5xx waits. */
|
|
301
301
|
onRetryWait?: (wait: TelegramApiRetryWait) => void;
|
|
302
|
+
/** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
|
|
303
|
+
isExpectedError?: (error: unknown) => boolean;
|
|
302
304
|
}
|
|
303
305
|
export declare class TelegramApiAuthorityError extends Error {
|
|
304
306
|
readonly requestIssued: boolean;
|
|
@@ -343,6 +345,12 @@ export interface TelegramAnswerGuestQueryOptions {
|
|
|
343
345
|
result?: TelegramGuestCachedMediaResult;
|
|
344
346
|
}
|
|
345
347
|
export interface TelegramEditGuestInlineMessageContent {
|
|
348
|
+
/** Cached media only: inline edits cannot upload a new local file. */
|
|
349
|
+
media?: {
|
|
350
|
+
type: "photo" | "document" | "audio";
|
|
351
|
+
media: string;
|
|
352
|
+
caption?: string;
|
|
353
|
+
};
|
|
346
354
|
text?: string;
|
|
347
355
|
richMessage?: TelegramInputRichMessage;
|
|
348
356
|
parseMode?: "HTML";
|
|
@@ -465,13 +473,12 @@ export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call"
|
|
|
465
473
|
answerCallbackQuery: (callbackQueryId: string, text?: string, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
466
474
|
answerGuestQuery: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<void>;
|
|
467
475
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
* guest answers, so this exists only to falsify that behavior live.
|
|
476
|
+
* Answers the guest query and retains the sent inline message id for final
|
|
477
|
+
* text or cached-media replacement. Requires direct transport ownership;
|
|
478
|
+
* actual guest-media rendering remains live-client gated.
|
|
472
479
|
*/
|
|
473
480
|
answerGuestQueryForInlineMessage: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<string | undefined>;
|
|
474
|
-
editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent) => Promise<void>;
|
|
481
|
+
editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
475
482
|
deleteMessage: (chatId: number, messageId: number) => Promise<void>;
|
|
476
483
|
prepareTempDir: () => Promise<number>;
|
|
477
484
|
}
|
|
@@ -1134,7 +1134,8 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1134
1134
|
"Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
|
|
1135
1135
|
return true;
|
|
1136
1136
|
}
|
|
1137
|
-
|
|
1137
|
+
if (!options?.isExpectedError?.(error))
|
|
1138
|
+
deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
|
|
1138
1139
|
throw error;
|
|
1139
1140
|
}
|
|
1140
1141
|
};
|
|
@@ -1267,14 +1268,31 @@ export function createTelegramBridgeApiRuntime(deps) {
|
|
|
1267
1268
|
const sent = await callRecorded("answerGuestQuery", buildTelegramAnswerGuestQueryBody(guestQueryId, text, options));
|
|
1268
1269
|
return sent?.inline_message_id;
|
|
1269
1270
|
},
|
|
1270
|
-
editGuestInlineMessage: async (inlineMessageId, content) => {
|
|
1271
|
+
editGuestInlineMessage: async (inlineMessageId, content, options) => {
|
|
1272
|
+
if (content.media) {
|
|
1273
|
+
try {
|
|
1274
|
+
const result = await callRecorded("editMessageMedia", {
|
|
1275
|
+
inline_message_id: inlineMessageId,
|
|
1276
|
+
media: content.media,
|
|
1277
|
+
}, { ...options, maxAttempts: 1, retryRateLimit: false });
|
|
1278
|
+
if (result !== true)
|
|
1279
|
+
throw new Error("Missing inline edit acknowledgement.");
|
|
1280
|
+
}
|
|
1281
|
+
catch (error) {
|
|
1282
|
+
// Only a parsed rejection permits replacing this attempt with a failure notice.
|
|
1283
|
+
if (isTelegramApiRequestRejected(error, "editMessageMedia"))
|
|
1284
|
+
throw error;
|
|
1285
|
+
throw new TelegramApiCommitUnknownError("editMessageMedia", error);
|
|
1286
|
+
}
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1271
1289
|
await callRecorded("editMessageText", {
|
|
1272
1290
|
inline_message_id: inlineMessageId,
|
|
1273
1291
|
...(content.richMessage
|
|
1274
1292
|
? { rich_message: content.richMessage }
|
|
1275
1293
|
: { text: content.text }),
|
|
1276
1294
|
...(content.parseMode ? { parse_mode: content.parseMode } : {}),
|
|
1277
|
-
});
|
|
1295
|
+
}, options);
|
|
1278
1296
|
},
|
|
1279
1297
|
prepareTempDir: () => prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
|
|
1280
1298
|
deleteMessage: (chatId, messageId) => callRecorded("deleteMessage", {
|
|
@@ -339,7 +339,11 @@ export async function applyThreadReconciliationPlan(plan, ports) {
|
|
|
339
339
|
}
|
|
340
340
|
let deleteConfirmed = false;
|
|
341
341
|
let superseded = false;
|
|
342
|
-
|
|
342
|
+
// Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
|
|
343
|
+
const skipClose = ports.skipCloseBeforeDelete ||
|
|
344
|
+
(action.kind === "close-delete-graceful-shutdown-topic" &&
|
|
345
|
+
action.target.chatId > 0);
|
|
346
|
+
for (const method of skipClose
|
|
343
347
|
? ["deleteForumTopic"]
|
|
344
348
|
: ["closeForumTopic", "deleteForumTopic"]) {
|
|
345
349
|
if (ports.isCleanupTargetProtected?.(action.target, action)) {
|
|
@@ -157,6 +157,10 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
|
|
|
157
157
|
acceptedWork: TelegramWorkspaceProtectionState;
|
|
158
158
|
deliveryAuthority: TelegramWorkspaceProtectionState;
|
|
159
159
|
}
|
|
160
|
+
/** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
|
|
161
|
+
export type TelegramWorkspaceExternalProtectionCapture = ((binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence) & {
|
|
162
|
+
withSnapshot?: <T>(capture: () => T) => T;
|
|
163
|
+
};
|
|
160
164
|
export interface TelegramWorkspaceSlotOccupancySnapshot {
|
|
161
165
|
bindings: TelegramWorkspaceSlotOccupancy[];
|
|
162
166
|
reservedSlots: string[];
|
|
@@ -549,7 +553,7 @@ export interface TelegramTopicTargetStore {
|
|
|
549
553
|
bindingUpdatedAtMs: number;
|
|
550
554
|
}, isCurrent: () => boolean) => Promise<boolean>;
|
|
551
555
|
/** Caller must separately prove no external live owner, accepted work, or delivery authority. */
|
|
552
|
-
captureWorkspaceSlotOccupancy: (getExternalProtection:
|
|
556
|
+
captureWorkspaceSlotOccupancy: (getExternalProtection: TelegramWorkspaceExternalProtectionCapture, options?: {
|
|
553
557
|
expectedRetirement?: TelegramWorkspaceRetirementIntent;
|
|
554
558
|
}) => TelegramWorkspaceSlotOccupancySnapshot;
|
|
555
559
|
hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;
|