@llblab/pi-telegram 0.54.2 → 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 +14 -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 +338 -63
- 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 +434 -78
- 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"
|
|
@@ -910,6 +927,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
|
910
927
|
export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
|
|
911
928
|
/** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
|
|
912
929
|
const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
|
|
930
|
+
/** A mobile client's native tab creation precedes the All input it is named after by about a second. */
|
|
931
|
+
const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
|
|
913
932
|
export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
|
|
914
933
|
return (message.message_thread_id === undefined &&
|
|
915
934
|
typeof message.date === "number" &&
|
|
@@ -1559,6 +1578,47 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1559
1578
|
};
|
|
1560
1579
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
1561
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
|
+
};
|
|
1562
1622
|
const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
|
|
1563
1623
|
const chatId = query.message?.chat?.id;
|
|
1564
1624
|
const messageId = query.message?.message_id;
|
|
@@ -1584,6 +1644,26 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1584
1644
|
return false;
|
|
1585
1645
|
}
|
|
1586
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
|
+
};
|
|
1587
1667
|
const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
|
|
1588
1668
|
if (!target || !deps.threadStore)
|
|
1589
1669
|
return true;
|
|
@@ -1706,8 +1786,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
1706
1786
|
continue;
|
|
1707
1787
|
if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
|
|
1708
1788
|
continue;
|
|
1789
|
+
// A sibling cancelled by Restore stays visible in the restored tab, like a desktop input typed there.
|
|
1709
1790
|
if (await retireCancellationChooser(id, pending, current))
|
|
1710
|
-
await
|
|
1791
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
1711
1792
|
}
|
|
1712
1793
|
catch (error) {
|
|
1713
1794
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -2237,12 +2318,28 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2237
2318
|
if (!intent)
|
|
2238
2319
|
continue;
|
|
2239
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
|
+
};
|
|
2240
2336
|
if (intent.routing?.cleanup === undefined) {
|
|
2241
2337
|
if (!intent.request.source.updateIds.every((id) => intent.routing.settlements.some((value) => value.kind !== "queued" && value.updateIds.includes(id))))
|
|
2242
2338
|
continue;
|
|
2243
|
-
// Positive settlement of every selected source is the success proof
|
|
2339
|
+
// Positive settlement of every selected source is the success proof.
|
|
2244
2340
|
await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
|
|
2245
2341
|
assertSettlementCurrent();
|
|
2342
|
+
await retireRestoreChoosers();
|
|
2246
2343
|
if (isRerouteTargetProtected(intent.request.binding.target, intent))
|
|
2247
2344
|
continue;
|
|
2248
2345
|
const grant = store.issueCleanup(intent, settlementAuthority);
|
|
@@ -2263,19 +2360,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
2263
2360
|
if (!store.retire(intent, settlementAuthority))
|
|
2264
2361
|
continue;
|
|
2265
2362
|
retired = true;
|
|
2266
|
-
|
|
2267
|
-
if (pending.workspaceRestore?.operationId !== operationId)
|
|
2268
|
-
continue;
|
|
2269
|
-
pending.phase = { kind: "finalizing", message: "Restored" };
|
|
2270
|
-
if (!deps.editInteractiveMessage ||
|
|
2271
|
-
pending.chooserMessageId === undefined)
|
|
2272
|
-
continue;
|
|
2273
|
-
assertSettlementCurrent();
|
|
2274
|
-
await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
|
|
2275
|
-
assertSettlementCurrent();
|
|
2276
|
-
if (pendingUnboundReroutes.get(id) === pending)
|
|
2277
|
-
removePendingReroute(id);
|
|
2278
|
-
}
|
|
2363
|
+
await retireRestoreChoosers();
|
|
2279
2364
|
}
|
|
2280
2365
|
}))
|
|
2281
2366
|
.catch((error) => {
|
|
@@ -3209,6 +3294,20 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3209
3294
|
messages: selection.messages,
|
|
3210
3295
|
restoreStore: store,
|
|
3211
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,
|
|
3212
3311
|
authority: {
|
|
3213
3312
|
executor: { instanceId, leaderEpoch: String(epoch) },
|
|
3214
3313
|
operatorUserId,
|
|
@@ -3727,6 +3826,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3727
3826
|
const issueLiveRebindFollowerCleanup = (intent, ctx) => inspectLiveRebindRecipient(intent, ctx, "follower-issue");
|
|
3728
3827
|
// One pacing chain per operation: a waiting timer or a running attempt; repeated choices never start another.
|
|
3729
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();
|
|
3730
3831
|
/** End an unissued attempt honestly under fresh admission and current operator authority; no executor grant is borrowed. */
|
|
3731
3832
|
const terminalizeUnissuedLiveRebindCleanup = async (operationId, ctx) => {
|
|
3732
3833
|
const run = deps.runWorkspaceOperation, store = deps.getWorkspaceRestoreStore?.();
|
|
@@ -3777,7 +3878,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3777
3878
|
liveRebindCleanupChains.delete(operationId);
|
|
3778
3879
|
return;
|
|
3779
3880
|
}
|
|
3780
|
-
const
|
|
3881
|
+
const attemptNow = () => {
|
|
3882
|
+
liveRebindCleanupWakers.delete(operationId);
|
|
3781
3883
|
if (liveRebindCleanupChains.get(operationId) !== timer)
|
|
3782
3884
|
return;
|
|
3783
3885
|
liveRebindCleanupChains.set(operationId, "running");
|
|
@@ -3812,9 +3914,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
3812
3914
|
});
|
|
3813
3915
|
restoreSettlementTasks.add(task);
|
|
3814
3916
|
void task.finally(() => restoreSettlementTasks.delete(task));
|
|
3815
|
-
}
|
|
3917
|
+
};
|
|
3918
|
+
const timer = setTimeout(attemptNow, pacing.delaysMs[attempt] ?? pacing.intervalMs);
|
|
3816
3919
|
timer.unref?.();
|
|
3817
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();
|
|
3818
3936
|
};
|
|
3819
3937
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
3820
3938
|
? async (input) => {
|
|
@@ -4201,17 +4319,25 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4201
4319
|
}
|
|
4202
4320
|
await promptEnqueue(messages, ctx);
|
|
4203
4321
|
};
|
|
4204
|
-
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) => {
|
|
4205
4323
|
assertExecutionCurrent();
|
|
4206
4324
|
const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
|
|
4207
4325
|
assertExecutionCurrent();
|
|
4208
4326
|
if (dismissed) {
|
|
4209
|
-
|
|
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);
|
|
4210
4332
|
await deps.answerCallbackQuery(query.id, successMessage);
|
|
4211
4333
|
await deleteAllTabCopies(pending);
|
|
4212
4334
|
return;
|
|
4213
4335
|
}
|
|
4214
|
-
pending.phase = {
|
|
4336
|
+
pending.phase = {
|
|
4337
|
+
kind: "finalizing",
|
|
4338
|
+
message: successMessage,
|
|
4339
|
+
...(retainRecord ? { retainRecord: true } : {}),
|
|
4340
|
+
};
|
|
4215
4341
|
await deps.answerCallbackQuery(query.id, `${successMessage}; tap again to clear the menu`);
|
|
4216
4342
|
};
|
|
4217
4343
|
const isTemporaryReroute = (pending) => !!pending.temporaryThread ||
|
|
@@ -4611,10 +4737,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4611
4737
|
const retireCancellationChooser = async (id, pending, isCurrent) => {
|
|
4612
4738
|
if (!isPendingRerouteCancelled(pending) ||
|
|
4613
4739
|
!isCurrent() ||
|
|
4614
|
-
!deps.editInteractiveMessage ||
|
|
4740
|
+
(!deps.deleteMessage && !deps.editInteractiveMessage) ||
|
|
4615
4741
|
pending.chooserMessageId === undefined)
|
|
4616
4742
|
return false;
|
|
4617
|
-
await
|
|
4743
|
+
await retireRerouteChooserMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>");
|
|
4618
4744
|
if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
|
|
4619
4745
|
return false;
|
|
4620
4746
|
removePendingReroute(id);
|
|
@@ -4971,9 +5097,11 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4971
5097
|
if (!result)
|
|
4972
5098
|
return;
|
|
4973
5099
|
// Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
|
|
5100
|
+
let movedIntoTab = false;
|
|
4974
5101
|
for (const [id, pending] of pendingUnboundReroutes) {
|
|
4975
5102
|
if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
|
|
4976
5103
|
continue;
|
|
5104
|
+
movedIntoTab ||= pending.allTabCopiesForwarded === true;
|
|
4977
5105
|
removePendingReroute(id);
|
|
4978
5106
|
if (signal.aborted ||
|
|
4979
5107
|
deps.isContextActive?.(ctx) !== true ||
|
|
@@ -4997,7 +5125,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
4997
5125
|
// never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
|
|
4998
5126
|
if (temporary?.target && runtimeCurrent()) {
|
|
4999
5127
|
scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
|
|
5000
|
-
|
|
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));
|
|
5001
5131
|
}
|
|
5002
5132
|
});
|
|
5003
5133
|
};
|
|
@@ -5446,7 +5576,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5446
5576
|
return true;
|
|
5447
5577
|
}
|
|
5448
5578
|
if (pending.phase.kind === "finalizing") {
|
|
5449
|
-
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);
|
|
5450
5580
|
return true;
|
|
5451
5581
|
}
|
|
5452
5582
|
if (pending.liveRebind &&
|
|
@@ -5721,7 +5851,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5721
5851
|
if (row?.phase === "released" && row.cleanup === undefined)
|
|
5722
5852
|
scheduleLiveRebindCleanup(operationId, selection.record.owner?.kind === "leader" ? "leader" : "follower", ctx);
|
|
5723
5853
|
if (outcome === "released")
|
|
5724
|
-
await
|
|
5854
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5725
5855
|
}
|
|
5726
5856
|
catch (error) {
|
|
5727
5857
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
@@ -5732,29 +5862,32 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5732
5862
|
selection.isCurrent = undefined;
|
|
5733
5863
|
}
|
|
5734
5864
|
assertExecutionCurrent();
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
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);
|
|
5758
5891
|
return true;
|
|
5759
5892
|
}
|
|
5760
5893
|
if (!(await selectPendingDestination()))
|
|
@@ -5766,6 +5899,8 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5766
5899
|
messages: [...pending.messages],
|
|
5767
5900
|
});
|
|
5768
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;
|
|
5769
5904
|
const ownerUserId = deps.configStore.getAllowedUserId();
|
|
5770
5905
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
5771
5906
|
const admissionScope = deps.getAdmissionScope?.();
|
|
@@ -5816,12 +5951,15 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5816
5951
|
: routed;
|
|
5817
5952
|
await dispatchPendingRerouteMessages(pending, messages, ctx);
|
|
5818
5953
|
assertRecipientCurrent();
|
|
5819
|
-
|
|
5954
|
+
delivered = true;
|
|
5955
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5820
5956
|
return true;
|
|
5821
5957
|
}
|
|
5822
5958
|
const forwarded = await forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
|
|
5823
|
-
if (forwarded)
|
|
5824
|
-
|
|
5959
|
+
if (forwarded) {
|
|
5960
|
+
delivered = true;
|
|
5961
|
+
await releaseAllTabCopiesIntoSurvivingTab(pending);
|
|
5962
|
+
}
|
|
5825
5963
|
return forwarded;
|
|
5826
5964
|
},
|
|
5827
5965
|
});
|
|
@@ -5832,7 +5970,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
5832
5970
|
assertExecutionCurrent();
|
|
5833
5971
|
if (!selectionCurrent())
|
|
5834
5972
|
return true;
|
|
5835
|
-
await deps.answerCallbackQuery(query.id, "Restore unconfirmed; not resending");
|
|
5973
|
+
await deps.answerCallbackQuery(query.id, delivered ? "Restored" : "Restore unconfirmed; not resending");
|
|
5836
5974
|
return true;
|
|
5837
5975
|
}
|
|
5838
5976
|
if (record.instanceId &&
|
|
@@ -6854,6 +6992,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6854
6992
|
}
|
|
6855
6993
|
return undefined;
|
|
6856
6994
|
};
|
|
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
|
+
*/
|
|
6999
|
+
const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
|
|
7000
|
+
const text = (message.text ?? message.caption ?? "").trim();
|
|
7001
|
+
let match;
|
|
7002
|
+
for (const [key, implicit] of implicitThreadCreations) {
|
|
7003
|
+
if (((implicit.name && text.startsWith(implicit.name)) ||
|
|
7004
|
+
implicit.updateId === updateId - 1) &&
|
|
7005
|
+
implicit.updateId < updateId &&
|
|
7006
|
+
implicit.createdAtSec > 0 &&
|
|
7007
|
+
message.date !== undefined &&
|
|
7008
|
+
message.date >= implicit.createdAtSec &&
|
|
7009
|
+
message.date - implicit.createdAtSec <=
|
|
7010
|
+
TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
|
|
7011
|
+
implicit.ctx === ctx &&
|
|
7012
|
+
implicit.operatorUserId === cap.operatorUserId &&
|
|
7013
|
+
implicit.journalBindingKey === cap.journalBindingKey &&
|
|
7014
|
+
isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
|
|
7015
|
+
implicit.generation === deps.getSessionGeneration?.() &&
|
|
7016
|
+
implicit.scope === deps.getAdmissionScope?.() &&
|
|
7017
|
+
(!match || implicit.updateId > match.implicit.updateId))
|
|
7018
|
+
match = { key, implicit };
|
|
7019
|
+
}
|
|
7020
|
+
return match;
|
|
7021
|
+
};
|
|
6857
7022
|
/**
|
|
6858
7023
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
6859
7024
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
@@ -6880,6 +7045,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6880
7045
|
.find((entry) => entry.source.journalBindingKey === journalBindingKey &&
|
|
6881
7046
|
entry.source.updateId === updateId);
|
|
6882
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;
|
|
6883
7051
|
await deps.runWorkspaceOperation({
|
|
6884
7052
|
operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
|
|
6885
7053
|
operationKind: "workspace.temporary-thread",
|
|
@@ -6893,6 +7061,35 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6893
7061
|
entry = found;
|
|
6894
7062
|
return;
|
|
6895
7063
|
}
|
|
7064
|
+
// Mobile clients sending from All first create a native tab named after the input, then deliver
|
|
7065
|
+
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
7066
|
+
const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
|
|
7067
|
+
if (native) {
|
|
7068
|
+
const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
|
|
7069
|
+
...authority,
|
|
7070
|
+
isCurrent: () => current() &&
|
|
7071
|
+
implicitThreadCreations.get(native.key) === native.implicit,
|
|
7072
|
+
});
|
|
7073
|
+
if (adopted && current()) {
|
|
7074
|
+
implicitThreadCreations.delete(native.key);
|
|
7075
|
+
entry = adopted;
|
|
7076
|
+
openedNow = true;
|
|
7077
|
+
// Adopted native tabs share the routing name; a failed rename never blocks routing.
|
|
7078
|
+
try {
|
|
7079
|
+
await deps.callApi("editForumTopic", {
|
|
7080
|
+
chat_id: native.implicit.target.chatId,
|
|
7081
|
+
message_thread_id: native.implicit.target.threadId,
|
|
7082
|
+
name: TELEGRAM_TEMPORARY_THREAD_NAME,
|
|
7083
|
+
});
|
|
7084
|
+
}
|
|
7085
|
+
catch (error) {
|
|
7086
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
7087
|
+
phase: "temporary-thread-name",
|
|
7088
|
+
});
|
|
7089
|
+
}
|
|
7090
|
+
return;
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
6896
7093
|
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
6897
7094
|
entry = reservation?.entry;
|
|
6898
7095
|
if (!reservation?.reserved || !current())
|
|
@@ -6917,8 +7114,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6917
7114
|
!Number.isSafeInteger(threadId) ||
|
|
6918
7115
|
threadId <= 0)
|
|
6919
7116
|
return;
|
|
6920
|
-
entry
|
|
6921
|
-
|
|
7117
|
+
const acknowledged = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority);
|
|
7118
|
+
openedNow = !!acknowledged;
|
|
7119
|
+
entry = acknowledged ?? entry;
|
|
6922
7120
|
});
|
|
6923
7121
|
if (!current() || !entry)
|
|
6924
7122
|
throw new Error("Temporary Thread authority changed; the input remains retryable.");
|
|
@@ -6949,6 +7147,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6949
7147
|
};
|
|
6950
7148
|
}
|
|
6951
7149
|
pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
|
|
7150
|
+
const tabCopyId = openedNow
|
|
7151
|
+
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
7152
|
+
: undefined;
|
|
6952
7153
|
try {
|
|
6953
7154
|
if (!deps.sendInteractiveMessage)
|
|
6954
7155
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
|
@@ -6956,7 +7157,9 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6956
7157
|
canRestore: true,
|
|
6957
7158
|
canCancel: !!pending.abandonment,
|
|
6958
7159
|
getDisplayTitle: deps.getDisplayTitle,
|
|
6959
|
-
}),
|
|
7160
|
+
}), tabCopyId === undefined
|
|
7161
|
+
? { target }
|
|
7162
|
+
: { target, replyToMessageId: tabCopyId });
|
|
6960
7163
|
rememberRerouteChooser(rerouteId, chooserId);
|
|
6961
7164
|
}
|
|
6962
7165
|
catch (error) {
|
|
@@ -7253,6 +7456,14 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7253
7456
|
executor: structuredClone(cap.authority.executor),
|
|
7254
7457
|
generation: deps.getSessionGeneration?.(),
|
|
7255
7458
|
scope: deps.getAdmissionScope?.(),
|
|
7459
|
+
target: {
|
|
7460
|
+
chatId: lifecycle.target.chatId,
|
|
7461
|
+
threadId: lifecycle.target.threadId,
|
|
7462
|
+
},
|
|
7463
|
+
name: "name" in created && typeof created.name === "string"
|
|
7464
|
+
? created.name
|
|
7465
|
+
: "",
|
|
7466
|
+
createdAtSec: message.date ?? 0,
|
|
7256
7467
|
});
|
|
7257
7468
|
}
|
|
7258
7469
|
else
|
|
@@ -7563,7 +7774,35 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7563
7774
|
threadId: target.threadId,
|
|
7564
7775
|
};
|
|
7565
7776
|
/** Rebinds the leader record to this unbound tab, publishes its identity and handles the message there. */
|
|
7566
|
-
|
|
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
|
+
}
|
|
7567
7806
|
deps.threadStore.upsert({
|
|
7568
7807
|
...base,
|
|
7569
7808
|
profileKey,
|
|
@@ -7594,6 +7833,33 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7594
7833
|
slot,
|
|
7595
7834
|
profileKey,
|
|
7596
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
|
+
}
|
|
7597
7863
|
await textDispatch.handleMessage(message, ctx);
|
|
7598
7864
|
};
|
|
7599
7865
|
const records = deps.threadStore.list();
|
|
@@ -7694,7 +7960,10 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7694
7960
|
throw error;
|
|
7695
7961
|
}
|
|
7696
7962
|
if (currentLeaderIsStale) {
|
|
7697
|
-
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);
|
|
7698
7967
|
if (!slot) {
|
|
7699
7968
|
deps.threadStore.markStaleByTarget(currentLeaderRecord.target, "deleted", "Current leader thread is stale during unbound prompt routing.");
|
|
7700
7969
|
await deps.threadStore.persist();
|
|
@@ -7707,7 +7976,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7707
7976
|
await reclaimUnboundTargetForLeader(currentLeaderRecord, leaderProfileKey, slot, threadName, "Bus leader reclaimed stale-current unbound thread", {
|
|
7708
7977
|
phase: "leader-topic-unbound-stale-reclaim",
|
|
7709
7978
|
staleThreadId: currentLeaderRecord.target.threadId,
|
|
7710
|
-
});
|
|
7979
|
+
}, leaderBinding?.slot === slot ? leaderBinding : undefined);
|
|
7711
7980
|
return;
|
|
7712
7981
|
}
|
|
7713
7982
|
}
|
|
@@ -7717,7 +7986,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7717
7986
|
!hasAnyRoutableThread) {
|
|
7718
7987
|
const priorLeaderRecord = deps.threadStore.getByProfileKey(leaderProfileKey);
|
|
7719
7988
|
const priorLeaderIdentity = deps.threadStore.getIdentityByProfileKey(leaderProfileKey);
|
|
7720
|
-
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);
|
|
7721
7995
|
if (!slot) {
|
|
7722
7996
|
await deps.sendTextReply(target.chatId, message.message_id, TELEGRAM_SLOT_CAPACITY_MESSAGE, { target });
|
|
7723
7997
|
return;
|
|
@@ -7730,7 +8004,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7730
8004
|
identityThreadName ??
|
|
7731
8005
|
ThreadNaming.chooseTelegramThreadName({ slot }) ??
|
|
7732
8006
|
"Pi";
|
|
7733
|
-
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);
|
|
7734
8008
|
return;
|
|
7735
8009
|
}
|
|
7736
8010
|
await sendUnboundRerouteChooser(message, ctx);
|
|
@@ -7757,6 +8031,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7757
8031
|
onUpdateCompleted,
|
|
7758
8032
|
prepareHeldCommand: commandHandler.prepareHeldCommand,
|
|
7759
8033
|
canPrepareHeldCommand: commandHandler.canPrepareHeldCommand,
|
|
8034
|
+
onSessionSettled,
|
|
7760
8035
|
observeLiveRebindLeaderWork,
|
|
7761
8036
|
prepareLiveRebindLeaderCleanup,
|
|
7762
8037
|
prepareLiveRebindFollowerCleanup,
|