@llblab/pi-telegram 0.55.0 → 0.56.0
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/AGENTS.md +3 -2
- package/BACKLOG.md +4 -1
- package/CHANGELOG.md +14 -0
- package/README.md +27 -18
- package/dist/lib/bindings.d.ts +8 -4
- package/dist/lib/bindings.js +41 -1
- package/dist/lib/bus-follower.d.ts +1 -0
- package/dist/lib/bus-follower.js +5 -1
- package/dist/lib/bus-leader.js +9 -1
- package/dist/lib/bus.d.ts +5 -0
- package/dist/lib/bus.js +17 -6
- package/dist/lib/extension.js +14 -2
- package/dist/lib/lifecycle.d.ts +2 -1
- package/dist/lib/lifecycle.js +28 -20
- package/dist/lib/menu-queue.d.ts +1 -0
- package/dist/lib/menu-queue.js +102 -99
- package/dist/lib/pi.d.ts +3 -2
- package/dist/lib/pi.js +3 -0
- package/dist/lib/polling.js +3 -1
- package/dist/lib/queue.d.ts +57 -5
- package/dist/lib/queue.js +313 -25
- package/dist/lib/routing.d.ts +1 -1
- package/dist/lib/routing.js +261 -29
- package/dist/lib/status.d.ts +1 -2
- package/dist/lib/status.js +6 -7
- package/dist/lib/turns.d.ts +4 -0
- package/dist/lib/turns.js +6 -0
- package/dist/lib/updates.d.ts +10 -6
- package/dist/lib/updates.js +20 -4
- package/dist/package.json +1 -1
- package/docs/README.md +1 -1
- package/docs/architecture.md +17 -14
- package/docs/callback-namespaces.md +4 -0
- package/docs/multi-instance-bus.md +4 -2
- package/docs/ui-style.md +14 -10
- package/lib/bindings.ts +52 -3
- package/lib/bus-follower.ts +5 -0
- package/lib/bus-leader.ts +9 -0
- package/lib/bus.ts +20 -3
- package/lib/extension.ts +14 -1
- package/lib/lifecycle.ts +34 -28
- package/lib/menu-queue.ts +116 -162
- package/lib/pi.ts +8 -0
- package/lib/polling.ts +3 -1
- package/lib/queue.ts +328 -33
- package/lib/routing.ts +299 -29
- package/lib/status.ts +7 -7
- package/lib/turns.ts +7 -0
- package/lib/updates.ts +24 -10
- package/package.json +1 -1
package/lib/routing.ts
CHANGED
|
@@ -1558,7 +1558,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1558
1558
|
requiredJournalBindingKeys: readonly string[],
|
|
1559
1559
|
ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
|
|
1560
1560
|
) => readonly number[] | undefined;
|
|
1561
|
-
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to
|
|
1561
|
+
/** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
|
|
1562
1562
|
temporaryThreadCleanupDelayMs?: number;
|
|
1563
1563
|
/** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
|
|
1564
1564
|
liveRebindCleanupSchedule?: {
|
|
@@ -1943,10 +1943,21 @@ export function createTelegramInboundRouteRuntime<
|
|
|
1943
1943
|
temporaryThread?: Threads.TelegramTemporaryThreadEntry;
|
|
1944
1944
|
/** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
|
|
1945
1945
|
allTabCopies?: number[];
|
|
1946
|
-
/**
|
|
1947
|
-
|
|
1946
|
+
/** This live opening attempt did not remove its All originals; replay/revival cannot reconstruct that fact. */
|
|
1947
|
+
allTabSourceRetained?: true;
|
|
1948
|
+
/** Complete warm copy identity; journal originals stay unchanged even after their All messages leave. */
|
|
1949
|
+
allTabCopiesForwarded?: {
|
|
1950
|
+
target: { chatId: number; threadId: number };
|
|
1951
|
+
sourceMessageIds: readonly number[];
|
|
1952
|
+
messageIds: readonly number[];
|
|
1953
|
+
};
|
|
1948
1954
|
/** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
|
|
1949
1955
|
temporaryMembership?: true;
|
|
1956
|
+
/** Confirmed display copies only; the durable Forward fact remains the native one-shot authority. */
|
|
1957
|
+
rerouteCopies?: {
|
|
1958
|
+
target: { chatId: number; threadId: number };
|
|
1959
|
+
bySourceUpdateId: ReadonlyMap<number, number>;
|
|
1960
|
+
};
|
|
1950
1961
|
/** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
|
|
1951
1962
|
dispatching?: boolean;
|
|
1952
1963
|
abandonment?: {
|
|
@@ -2415,15 +2426,22 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2415
2426
|
const cloneTelegramMessagesForThread = (
|
|
2416
2427
|
messages: TMessage[],
|
|
2417
2428
|
threadId: number,
|
|
2429
|
+
copies?: ReadonlyMap<number, number>,
|
|
2418
2430
|
): TMessage[] => {
|
|
2419
|
-
return messages.map((message) =>
|
|
2420
|
-
Updates.
|
|
2431
|
+
return messages.map((message) => {
|
|
2432
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
2433
|
+
const copyId = copies && ids.length === 1 ? copies.get(ids[0]!) : undefined;
|
|
2434
|
+
if (copies && (!Number.isSafeInteger(copyId) || copyId! <= 0))
|
|
2435
|
+
throw new Error("Reroute destination copy identity is unavailable.");
|
|
2436
|
+
const routed = {
|
|
2421
2437
|
...message,
|
|
2422
|
-
message_id: 0,
|
|
2438
|
+
message_id: copyId ?? 0,
|
|
2423
2439
|
message_thread_id: threadId,
|
|
2424
2440
|
reply_to_message: undefined,
|
|
2425
|
-
} as TMessage
|
|
2426
|
-
|
|
2441
|
+
} as TMessage;
|
|
2442
|
+
return Updates.carryTelegramUpdateExecutionFence(message,
|
|
2443
|
+
copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
|
|
2444
|
+
});
|
|
2427
2445
|
};
|
|
2428
2446
|
/** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
|
|
2429
2447
|
const hasPendingRerouteForTarget = (
|
|
@@ -2819,9 +2837,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2819
2837
|
const deleteAllTabMessages = async (
|
|
2820
2838
|
chatId: number,
|
|
2821
2839
|
messageIds: readonly number[],
|
|
2840
|
+
isCurrent?: () => boolean,
|
|
2822
2841
|
): Promise<void> => {
|
|
2823
2842
|
if (!deps.deleteMessage) return;
|
|
2824
2843
|
for (const messageId of messageIds) {
|
|
2844
|
+
if (isCurrent?.() === false) return;
|
|
2825
2845
|
try {
|
|
2826
2846
|
await deps.deleteMessage(chatId, messageId);
|
|
2827
2847
|
} catch (error) {
|
|
@@ -2834,10 +2854,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2834
2854
|
}
|
|
2835
2855
|
};
|
|
2836
2856
|
/** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
|
|
2837
|
-
const deleteAllTabCopies = (
|
|
2857
|
+
const deleteAllTabCopies = (
|
|
2858
|
+
pending: PendingUnboundReroute,
|
|
2859
|
+
isCurrent?: () => boolean,
|
|
2860
|
+
): Promise<void> =>
|
|
2838
2861
|
deleteAllTabMessages(
|
|
2839
2862
|
pending.sourceTarget.chatId,
|
|
2840
2863
|
pending.allTabCopies?.splice(0) ?? [],
|
|
2864
|
+
isCurrent,
|
|
2841
2865
|
);
|
|
2842
2866
|
/**
|
|
2843
2867
|
* Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
|
|
@@ -2846,7 +2870,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2846
2870
|
const releaseAllTabCopiesIntoSurvivingTab = (
|
|
2847
2871
|
pending: PendingUnboundReroute,
|
|
2848
2872
|
): Promise<void> => {
|
|
2849
|
-
if (
|
|
2873
|
+
if (
|
|
2874
|
+
pending.allTabCopiesForwarded &&
|
|
2875
|
+
isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget)
|
|
2876
|
+
)
|
|
2877
|
+
return deleteAllTabCopies(pending);
|
|
2850
2878
|
pending.allTabCopies?.splice(0);
|
|
2851
2879
|
return Promise.resolve();
|
|
2852
2880
|
};
|
|
@@ -2862,26 +2890,38 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2862
2890
|
): Promise<number | undefined> => {
|
|
2863
2891
|
const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
|
|
2864
2892
|
if (messageIds.length === 0 || !deps.callApi || !isCurrent()) return;
|
|
2893
|
+
const copyTarget = { ...target };
|
|
2865
2894
|
const route = {
|
|
2866
|
-
chat_id:
|
|
2867
|
-
message_thread_id:
|
|
2895
|
+
chat_id: copyTarget.chatId,
|
|
2896
|
+
message_thread_id: copyTarget.threadId,
|
|
2868
2897
|
from_chat_id: pending.sourceTarget.chatId,
|
|
2869
2898
|
disable_notification: true,
|
|
2870
2899
|
};
|
|
2900
|
+
const options = {
|
|
2901
|
+
maxAttempts: 1,
|
|
2902
|
+
retrySafety: "non-idempotent" as const,
|
|
2903
|
+
assertAuthority() {
|
|
2904
|
+
if (!isCurrent())
|
|
2905
|
+
throw new Error("Temporary Thread source forwarding authority changed.");
|
|
2906
|
+
},
|
|
2907
|
+
};
|
|
2871
2908
|
try {
|
|
2872
2909
|
// An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
|
|
2873
2910
|
const copies =
|
|
2874
2911
|
messageIds.length === 1
|
|
2875
2912
|
? [
|
|
2876
|
-
await deps.callApi<{ message_id?: unknown }>(
|
|
2877
|
-
|
|
2878
|
-
message_id: messageIds[0],
|
|
2879
|
-
|
|
2913
|
+
await deps.callApi<{ message_id?: unknown }>(
|
|
2914
|
+
"forwardMessage",
|
|
2915
|
+
{ ...route, message_id: messageIds[0] },
|
|
2916
|
+
options,
|
|
2917
|
+
),
|
|
2880
2918
|
]
|
|
2881
2919
|
: await deps.callApi<Array<{ message_id?: unknown }>>(
|
|
2882
2920
|
"forwardMessages",
|
|
2883
2921
|
{ ...route, message_ids: messageIds },
|
|
2922
|
+
options,
|
|
2884
2923
|
);
|
|
2924
|
+
if (!isCurrent()) return;
|
|
2885
2925
|
const copyIds = Array.isArray(copies)
|
|
2886
2926
|
? copies.map((copy) => copy?.message_id)
|
|
2887
2927
|
: [];
|
|
@@ -2889,20 +2929,88 @@ export function createTelegramInboundRouteRuntime<
|
|
|
2889
2929
|
copyIds.length !== messageIds.length ||
|
|
2890
2930
|
!copyIds.every(
|
|
2891
2931
|
(id): id is number =>
|
|
2892
|
-
typeof id === "number" && Number.isSafeInteger(id),
|
|
2893
|
-
)
|
|
2932
|
+
typeof id === "number" && Number.isSafeInteger(id) && id > 0,
|
|
2933
|
+
) ||
|
|
2934
|
+
new Set(copyIds).size !== copyIds.length
|
|
2894
2935
|
)
|
|
2895
2936
|
return;
|
|
2896
|
-
pending.allTabCopiesForwarded =
|
|
2897
|
-
|
|
2898
|
-
|
|
2937
|
+
pending.allTabCopiesForwarded = {
|
|
2938
|
+
target: copyTarget,
|
|
2939
|
+
sourceMessageIds: messageIds,
|
|
2940
|
+
messageIds: copyIds,
|
|
2941
|
+
};
|
|
2942
|
+
await deleteAllTabCopies(pending, isCurrent);
|
|
2943
|
+
if (!isCurrent()) return;
|
|
2944
|
+
return pending.allTabCopiesForwarded.messageIds[0];
|
|
2899
2945
|
} catch (error) {
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2946
|
+
if (isCurrent())
|
|
2947
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
2948
|
+
phase: "temporary-thread-source-forward",
|
|
2949
|
+
});
|
|
2903
2950
|
return;
|
|
2904
2951
|
}
|
|
2905
2952
|
};
|
|
2953
|
+
/** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
|
|
2954
|
+
const resolvePendingRerouteForwardSource = (
|
|
2955
|
+
pending: PendingUnboundReroute,
|
|
2956
|
+
): { chatId: number; sourceUpdateIds: number[]; sourceMessageIds: number[]; messageIds: number[] } | undefined => {
|
|
2957
|
+
try {
|
|
2958
|
+
const originals = pending.messages.map((message) => {
|
|
2959
|
+
const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
|
|
2960
|
+
const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
|
|
2961
|
+
const original = snapshot?.update.message as TMessage | undefined;
|
|
2962
|
+
if (
|
|
2963
|
+
!snapshot ||
|
|
2964
|
+
ids.length !== 1 ||
|
|
2965
|
+
snapshot.source.updateId !== ids[0] ||
|
|
2966
|
+
snapshot.update.update_id !== ids[0] ||
|
|
2967
|
+
Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
|
|
2968
|
+
snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
|
|
2969
|
+
!original ||
|
|
2970
|
+
original.chat?.type !== "private" ||
|
|
2971
|
+
original.chat.id !== pending.sourceTarget.chatId ||
|
|
2972
|
+
original.from?.id !== deps.configStore.getAllowedUserId() ||
|
|
2973
|
+
original.from?.is_bot !== false ||
|
|
2974
|
+
!Number.isSafeInteger(original.message_id) ||
|
|
2975
|
+
original.message_id <= 0
|
|
2976
|
+
)
|
|
2977
|
+
return undefined;
|
|
2978
|
+
return { message: original, updateId: ids[0]! };
|
|
2979
|
+
});
|
|
2980
|
+
if (originals.length === 0 || originals.some((source) => !source)) return;
|
|
2981
|
+
originals.sort((a, b) => a!.message.message_id - b!.message.message_id);
|
|
2982
|
+
const sourceMessageIds = originals.map((source) => source!.message.message_id);
|
|
2983
|
+
const sourceUpdateIds = originals.map((source) => source!.updateId);
|
|
2984
|
+
if (
|
|
2985
|
+
new Set(sourceMessageIds).size !== sourceMessageIds.length ||
|
|
2986
|
+
new Set(sourceUpdateIds).size !== sourceUpdateIds.length
|
|
2987
|
+
)
|
|
2988
|
+
return;
|
|
2989
|
+
const copied = pending.allTabCopiesForwarded;
|
|
2990
|
+
if (copied) {
|
|
2991
|
+
if (
|
|
2992
|
+
!isDeepStrictEqual(copied.target, pending.sourceTarget) ||
|
|
2993
|
+
!isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
|
|
2994
|
+
copied.messageIds.length !== sourceMessageIds.length ||
|
|
2995
|
+
!copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
|
|
2996
|
+
new Set(copied.messageIds).size !== copied.messageIds.length
|
|
2997
|
+
)
|
|
2998
|
+
return;
|
|
2999
|
+
return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
|
|
3000
|
+
}
|
|
3001
|
+
const nativeOriginals = originals.every((source) =>
|
|
3002
|
+
typeof pending.sourceTarget.threadId === "number" &&
|
|
3003
|
+
source!.message.message_thread_id === pending.sourceTarget.threadId);
|
|
3004
|
+
const retainedAllOriginals = pending.allTabSourceRetained === true &&
|
|
3005
|
+
originals.every((source) => source!.message.message_thread_id === undefined) &&
|
|
3006
|
+
isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
|
|
3007
|
+
if (nativeOriginals || retainedAllOriginals)
|
|
3008
|
+
return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
|
|
3009
|
+
} catch (error) {
|
|
3010
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
|
|
3011
|
+
}
|
|
3012
|
+
return undefined;
|
|
3013
|
+
};
|
|
2906
3014
|
const dismissRerouteChooserMessage = async (
|
|
2907
3015
|
query: TCallbackQuery,
|
|
2908
3016
|
assertExecutionCurrent?: () => void,
|
|
@@ -6172,7 +6280,21 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6172
6280
|
for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
|
|
6173
6281
|
};
|
|
6174
6282
|
const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
|
|
6175
|
-
const onDeliverySettled = (ctx: TContext): void =>
|
|
6283
|
+
const onDeliverySettled = (ctx: TContext): void => {
|
|
6284
|
+
wakeLiveRebindCleanup(ctx, "delivery-drained");
|
|
6285
|
+
const cap = captureTemporaryThreadAuthority(ctx);
|
|
6286
|
+
if (!cap?.isCurrent()) return;
|
|
6287
|
+
try {
|
|
6288
|
+
for (const entry of cap.store.listTemporaryThreads()) {
|
|
6289
|
+
if (!cap.isCurrent()) return;
|
|
6290
|
+
if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
|
|
6291
|
+
Threads.isTelegramTemporaryThreadFullyResolved(entry))
|
|
6292
|
+
scheduleTemporaryThreadCleanup(entry.target, ctx);
|
|
6293
|
+
}
|
|
6294
|
+
} catch (error) {
|
|
6295
|
+
deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
|
|
6296
|
+
}
|
|
6297
|
+
};
|
|
6176
6298
|
const restoreWorkspace = deps.workspaceRestoreRecipient
|
|
6177
6299
|
? async (input: {
|
|
6178
6300
|
operationId: string;
|
|
@@ -6802,6 +6924,97 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6802
6924
|
return false;
|
|
6803
6925
|
}
|
|
6804
6926
|
};
|
|
6927
|
+
/** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
|
|
6928
|
+
const publishPendingRerouteCopies = async (
|
|
6929
|
+
pending: PendingUnboundReroute,
|
|
6930
|
+
record: Threads.TelegramTopicTargetRecord,
|
|
6931
|
+
ctx: TContext,
|
|
6932
|
+
assertExecutionCurrent: () => void,
|
|
6933
|
+
foreign: boolean,
|
|
6934
|
+
): Promise<(() => void) | undefined> => {
|
|
6935
|
+
const source = resolvePendingRerouteForwardSource(pending);
|
|
6936
|
+
const target = { ...record.target };
|
|
6937
|
+
const recordIdentity = (value: Threads.TelegramTopicTargetRecord | undefined) => value && ({
|
|
6938
|
+
target: value.target, profileKey: value.profileKey, owner: value.owner,
|
|
6939
|
+
instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
|
|
6940
|
+
});
|
|
6941
|
+
const bindingIdentity = () => deps.threadStore!.listWorkspaceBindings()
|
|
6942
|
+
.filter((value) => isDeepStrictEqual(value.target, target))
|
|
6943
|
+
.map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
|
|
6944
|
+
cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
|
|
6945
|
+
target: value.target }));
|
|
6946
|
+
const expectedRecord = structuredClone(recordIdentity(record));
|
|
6947
|
+
const expectedBindings = structuredClone(bindingIdentity());
|
|
6948
|
+
const ownership = deps.getTargetOwnership?.(target);
|
|
6949
|
+
if (
|
|
6950
|
+
!source || !deps.callApi || expectedBindings.length > 1 ||
|
|
6951
|
+
(foreign && (!ownership || ownership.instanceId !== record.instanceId ||
|
|
6952
|
+
!ownership.ownerGeneration || !ownership.recipientBindingKey))
|
|
6953
|
+
)
|
|
6954
|
+
return;
|
|
6955
|
+
const expectedOwnership = ownership && structuredClone(ownership);
|
|
6956
|
+
const cap = captureTemporaryThreadAuthority(ctx, () => {
|
|
6957
|
+
try {
|
|
6958
|
+
assertExecutionCurrent();
|
|
6959
|
+
const current = getTelegramRoutableThreadRecords(deps.threadStore!.list(), deps.getLiveThreadTargets?.())
|
|
6960
|
+
.find((value) => isDeepStrictEqual(value.target, target));
|
|
6961
|
+
return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
|
|
6962
|
+
isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
|
|
6963
|
+
(!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership!, deps.getTargetOwnership?.(target)));
|
|
6964
|
+
} catch { return false; }
|
|
6965
|
+
});
|
|
6966
|
+
if (!cap?.isCurrent()) return;
|
|
6967
|
+
const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
|
|
6968
|
+
if (!temporary || !issueTemporaryThreadForward(pending, ctx)) return;
|
|
6969
|
+
pending.phase = { kind: "forward-unknown" };
|
|
6970
|
+
const assertRecipientCurrent = (): void => {
|
|
6971
|
+
assertExecutionCurrent();
|
|
6972
|
+
if (!cap.isCurrent()) throw new Error("Reroute publication recipient authority changed.");
|
|
6973
|
+
};
|
|
6974
|
+
const assertPublicationCurrent = (): void => {
|
|
6975
|
+
assertRecipientCurrent();
|
|
6976
|
+
const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
|
|
6977
|
+
const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
|
|
6978
|
+
if (
|
|
6979
|
+
!entry || entry.phase !== "created" || entry.cleanupIssued ||
|
|
6980
|
+
entry.operatorUserId !== cap.operatorUserId ||
|
|
6981
|
+
!isDeepStrictEqual(entry.executor, cap.authority.executor) ||
|
|
6982
|
+
!isDeepStrictEqual(entry.target, pending.sourceTarget) ||
|
|
6983
|
+
!entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
|
|
6984
|
+
isDeepStrictEqual(input.updateIds, ids)) ||
|
|
6985
|
+
!isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source)
|
|
6986
|
+
)
|
|
6987
|
+
throw new Error("Reroute publication source authority changed.");
|
|
6988
|
+
};
|
|
6989
|
+
try {
|
|
6990
|
+
assertPublicationCurrent();
|
|
6991
|
+
// Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
|
|
6992
|
+
const order = source.messageIds.map((_, index) => index)
|
|
6993
|
+
.sort((a, b) => source.messageIds[a]! - source.messageIds[b]!);
|
|
6994
|
+
const messageIds = order.map((index) => source.messageIds[index]!);
|
|
6995
|
+
const body = { chat_id: target.chatId, message_thread_id: target.threadId,
|
|
6996
|
+
from_chat_id: source.chatId, disable_notification: true };
|
|
6997
|
+
const options = { maxAttempts: 1, retrySafety: "non-idempotent" as const, assertAuthority: assertPublicationCurrent };
|
|
6998
|
+
const copies = messageIds.length === 1
|
|
6999
|
+
? [await deps.callApi<{ message_id?: unknown }>("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
|
|
7000
|
+
: await deps.callApi<Array<{ message_id?: unknown }>>("forwardMessages", { ...body, message_ids: messageIds }, options);
|
|
7001
|
+
assertPublicationCurrent();
|
|
7002
|
+
const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
|
|
7003
|
+
if (
|
|
7004
|
+
copyIds.length !== messageIds.length ||
|
|
7005
|
+
!copyIds.every((id): id is number => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
|
|
7006
|
+
new Set(copyIds).size !== copyIds.length
|
|
7007
|
+
)
|
|
7008
|
+
return;
|
|
7009
|
+
pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) =>
|
|
7010
|
+
[source.sourceUpdateIds[index]!, copyIds[position]!] as const)) };
|
|
7011
|
+
pending.phase = { kind: "selected" };
|
|
7012
|
+
return assertRecipientCurrent;
|
|
7013
|
+
} catch (error) {
|
|
7014
|
+
if (cap.isCurrent()) deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
|
|
7015
|
+
return;
|
|
7016
|
+
}
|
|
7017
|
+
};
|
|
6805
7018
|
const forwardPendingRerouteMessages = async (
|
|
6806
7019
|
pending: PendingUnboundReroute,
|
|
6807
7020
|
instanceId: string,
|
|
@@ -6827,12 +7040,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6827
7040
|
)
|
|
6828
7041
|
return false;
|
|
6829
7042
|
const ownership = structuredClone(liveOwnership);
|
|
6830
|
-
const
|
|
7043
|
+
const copies = pending.rerouteCopies;
|
|
7044
|
+
const published = !!copies && isDeepStrictEqual(copies.target, target);
|
|
7045
|
+
const messages = cloneTelegramMessagesForThread(
|
|
7046
|
+
pending.messages,
|
|
7047
|
+
threadId,
|
|
7048
|
+
published ? copies.bySourceUpdateId : undefined,
|
|
7049
|
+
);
|
|
6831
7050
|
// Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
|
|
6832
7051
|
// even after restart. Any refusal or publication failure sends nothing and keeps the input held.
|
|
6833
7052
|
if (
|
|
6834
7053
|
!recordForwardAcceptance &&
|
|
6835
7054
|
isTemporaryReroute(pending) &&
|
|
7055
|
+
!published &&
|
|
6836
7056
|
!issueTemporaryThreadForward(pending, ctx)
|
|
6837
7057
|
) {
|
|
6838
7058
|
if (isTemporaryThreadForwardIssued(pending))
|
|
@@ -6847,6 +7067,10 @@ export function createTelegramInboundRouteRuntime<
|
|
|
6847
7067
|
message,
|
|
6848
7068
|
ownership,
|
|
6849
7069
|
ctx,
|
|
7070
|
+
assertAuthority() {
|
|
7071
|
+
assertExecutionCurrent?.();
|
|
7072
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
7073
|
+
},
|
|
6850
7074
|
}),
|
|
6851
7075
|
),
|
|
6852
7076
|
);
|
|
@@ -7073,6 +7297,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7073
7297
|
temporaryCleanupTimers.delete(token);
|
|
7074
7298
|
if (retry) restoreSettlementTasks.add(task);
|
|
7075
7299
|
runTemporaryThreadCleanup(token, cap, reconcileExpiry)
|
|
7300
|
+
.then(() => {
|
|
7301
|
+
if (!cap.isCurrent()) return;
|
|
7302
|
+
const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
|
|
7303
|
+
// A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
|
|
7304
|
+
// elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
|
|
7305
|
+
if (retained?.phase === "created" && !retained.cleanupIssued &&
|
|
7306
|
+
Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
|
|
7307
|
+
cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
|
|
7308
|
+
queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
|
|
7309
|
+
})
|
|
7076
7310
|
.catch((error) => {
|
|
7077
7311
|
deps.recordRuntimeEvent?.("routing", error, {
|
|
7078
7312
|
phase: "temporary-thread-cleanup",
|
|
@@ -7799,7 +8033,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
7799
8033
|
).includes(source.original.updateId)
|
|
7800
8034
|
)
|
|
7801
8035
|
continue;
|
|
7802
|
-
movedIntoTab ||= pending.allTabCopiesForwarded
|
|
8036
|
+
movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
|
|
7803
8037
|
removePendingReroute(id);
|
|
7804
8038
|
if (
|
|
7805
8039
|
signal.aborted ||
|
|
@@ -8901,6 +9135,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
8901
9135
|
);
|
|
8902
9136
|
return true;
|
|
8903
9137
|
}
|
|
9138
|
+
if (
|
|
9139
|
+
!parsed.useNewSlot &&
|
|
9140
|
+
isTemporaryReroute(pending) &&
|
|
9141
|
+
!resolvePendingRerouteForwardSource(pending)
|
|
9142
|
+
) {
|
|
9143
|
+
await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
|
|
9144
|
+
return true;
|
|
9145
|
+
}
|
|
8904
9146
|
if (!(await selectPendingDestination())) return true;
|
|
8905
9147
|
if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
|
|
8906
9148
|
const selection = (pending.workspaceRestore ??= {
|
|
@@ -9006,6 +9248,19 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9006
9248
|
);
|
|
9007
9249
|
return true;
|
|
9008
9250
|
}
|
|
9251
|
+
let assertRerouteCurrent = assertExecutionCurrent;
|
|
9252
|
+
if (isTemporaryReroute(pending)) {
|
|
9253
|
+
const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent,
|
|
9254
|
+
!!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
|
|
9255
|
+
if (!confirmed) {
|
|
9256
|
+
await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
|
|
9257
|
+
? "Publication unconfirmed; not resending"
|
|
9258
|
+
: "Publication unavailable; input kept");
|
|
9259
|
+
return true;
|
|
9260
|
+
}
|
|
9261
|
+
assertRerouteCurrent = confirmed;
|
|
9262
|
+
}
|
|
9263
|
+
assertRerouteCurrent();
|
|
9009
9264
|
if (
|
|
9010
9265
|
record.instanceId &&
|
|
9011
9266
|
record.instanceId !== currentInstanceId &&
|
|
@@ -9016,7 +9271,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9016
9271
|
record.instanceId,
|
|
9017
9272
|
parsed.threadId,
|
|
9018
9273
|
ctx,
|
|
9019
|
-
|
|
9274
|
+
assertRerouteCurrent,
|
|
9020
9275
|
);
|
|
9021
9276
|
if (!allForwarded) {
|
|
9022
9277
|
await deps.answerCallbackQuery(
|
|
@@ -9032,6 +9287,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9032
9287
|
// Local handlers and queue admission consume the same one-time group grant as follower forwarding.
|
|
9033
9288
|
if (
|
|
9034
9289
|
isTemporaryReroute(pending) &&
|
|
9290
|
+
!pending.rerouteCopies &&
|
|
9035
9291
|
!issueTemporaryThreadForward(pending, ctx)
|
|
9036
9292
|
) {
|
|
9037
9293
|
await deps.answerCallbackQuery(
|
|
@@ -9040,7 +9296,11 @@ export function createTelegramInboundRouteRuntime<
|
|
|
9040
9296
|
);
|
|
9041
9297
|
return true;
|
|
9042
9298
|
}
|
|
9043
|
-
|
|
9299
|
+
const localMessages = pending.rerouteCopies
|
|
9300
|
+
? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
|
|
9301
|
+
: reroutedMessages;
|
|
9302
|
+
await dispatchPendingRerouteMessages(pending, localMessages, ctx);
|
|
9303
|
+
assertRerouteCurrent();
|
|
9044
9304
|
pending.messages = [];
|
|
9045
9305
|
return completeRoutedReroute();
|
|
9046
9306
|
};
|
|
@@ -10632,6 +10892,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10632
10892
|
const tabCopyId = openedNow
|
|
10633
10893
|
? await moveAllTabSourceIntoTab(pending, target, current)
|
|
10634
10894
|
: undefined;
|
|
10895
|
+
if (!current()) {
|
|
10896
|
+
removePendingReroute(rerouteId);
|
|
10897
|
+
throw new Error("Temporary Thread source presentation authority changed.");
|
|
10898
|
+
}
|
|
10899
|
+
if (
|
|
10900
|
+
openedNow &&
|
|
10901
|
+
!pending.allTabCopiesForwarded &&
|
|
10902
|
+
pending.allTabCopies.length === held.length
|
|
10903
|
+
)
|
|
10904
|
+
pending.allTabSourceRetained = true;
|
|
10635
10905
|
try {
|
|
10636
10906
|
if (!deps.sendInteractiveMessage)
|
|
10637
10907
|
throw new Error("Temporary Thread chooser publication is unavailable.");
|
package/lib/status.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function formatTelegramConnectionFailure(error: unknown): string {
|
|
|
66
66
|
|
|
67
67
|
const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
|
|
68
68
|
|
|
69
|
-
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
|
69
|
+
export type TelegramStatusQueueLane = "control" | "steer" | "priority" | "default";
|
|
70
70
|
|
|
71
71
|
export interface TelegramUsageStats {
|
|
72
72
|
totalInput: number;
|
|
@@ -441,7 +441,6 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|
|
441
441
|
hasPendingModelSwitch: () => boolean;
|
|
442
442
|
getQueuedItems: () => TQueueItem[];
|
|
443
443
|
getQueuedItemCount?: (items: TQueueItem[]) => number;
|
|
444
|
-
formatQueuedStatus: (items: TQueueItem[]) => string;
|
|
445
444
|
getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
|
|
446
445
|
getRuntimeLockState?: () => string;
|
|
447
446
|
getBusRole?: () => TelegramBridgeBusRole | undefined;
|
|
@@ -1200,9 +1199,7 @@ export function buildTelegramStatusBarText(
|
|
|
1200
1199
|
state: TelegramStatusBarState,
|
|
1201
1200
|
): string {
|
|
1202
1201
|
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
|
1203
|
-
const queued = state.queuedStatus
|
|
1204
|
-
? theme.fg("warning", state.queuedStatus)
|
|
1205
|
-
: "";
|
|
1202
|
+
const queued = state.queuedStatus ? theme.fg("warning", state.queuedStatus) : "";
|
|
1206
1203
|
if (!state.hasBotToken)
|
|
1207
1204
|
return `${label} ${theme.fg("muted", "not configured")}${queued}`;
|
|
1208
1205
|
if (
|
|
@@ -1612,6 +1609,7 @@ function countTelegramQueueLanes(
|
|
|
1612
1609
|
): Record<TelegramStatusQueueLane, number> {
|
|
1613
1610
|
const counts: Record<TelegramStatusQueueLane, number> = {
|
|
1614
1611
|
control: 0,
|
|
1612
|
+
steer: 0,
|
|
1615
1613
|
priority: 0,
|
|
1616
1614
|
default: 0,
|
|
1617
1615
|
};
|
|
@@ -1626,10 +1624,11 @@ function buildTelegramBridgeCompactStatusLines(
|
|
|
1626
1624
|
control: controlQueueCount,
|
|
1627
1625
|
priority: priorityQueueCount,
|
|
1628
1626
|
default: defaultQueueCount,
|
|
1627
|
+
steer: steerQueueCount,
|
|
1629
1628
|
} = countTelegramQueueLanes(state.queuedItems);
|
|
1630
1629
|
const queueLine = `- queued turns: ${state.queuedItems.length}${
|
|
1631
1630
|
state.queuedItems.length > 0
|
|
1632
|
-
? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
|
|
1631
|
+
? ` (control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
|
|
1633
1632
|
: ""
|
|
1634
1633
|
}`;
|
|
1635
1634
|
const executionState = state.pendingDispatch
|
|
@@ -1698,6 +1697,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
|
|
|
1698
1697
|
control: controlQueueCount,
|
|
1699
1698
|
priority: priorityQueueCount,
|
|
1700
1699
|
default: defaultQueueCount,
|
|
1700
|
+
steer: steerQueueCount,
|
|
1701
1701
|
} = countTelegramQueueLanes(state.queuedItems);
|
|
1702
1702
|
return [
|
|
1703
1703
|
"connection:",
|
|
@@ -1744,7 +1744,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
|
|
|
1744
1744
|
"",
|
|
1745
1745
|
"queue:",
|
|
1746
1746
|
`- queued turns: ${state.queuedItems.length}`,
|
|
1747
|
-
`- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
|
|
1747
|
+
`- lanes: control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
|
|
1748
1748
|
...buildTelegramBusFollowerLines(state),
|
|
1749
1749
|
...buildTelegramLocalBusLines(state, { verbose: true }),
|
|
1750
1750
|
...buildTelegramTopicTargetDiagnosticLines(state),
|
package/lib/turns.ts
CHANGED
|
@@ -51,6 +51,13 @@ export function withoutTelegramTurnReplyAnchor<TMessage extends object>(message:
|
|
|
51
51
|
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
|
|
55
|
+
export function withTelegramTurnReplyAnchor<TMessage extends { message_id: number }>(message: TMessage): TMessage {
|
|
56
|
+
if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
|
|
57
|
+
throw new Error("Telegram reply anchor requires a positive message ID.");
|
|
58
|
+
return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
export const TELEGRAM_GUEST_TURN_NOTE =
|
|
55
62
|
"[guest] delivery: answer quickly with one concise, self-contained reply";
|
|
56
63
|
|