@llblab/pi-telegram 0.54.3 → 0.54.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 +10 -0
- package/dist/lib/bindings.d.ts +3 -1
- package/dist/lib/bindings.js +2 -1
- package/dist/lib/extension.js +8 -0
- package/dist/lib/routing.d.ts +8 -1
- package/dist/lib/routing.js +282 -67
- package/dist/lib/threads.d.ts +2 -0
- package/dist/lib/threads.js +4 -2
- package/dist/package.json +1 -1
- package/docs/architecture.md +5 -5
- package/docs/multi-instance-bus.md +2 -2
- package/docs/ui-style.md +6 -4
- package/lib/bindings.ts +4 -0
- package/lib/extension.ts +8 -0
- package/lib/routing.ts +357 -83
- package/lib/threads.ts +10 -2
- package/package.json +1 -1
package/dist/lib/routing.js
CHANGED
|
@@ -302,7 +302,8 @@ function formatTelegramUnboundRerouteChooserText(command) {
|
|
|
302
302
|
return `<b>🔀 Reroute ${formatTelegramRouteSubject(command)} to:</b>`;
|
|
303
303
|
}
|
|
304
304
|
function formatTelegramUnboundRerouteRestoreChooserText(command) {
|
|
305
|
-
|
|
305
|
+
const subject = command ? ` for ${formatTelegramRouteSubject(command)}` : "";
|
|
306
|
+
return `<b>🔁 Restore a Pi into this tab${subject}:</b>`;
|
|
306
307
|
}
|
|
307
308
|
function formatTelegramUnboundTopicGuidance() {
|
|
308
309
|
return [
|
|
@@ -370,7 +371,10 @@ function classifyLiveRebindCleanupFailure(error) {
|
|
|
370
371
|
? "rejected"
|
|
371
372
|
: "unknown";
|
|
372
373
|
}
|
|
373
|
-
/**
|
|
374
|
+
/**
|
|
375
|
+
* Default live-rebind cleanup pacing: quick early attempts, then minutely, within a 15-minute window. The first
|
|
376
|
+
* delay is not cosmetic: a leader's detached command reply still needs the released, cleanup-free row.
|
|
377
|
+
*/
|
|
374
378
|
export const TELEGRAM_LIVE_REBIND_CLEANUP_SCHEDULE = Object.freeze({
|
|
375
379
|
delaysMs: Object.freeze([1_000, 2_000, 5_000, 10_000, 30_000]),
|
|
376
380
|
intervalMs: 60_000,
|
|
@@ -465,7 +469,7 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
465
469
|
return confirmed && current();
|
|
466
470
|
};
|
|
467
471
|
let preparation;
|
|
468
|
-
let saved = false, commitIssued = false, applyIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
|
|
472
|
+
let saved = false, commitIssued = false, applyIssued = false, retitleIssued = false, peerReleaseIssued = false, donorSettlementAttempted = false, continuationIssued = false, released = false, advancing = false;
|
|
469
473
|
let preparedSource;
|
|
470
474
|
const runFollower = async (mode) => {
|
|
471
475
|
if (!input.follower || !recipient || !current())
|
|
@@ -687,6 +691,19 @@ export function createTelegramLiveRebindCoordinator(input) {
|
|
|
687
691
|
}
|
|
688
692
|
if (!sourceSaved() || (preparation && !preparation.confirmSaved()))
|
|
689
693
|
return "protected";
|
|
694
|
+
if (input.retitle && !retitleIssued) {
|
|
695
|
+
retitleIssued = true;
|
|
696
|
+
try {
|
|
697
|
+
await input.retitle(current);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
input.recordRuntimeEvent?.("telegram", error, {
|
|
701
|
+
phase: "live-rebind-title",
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
if (!current() || !canonical(intent))
|
|
705
|
+
return "unknown";
|
|
706
|
+
}
|
|
690
707
|
if (intent.phase === "rebound") {
|
|
691
708
|
const expected = intent;
|
|
692
709
|
input.restoreStore.advanceLiveRebind(expected, "release", recipient.kind === "leader"
|
|
@@ -1561,6 +1578,47 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1561
1578
|
};
|
|
1562
1579
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
1563
1580
|
const deleteAllTabCopies = (pending) => deleteAllTabMessages(pending.sourceTarget.chatId, pending.allTabCopies?.splice(0) ?? []);
|
|
1581
|
+
/**
|
|
1582
|
+
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
1583
|
+
* holds its forwarded copy. Without one it stays where the operator can still read it.
|
|
1584
|
+
*/
|
|
1585
|
+
const releaseAllTabCopiesIntoSurvivingTab = (pending) => {
|
|
1586
|
+
if (pending.allTabCopiesForwarded)
|
|
1587
|
+
return deleteAllTabCopies(pending);
|
|
1588
|
+
pending.allTabCopies?.splice(0);
|
|
1589
|
+
return Promise.resolve();
|
|
1590
|
+
};
|
|
1591
|
+
/**
|
|
1592
|
+
* The Bot API files a threadless All input in All even when a mobile client first draws it in its new tab. One silent
|
|
1593
|
+
* best-effort forward moves it into the routing tab it opened, and only a confirmed copy lets the original leave All;
|
|
1594
|
+
* any failure keeps the original where the operator can still read it.
|
|
1595
|
+
*/
|
|
1596
|
+
const moveAllTabSourceIntoTab = async (pending, target, isCurrent) => {
|
|
1597
|
+
const [messageId] = pending.allTabCopies ?? [];
|
|
1598
|
+
if (messageId === undefined || !deps.callApi || !isCurrent())
|
|
1599
|
+
return;
|
|
1600
|
+
try {
|
|
1601
|
+
const copy = await deps.callApi("forwardMessage", {
|
|
1602
|
+
chat_id: target.chatId,
|
|
1603
|
+
message_thread_id: target.threadId,
|
|
1604
|
+
from_chat_id: pending.sourceTarget.chatId,
|
|
1605
|
+
message_id: messageId,
|
|
1606
|
+
disable_notification: true,
|
|
1607
|
+
});
|
|
1608
|
+
const copyId = copy?.message_id;
|
|
1609
|
+
if (typeof copyId !== "number" || !Number.isSafeInteger(copyId))
|
|
1610
|
+
return;
|
|
1611
|
+
pending.allTabCopiesForwarded = true;
|
|
1612
|
+
await deleteAllTabCopies(pending);
|
|
1613
|
+
return copyId;
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
1617
|
+
phase: "temporary-thread-source-forward",
|
|
1618
|
+
});
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1564
1622
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
1565
1623
|
const chatId = query.message?.chat?.id;
|
|
1566
1624
|
const messageId = query.message?.message_id;
|
|
@@ -1586,6 +1644,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1586
1644
|
return false;
|
|
1587
1645
|
}
|
|
1588
1646
|
};
|
|
1647
|
+
/** Choosers are one-shot: a final outcome deletes the menu; only a failed delete leaves an inert, button-free notice. */
|
|
1648
|
+
const retireRerouteChooserMessage = async (chatId, messageId, fallbackText, assertCurrent) => {
|
|
1649
|
+
if (deps.deleteMessage) {
|
|
1650
|
+
try {
|
|
1651
|
+
await deps.deleteMessage(chatId, messageId);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
catch (error) {
|
|
1655
|
+
deps.recordRuntimeEvent?.("telegram", error, {
|
|
1656
|
+
phase: "reroute-chooser-delete",
|
|
1657
|
+
chatId,
|
|
1658
|
+
messageId,
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
assertCurrent?.();
|
|
1662
|
+
}
|
|
1663
|
+
await deps.editInteractiveMessage?.(chatId, messageId, fallbackText, "html", {
|
|
1664
|
+
inline_keyboard: [],
|
|
1665
|
+
});
|
|
1666
|
+
};
|
|
1589
1667
|
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
|
|
1590
1668
|
if (!target || !deps.threadStore)
|
|
1591
1669
|
return true;
|
|
@@ -1708,8 +1786,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1708
1786
|
continue;
|
|
1709
1787
|
if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
|
|
1710
1788
|
continue;
|
|
1789
|
+
// A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
|
|
1711
1790
|
if (await retireCancellationChooser(id, pending, current))
|
|
1712
|
-
await
|
|
1791
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
1713
1792
|
}
|
|
1714
1793
|
catch (error) {
|
|
1715
1794
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -2239,12 +2318,28 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2239
2318
|
if (!intent)
|
|
2240
2319
|
continue;
|
|
2241
2320
|
}
|
|
2321
|
+
// The chooser follows the success proof, never old-thread cleanup, which may stay protected indefinitely.
|
|
2322
|
+
const retireRestoreChoosers = async () => {
|
|
2323
|
+
for (const [id, pending] of pendingUnboundReroutes) {
|
|
2324
|
+
if (pending.workspaceRestore?.operationId !== operationId)
|
|
2325
|
+
continue;
|
|
2326
|
+
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
2327
|
+
if (pending.chooserMessageId === undefined)
|
|
2328
|
+
continue;
|
|
2329
|
+
assertSettlementCurrent();
|
|
2330
|
+
await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", assertSettlementCurrent);
|
|
2331
|
+
assertSettlementCurrent();
|
|
2332
|
+
if (pendingUnboundReroutes.get(id) === pending)
|
|
2333
|
+
removePendingReroute(id);
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2242
2336
|
if (intent.routing?.cleanup === undefined) {
|
|
2243
2337
|
if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
|
|
2244
2338
|
continue;
|
|
2245
|
-
// Positive settlement of every selected source is the success proof
|
|
2339
|
+
// Positive settlement of every selected source is the success proof.
|
|
2246
2340
|
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
2247
2341
|
assertSettlementCurrent();
|
|
2342
|
+
await retireRestoreChoosers();
|
|
2248
2343
|
if (isRerouteTargetProtected(intent.request.binding.target, intent))
|
|
2249
2344
|
continue;
|
|
2250
2345
|
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
@@ -2265,19 +2360,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2265
2360
|
if (!store.retire(intent, settlementAuthority))
|
|
2266
2361
|
continue;
|
|
2267
2362
|
retired = true;
|
|
2268
|
-
|
|
2269
|
-
if (pending.workspaceRestore?.operationId !== operationId)
|
|
2270
|
-
continue;
|
|
2271
|
-
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
2272
|
-
if (!deps.editInteractiveMessage ||
|
|
2273
|
-
pending.chooserMessageId === undefined)
|
|
2274
|
-
continue;
|
|
2275
|
-
assertSettlementCurrent();
|
|
2276
|
-
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
2277
|
-
assertSettlementCurrent();
|
|
2278
|
-
if (pendingUnboundReroutes.get(id) === pending)
|
|
2279
|
-
removePendingReroute(id);
|
|
2280
|
-
}
|
|
2363
|
+
await retireRestoreChoosers();
|
|
2281
2364
|
}
|
|
2282
2365
|
}))
|
|
2283
2366
|
.catch((error) => {
|
|
@@ -3211,6 +3294,20 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3211
3294
|
messages: selection.messages,
|
|
3212
3295
|
restoreStore: store,
|
|
3213
3296
|
threadStore: threads,
|
|
3297
|
+
// Same title as Workspace Restore: the display-mode title of the restored binding on its new tab.
|
|
3298
|
+
retitle: deps.callApi
|
|
3299
|
+
? async (isCurrent) => {
|
|
3300
|
+
if (!isCurrent())
|
|
3301
|
+
return;
|
|
3302
|
+
const slot = binding.slot ?? "";
|
|
3303
|
+
await deps.callApi("editForumTopic", {
|
|
3304
|
+
chat_id: target.chatId,
|
|
3305
|
+
message_thread_id: target.threadId,
|
|
3306
|
+
name: deps.getDisplayTitle?.(target) ??
|
|
3307
|
+
ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot),
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
: undefined,
|
|
3214
3311
|
authority: {
|
|
3215
3312
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
3216
3313
|
operatorUserId,
|
|
@@ -3729,6 +3826,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3729
3826
|
const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
|
|
3730
3827
|
// One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
|
|
3731
3828
|
const liveRebindCleanupChains = new Map();
|
|
3829
|
+
// A leader retry waiting out its delay; a settled session wakes it at once, since its own busy run is the usual wait.
|
|
3830
|
+
const liveRebindCleanupWakers = new Map();
|
|
3732
3831
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
3733
3832
|
const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
|
|
3734
3833
|
const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
|
|
@@ -3779,7 +3878,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3779
3878
|
liveRebindCleanupChains.delete(operationId);
|
|
3780
3879
|
return;
|
|
3781
3880
|
}
|
|
3782
|
-
const
|
|
3881
|
+
const attemptNow = () => {
|
|
3882
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
3783
3883
|
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3784
3884
|
return;
|
|
3785
3885
|
liveRebindCleanupChains.set(operationId, "running");
|
|
@@ -3814,9 +3914,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3814
3914
|
});
|
|
3815
3915
|
restoreSettlementTasks.add(task);
|
|
3816
3916
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
3817
|
-
}
|
|
3917
|
+
};
|
|
3918
|
+
const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
|
|
3818
3919
|
timer.unref?.();
|
|
3819
3920
|
liveRebindCleanupChains.set(operationId, timer);
|
|
3921
|
+
// The first delay stays whole: a detached command reply still needs the released, cleanup-free row.
|
|
3922
|
+
if (role === "leader" && attempt > 0)
|
|
3923
|
+
liveRebindCleanupWakers.set(operationId, () => {
|
|
3924
|
+
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3925
|
+
return;
|
|
3926
|
+
clearTimeout(timer);
|
|
3927
|
+
attemptNow();
|
|
3928
|
+
});
|
|
3929
|
+
};
|
|
3930
|
+
/** The agent settled: every gate is re-sampled now, so an old tab goes right after the answer, not at the next tick. */
|
|
3931
|
+
const onSessionSettled = (ctx) => {
|
|
3932
|
+
if (deps.isContextActive?.(ctx) !== true)
|
|
3933
|
+
return;
|
|
3934
|
+
for (const wake of [...liveRebindCleanupWakers.values()])
|
|
3935
|
+
wake();
|
|
3820
3936
|
};
|
|
3821
3937
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
3822
3938
|
? async (input) => {
|
|
@@ -4203,17 +4319,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4203
4319
|
}
|
|
4204
4320
|
await promptEnqueue(messages, ctx);
|
|
4205
4321
|
};
|
|
4206
|
-
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
|
|
4322
|
+
const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
|
|
4207
4323
|
assertExecutionCurrent();
|
|
4208
4324
|
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
4209
4325
|
assertExecutionCurrent();
|
|
4210
4326
|
if (dismissed) {
|
|
4211
|
-
|
|
4327
|
+
// A retained record no longer matches any chooser, so a late duplicate tap reads as expired.
|
|
4328
|
+
if (retainRecord)
|
|
4329
|
+
pending.chooserMessageId = undefined;
|
|
4330
|
+
else
|
|
4331
|
+
removePendingReroute(rerouteId);
|
|
4212
4332
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
4213
4333
|
await deleteAllTabCopies(pending);
|
|
4214
4334
|
return;
|
|
4215
4335
|
}
|
|
4216
|
-
pending.phase = {
|
|
4336
|
+
pending.phase = {
|
|
4337
|
+
kind: "finalizing",
|
|
4338
|
+
message: successMessage,
|
|
4339
|
+
...(retainRecord ? { retainRecord: true } : {}),
|
|
4340
|
+
};
|
|
4217
4341
|
await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
|
|
4218
4342
|
};
|
|
4219
4343
|
const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
|
|
@@ -4613,10 +4737,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4613
4737
|
const retireCancellationChooser = async (id, pending, isCurrent) => {
|
|
4614
4738
|
if (!isPendingRerouteCancelled(pending) ||
|
|
4615
4739
|
!isCurrent() ||
|
|
4616
|
-
!deps.editInteractiveMessage ||
|
|
4740
|
+
(!deps.deleteMessage && !deps.editInteractiveMessage) ||
|
|
4617
4741
|
pending.chooserMessageId === undefined)
|
|
4618
4742
|
return false;
|
|
4619
|
-
await
|
|
4743
|
+
await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>");
|
|
4620
4744
|
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
|
|
4621
4745
|
return false;
|
|
4622
4746
|
removePendingReroute(id);
|
|
@@ -4973,9 +5097,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4973
5097
|
if (!result)
|
|
4974
5098
|
return;
|
|
4975
5099
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
5100
|
+
let movedIntoTab = false;
|
|
4976
5101
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
4977
5102
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
4978
5103
|
continue;
|
|
5104
|
+
movedIntoTab ||= pending.allTabCopiesForwarded === true;
|
|
4979
5105
|
removePendingReroute(id);
|
|
4980
5106
|
if (signal.aborted ||
|
|
4981
5107
|
deps.isContextActive?.(ctx) !== true ||
|
|
@@ -4999,7 +5125,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4999
5125
|
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
5000
5126
|
if (temporary?.target && runtimeCurrent()) {
|
|
5001
5127
|
scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
5002
|
-
|
|
5128
|
+
// A moved original already left All with its forward; only an unmoved one is deleted here.
|
|
5129
|
+
if (!movedIntoTab)
|
|
5130
|
+
await deleteAllTabMessages(temporary.target.chatId, getTelegramAllTabSourceMessageIds([message], temporary.target.chatId));
|
|
5003
5131
|
}
|
|
5004
5132
|
});
|
|
5005
5133
|
};
|
|
@@ -5448,7 +5576,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5448
5576
|
return true;
|
|
5449
5577
|
}
|
|
5450
5578
|
if (pending.phase.kind === "finalizing") {
|
|
5451
|
-
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent);
|
|
5579
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, pending.phase.message, assertExecutionCurrent, pending.phase.retainRecord === true);
|
|
5452
5580
|
return true;
|
|
5453
5581
|
}
|
|
5454
5582
|
if (pending.liveRebind &&
|
|
@@ -5723,7 +5851,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5723
5851
|
if (row?.phase === "released" && row.cleanup === undefined)
|
|
5724
5852
|
scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
|
|
5725
5853
|
if (outcome === "released")
|
|
5726
|
-
await
|
|
5854
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5727
5855
|
}
|
|
5728
5856
|
catch (error) {
|
|
5729
5857
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -5734,29 +5862,32 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5734
5862
|
selection.isCurrent = undefined;
|
|
5735
5863
|
}
|
|
5736
5864
|
assertExecutionCurrent();
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5865
|
+
if (outcome !== "released") {
|
|
5866
|
+
await deps.answerCallbackQuery(query.id, "Live rebind is unconfirmed; this choice never replays issued input");
|
|
5867
|
+
return true;
|
|
5868
|
+
}
|
|
5869
|
+
// The tab now belongs to the rebound thread; only the one-shot chooser message goes.
|
|
5870
|
+
await finalizePendingReroute(parsed.rerouteId, pending, query, selection.extensionKind === "generated-prompt"
|
|
5871
|
+
? "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed"
|
|
5872
|
+
: selection.extensionCommand
|
|
5873
|
+
? "Command source disposal confirmed; reply delivery and old-Thread cleanup are not confirmed"
|
|
5874
|
+
: selection.nameCommand
|
|
5875
|
+
? selection.nameCommand.args.trim()
|
|
5876
|
+
? "Command source disposal confirmed; title/reply delivery and old-Thread cleanup are not confirmed"
|
|
5877
|
+
: "Command source disposal confirmed; name-dialog publication was handled, not title change or old-Thread cleanup"
|
|
5878
|
+
: selection.confirmationCommand
|
|
5879
|
+
? `Command source disposal confirmed; confirmation delivery and actual ${selection.confirmationCommand.name === "new" ? "session replacement" : "compaction"} or old-Thread cleanup are not confirmed`
|
|
5880
|
+
: selection.helpCommand
|
|
5881
|
+
? "Command source disposal confirmed; menu/sync delivery and old-Thread cleanup are not confirmed"
|
|
5882
|
+
: selection.stopCommand
|
|
5883
|
+
? "Command source disposal confirmed; abort settlement, reply delivery and old-Thread cleanup are not confirmed"
|
|
5884
|
+
: selection.nextCommand
|
|
5885
|
+
? "Command source disposal confirmed; queue dispatch, transition notices and old-Thread cleanup are not confirmed"
|
|
5886
|
+
: selection.menuCommand || selection.abortCommand
|
|
5887
|
+
? `Command source disposal confirmed; ${selection.menuCommand ? "menu" : "reply"} delivery and old-Thread cleanup are not confirmed`
|
|
5888
|
+
: selection.coordinator?.selectedCommandReference()
|
|
5889
|
+
? "Command source disposal confirmed; recipient command effects and old-Thread cleanup are not confirmed"
|
|
5890
|
+
: "Input queued in the rebound Thread; old-Thread cleanup is scheduled, not confirmed", assertExecutionCurrent, true);
|
|
5760
5891
|
return true;
|
|
5761
5892
|
}
|
|
5762
5893
|
if (!(await selectPendingDestination()))
|
|
@@ -5768,6 +5899,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5768
5899
|
messages: [...pending.messages],
|
|
5769
5900
|
});
|
|
5770
5901
|
let active = true;
|
|
5902
|
+
// Delivery proof for the toast only; chooser retirement still waits for the positive settlement that follows.
|
|
5903
|
+
let delivered = false;
|
|
5771
5904
|
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
5772
5905
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
5773
5906
|
const admissionScope = deps.getAdmissionScope?.();
|
|
@@ -5818,12 +5951,15 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5818
5951
|
: routed;
|
|
5819
5952
|
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
5820
5953
|
assertRecipientCurrent();
|
|
5821
|
-
|
|
5954
|
+
delivered = true;
|
|
5955
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5822
5956
|
return true;
|
|
5823
5957
|
}
|
|
5824
5958
|
const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
5825
|
-
if (forwarded)
|
|
5826
|
-
|
|
5959
|
+
if (forwarded) {
|
|
5960
|
+
delivered = true;
|
|
5961
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5962
|
+
}
|
|
5827
5963
|
return forwarded;
|
|
5828
5964
|
},
|
|
5829
5965
|
});
|
|
@@ -5834,7 +5970,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5834
5970
|
assertExecutionCurrent();
|
|
5835
5971
|
if (!selectionCurrent())
|
|
5836
5972
|
return true;
|
|
5837
|
-
await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
|
|
5973
|
+
await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
|
|
5838
5974
|
return true;
|
|
5839
5975
|
}
|
|
5840
5976
|
if (record.instanceId &&
|
|
@@ -6856,13 +6992,16 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6856
6992
|
}
|
|
6857
6993
|
return undefined;
|
|
6858
6994
|
};
|
|
6859
|
-
/**
|
|
6995
|
+
/**
|
|
6996
|
+
* A fresh implicit native tab observed just before the All input in the same exact scope: mobile names it after
|
|
6997
|
+
* the input, while other clients may use a generic name, so the immediately preceding creation also qualifies.
|
|
6998
|
+
*/
|
|
6860
6999
|
const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
|
|
6861
7000
|
const text = (message.text ?? message.caption ?? "").trim();
|
|
6862
7001
|
let match;
|
|
6863
7002
|
for (const [key, implicit] of implicitThreadCreations) {
|
|
6864
|
-
if (implicit.name &&
|
|
6865
|
-
|
|
7003
|
+
if (((implicit.name && text.startsWith(implicit.name)) ||
|
|
7004
|
+
implicit.updateId === updateId - 1) &&
|
|
6866
7005
|
implicit.updateId < updateId &&
|
|
6867
7006
|
implicit.createdAtSec > 0 &&
|
|
6868
7007
|
message.date !== undefined &&
|
|
@@ -6906,6 +7045,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6906
7045
|
.find((entry) => entry.source.journalBindingKey === journalBindingKey &&
|
|
6907
7046
|
entry.source.updateId === updateId);
|
|
6908
7047
|
let entry;
|
|
7048
|
+
// A tab opened by this call, by the bot or adopted from the client, gets the input's one forwarded copy; re-entry
|
|
7049
|
+
// after a restart finds its tab already prepared and never forwards again.
|
|
7050
|
+
let openedNow = false;
|
|
6909
7051
|
await deps.runWorkspaceOperation({
|
|
6910
7052
|
operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
6911
7053
|
operationKind: "workspace.temporary-thread",
|
|
@@ -6931,6 +7073,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6931
7073
|
if (adopted && current()) {
|
|
6932
7074
|
implicitThreadCreations.delete(native.key);
|
|
6933
7075
|
entry = adopted;
|
|
7076
|
+
openedNow = true;
|
|
6934
7077
|
// Adopted native tabs share the routing name; a failed rename never blocks routing.
|
|
6935
7078
|
try {
|
|
6936
7079
|
await deps.callApi("editForumTopic", {
|
|
@@ -6971,8 +7114,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6971
7114
|
!Number.isSafeInteger(threadId) ||
|
|
6972
7115
|
threadId <= 0)
|
|
6973
7116
|
return;
|
|
6974
|
-
entry
|
|
6975
|
-
|
|
7117
|
+
const acknowledged = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority);
|
|
7118
|
+
openedNow = !!acknowledged;
|
|
7119
|
+
entry = acknowledged ?? entry;
|
|
6976
7120
|
});
|
|
6977
7121
|
if (!current() || !entry)
|
|
6978
7122
|
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
@@ -7003,6 +7147,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7003
7147
|
};
|
|
7004
7148
|
}
|
|
7005
7149
|
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
7150
|
+
const tabCopyId = openedNow
|
|
7151
|
+
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
7152
|
+
: undefined;
|
|
7006
7153
|
try {
|
|
7007
7154
|
if (!deps.sendInteractiveMessage)
|
|
7008
7155
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
@@ -7010,7 +7157,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7010
7157
|
canRestore: true,
|
|
7011
7158
|
canCancel: !!pending.abandonment,
|
|
7012
7159
|
getDisplayTitle: deps.getDisplayTitle,
|
|
7013
|
-
}),
|
|
7160
|
+
}), tabCopyId === undefined
|
|
7161
|
+
? { target }
|
|
7162
|
+
: { target, replyToMessageId: tabCopyId });
|
|
7014
7163
|
rememberRerouteChooser(rerouteId, chooserId);
|
|
7015
7164
|
}
|
|
7016
7165
|
catch (error) {
|
|
@@ -7311,7 +7460,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7311
7460
|
chatId: lifecycle.target.chatId,
|
|
7312
7461
|
threadId: lifecycle.target.threadId,
|
|
7313
7462
|
},
|
|
7314
|
-
name: "name" in created && typeof created.name === "string"
|
|
7463
|
+
name: "name" in created && typeof created.name === "string"
|
|
7464
|
+
? created.name
|
|
7465
|
+
: "",
|
|
7315
7466
|
createdAtSec: message.date ?? 0,
|
|
7316
7467
|
});
|
|
7317
7468
|
}
|
|
@@ -7623,7 +7774,35 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7623
7774
|
threadId: target.threadId,
|
|
7624
7775
|
};
|
|
7625
7776
|
/** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
|
|
7626
|
-
|
|
7777
|
+
// The leader's own Workspace binding follows a reclaimed tab, keeping its slot and name; without it, the tab
|
|
7778
|
+
// would gain a second slot while Restore, labels and titles still pointed at the deleted target.
|
|
7779
|
+
const leaderWorkspaceCwd = typeof ctx.cwd === "string"
|
|
7780
|
+
? ctx.cwd
|
|
7781
|
+
: undefined;
|
|
7782
|
+
const claimLeaderWorkspaceBinding = () => {
|
|
7783
|
+
const sessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
|
|
7784
|
+
if (!leaderWorkspaceCwd || !sessionId || !instanceId)
|
|
7785
|
+
return undefined;
|
|
7786
|
+
const identity = deps.threadStore.claimWorkspaceIdentity(leaderWorkspaceCwd, instanceId, undefined, { existingBindingOnly: true, sessionId });
|
|
7787
|
+
const binding = identity
|
|
7788
|
+
? deps.threadStore.getWorkspaceBinding(identity.cwd, identity.instanceSlot, identity.sessionId)
|
|
7789
|
+
: undefined;
|
|
7790
|
+
if (!binding)
|
|
7791
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7792
|
+
return binding;
|
|
7793
|
+
};
|
|
7794
|
+
const reclaimUnboundTargetForLeader = async (base, profileKey, slot, threadName, event, details, leaderBinding) => {
|
|
7795
|
+
if (leaderBinding) {
|
|
7796
|
+
const { inactiveSinceMs: _inactiveSinceMs, displayTitle: _displayTitle, ...moved } = leaderBinding;
|
|
7797
|
+
const committed = deps.threadStore.upsertWorkspaceBinding({
|
|
7798
|
+
...moved,
|
|
7799
|
+
target: { ...unboundTarget },
|
|
7800
|
+
updatedAtMs: Date.now(),
|
|
7801
|
+
}, instanceId);
|
|
7802
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7803
|
+
if (committed)
|
|
7804
|
+
threadName = committed.threadName ?? threadName;
|
|
7805
|
+
}
|
|
7627
7806
|
deps.threadStore.upsert({
|
|
7628
7807
|
...base,
|
|
7629
7808
|
profileKey,
|
|
@@ -7654,6 +7833,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7654
7833
|
slot,
|
|
7655
7834
|
profileKey,
|
|
7656
7835
|
});
|
|
7836
|
+
if (leaderBinding && deps.callApi) {
|
|
7837
|
+
const title = deps.getDisplayTitle?.(unboundTarget);
|
|
7838
|
+
if (title)
|
|
7839
|
+
try {
|
|
7840
|
+
await deps.callApi("editForumTopic", {
|
|
7841
|
+
chat_id: unboundTarget.chatId,
|
|
7842
|
+
message_thread_id: unboundTarget.threadId,
|
|
7843
|
+
name: title,
|
|
7844
|
+
});
|
|
7845
|
+
const current = deps
|
|
7846
|
+
.threadStore.listWorkspaceBindings()
|
|
7847
|
+
.find((value) => value.bindingKey === leaderBinding.bindingKey &&
|
|
7848
|
+
isDeepStrictEqual(value.target, unboundTarget));
|
|
7849
|
+
if (current) {
|
|
7850
|
+
deps.threadStore.upsertWorkspaceBinding({
|
|
7851
|
+
...current,
|
|
7852
|
+
displayTitle: title,
|
|
7853
|
+
});
|
|
7854
|
+
await deps.threadStore.persist();
|
|
7855
|
+
}
|
|
7856
|
+
}
|
|
7857
|
+
catch (error) {
|
|
7858
|
+
deps.recordRuntimeEvent?.("bus", error, {
|
|
7859
|
+
phase: "leader-topic-reclaim-title",
|
|
7860
|
+
});
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7657
7863
|
await textDispatch.handleMessage(message, ctx);
|
|
7658
7864
|
};
|
|
7659
7865
|
const records = deps.threadStore.list();
|
|
@@ -7754,7 +7960,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7754
7960
|
throw error;
|
|
7755
7961
|
}
|
|
7756
7962
|
if (currentLeaderIsStale) {
|
|
7757
|
-
const
|
|
7963
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
7964
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot, leaderBinding?.bindingKey);
|
|
7965
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
7966
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7758
7967
|
if (!slot) {
|
|
7759
7968
|
deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
|
|
7760
7969
|
await deps.threadStore.persist();
|
|
@@ -7767,7 +7976,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7767
7976
|
await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
|
|
7768
7977
|
phase: "leader-topic-unbound-stale-reclaim",
|
|
7769
7978
|
staleThreadId: currentLeaderRecord.target.threadId,
|
|
7770
|
-
});
|
|
7979
|
+
}, leaderBinding?.slot === slot ? leaderBinding : undefined);
|
|
7771
7980
|
return;
|
|
7772
7981
|
}
|
|
7773
7982
|
}
|
|
@@ -7777,7 +7986,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7777
7986
|
!hasAnyRoutableThread) {
|
|
7778
7987
|
const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
7779
7988
|
const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
7780
|
-
const
|
|
7989
|
+
const leaderBinding = claimLeaderWorkspaceBinding();
|
|
7990
|
+
const slot = deps.threadStore.allocateSlot(leaderProfileKey, leaderBinding?.slot ??
|
|
7991
|
+
priorLeaderRecord?.slot ??
|
|
7992
|
+
priorLeaderIdentity?.slot, leaderBinding?.bindingKey);
|
|
7993
|
+
if (leaderBinding && leaderBinding.slot !== slot)
|
|
7994
|
+
deps.threadStore.releaseWorkspaceClaim(instanceId);
|
|
7781
7995
|
if (!slot) {
|
|
7782
7996
|
await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
|
|
7783
7997
|
return;
|
|
@@ -7790,7 +8004,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7790
8004
|
identityThreadName ??
|
|
7791
8005
|
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
7792
8006
|
"Pi";
|
|
7793
|
-
await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" });
|
|
8007
|
+
await reclaimUnboundTargetForLeader({ createdAtMs: priorLeaderRecord?.createdAtMs ?? Date.now() }, leaderProfileKey, slot, threadName, "Bus leader reclaimed unbound thread", { phase: "leader-topic-reclaim" }, leaderBinding?.slot === slot ? leaderBinding : undefined);
|
|
7794
8008
|
return;
|
|
7795
8009
|
}
|
|
7796
8010
|
await sendUnboundRerouteChooser(message, ctx);
|
|
@@ -7817,6 +8031,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7817
8031
|
onUpdateCompleted,
|
|
7818
8032
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
7819
8033
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
8034
|
+
onSessionSettled,
|
|
7820
8035
|
observeLiveRebindLeaderWork,
|
|
7821
8036
|
prepareLiveRebindLeaderCleanup,
|
|
7822
8037
|
prepareLiveRebindFollowerCleanup,
|
package/dist/lib/threads.d.ts
CHANGED
|
@@ -855,6 +855,8 @@ export interface TelegramCurrentThreadAssemblyDeps {
|
|
|
855
855
|
instanceId: string;
|
|
856
856
|
listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
|
|
857
857
|
listWorkspaceBindings?: () => readonly TelegramWorkspaceThreadBinding[];
|
|
858
|
+
/** Display-mode title for a binding whose tab has no acknowledged title yet (fresh, moved or reclaimed). */
|
|
859
|
+
resolveAutomaticDisplayTitle?: (binding: TelegramWorkspaceThreadBinding) => string | undefined;
|
|
858
860
|
getFollowerDisplayTitle?: () => string | undefined;
|
|
859
861
|
getActiveTurnTarget(): TelegramTarget | undefined;
|
|
860
862
|
getFollowerTarget(): TelegramTarget | undefined;
|