@llblab/pi-telegram 0.50.0 → 0.51.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 +9 -7
- package/BACKLOG.md +13 -32
- package/CHANGELOG.md +18 -0
- package/README.md +5 -5
- package/dist/lib/bindings.d.ts +3 -5
- package/dist/lib/bindings.js +4 -11
- package/dist/lib/bus-follower.d.ts +1 -1
- package/dist/lib/bus-follower.js +93 -9
- package/dist/lib/bus-leader.d.ts +9 -3
- package/dist/lib/bus-leader.js +231 -71
- package/dist/lib/bus.d.ts +4 -0
- package/dist/lib/bus.js +34 -7
- package/dist/lib/commands.d.ts +1 -4
- package/dist/lib/commands.js +13 -33
- package/dist/lib/extension.js +64 -6
- package/dist/lib/journal.d.ts +3 -0
- package/dist/lib/journal.js +13 -0
- package/dist/lib/lifecycle.d.ts +2 -0
- package/dist/lib/lifecycle.js +25 -4
- package/dist/lib/locks.d.ts +1 -0
- package/dist/lib/locks.js +32 -12
- package/dist/lib/menu-queue.d.ts +1 -0
- package/dist/lib/menu-queue.js +72 -1
- package/dist/lib/paths.d.ts +10 -0
- package/dist/lib/paths.js +22 -1
- package/dist/lib/polling.js +2 -2
- package/dist/lib/queue.d.ts +8 -1
- package/dist/lib/queue.js +28 -2
- package/dist/lib/replies.d.ts +3 -0
- package/dist/lib/replies.js +17 -0
- package/dist/lib/sync.d.ts +9 -0
- package/dist/lib/sync.js +49 -2
- package/dist/lib/telegram-api.d.ts +9 -1
- package/dist/lib/telegram-api.js +23 -3
- package/dist/lib/thread-cleanup-manager.js +7 -2
- package/dist/lib/threads.d.ts +2 -0
- package/dist/lib/threads.js +68 -37
- package/dist/lib/updates.d.ts +6 -4
- package/dist/lib/updates.js +134 -61
- package/dist/lib/workspace-admission.d.ts +9 -0
- package/dist/lib/workspace-admission.js +49 -3
- package/dist/lib/workspace-retirement.d.ts +77 -6
- package/dist/lib/workspace-retirement.js +384 -8
- package/dist/lib/workspace-slots.d.ts +3 -0
- package/dist/lib/workspace-slots.js +6 -0
- package/dist/package.json +1 -1
- package/docs/architecture.md +23 -14
- package/docs/multi-instance-bus.md +32 -11
- package/docs/public-api.md +1 -1
- package/docs/updates.md +2 -0
- package/lib/bindings.ts +5 -17
- package/lib/bus-follower.ts +69 -12
- package/lib/bus-leader.ts +236 -94
- package/lib/bus.ts +39 -9
- package/lib/commands.ts +18 -41
- package/lib/extension.ts +70 -10
- package/lib/journal.ts +14 -0
- package/lib/lifecycle.ts +23 -4
- package/lib/locks.ts +32 -13
- package/lib/menu-queue.ts +126 -1
- package/lib/paths.ts +29 -1
- package/lib/polling.ts +2 -2
- package/lib/queue.ts +48 -3
- package/lib/replies.ts +19 -0
- package/lib/sync.ts +53 -2
- package/lib/telegram-api.ts +30 -2
- package/lib/thread-cleanup-manager.ts +11 -3
- package/lib/threads.ts +73 -36
- package/lib/updates.ts +145 -78
- package/lib/workspace-admission.ts +67 -4
- package/lib/workspace-retirement.ts +465 -7
- package/lib/workspace-slots.ts +7 -0
- package/package.json +1 -1
package/lib/paths.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { join, resolve } from "node:path";
|
|
12
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
13
13
|
|
|
14
14
|
export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
|
|
15
15
|
|
|
@@ -43,6 +43,20 @@ export function resolveAgentDir(
|
|
|
43
43
|
return join(homedir(), ".pi", "agent");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Pure reference preflight against an independently approved resource path.
|
|
48
|
+
* Reject aliases; never repair relative historical references or redirect storage.
|
|
49
|
+
* Equality proves spelling only, not file identity, consumer closure or migration readiness.
|
|
50
|
+
*/
|
|
51
|
+
export function requireTelegramStoragePathReference(path: string, expectedPath: string): string {
|
|
52
|
+
if (typeof path !== "string" || typeof expectedPath !== "string" ||
|
|
53
|
+
!isAbsolute(path) || resolve(path) !== path ||
|
|
54
|
+
!isAbsolute(expectedPath) || resolve(expectedPath) !== expectedPath || path !== expectedPath) {
|
|
55
|
+
throw new Error("Telegram storage reference does not match its approved absolute path.");
|
|
56
|
+
}
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
/** Telegram bridge configuration file (<agentDir>/telegram.json). */
|
|
47
61
|
export function resolveTelegramConfigPath(): string {
|
|
48
62
|
return join(resolveAgentDir(), "telegram.json");
|
|
@@ -100,6 +114,13 @@ export function resolveTelegramWorkspaceAdmissionPath(
|
|
|
100
114
|
);
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
/** Profile-only callback shape; binds storage to the configured agent directory. */
|
|
118
|
+
export function resolveTelegramWorkspaceAdmissionPathForProfile(
|
|
119
|
+
profileName?: string,
|
|
120
|
+
): string {
|
|
121
|
+
return resolveTelegramWorkspaceAdmissionPath(resolveAgentDir(), profileName);
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
/** Durable inactive Thread cleanup work-set journal. */
|
|
104
125
|
export function resolveTelegramThreadCleanupWorkPath(
|
|
105
126
|
agentDir = resolveAgentDir(),
|
|
@@ -129,6 +150,13 @@ export function resolveTelegramUpdateJournalPath(
|
|
|
129
150
|
);
|
|
130
151
|
}
|
|
131
152
|
|
|
153
|
+
/** Profile-only callback shape; binds storage to the configured agent directory. */
|
|
154
|
+
export function resolveTelegramUpdateJournalPathForProfile(
|
|
155
|
+
profileName?: string,
|
|
156
|
+
): string {
|
|
157
|
+
return resolveTelegramUpdateJournalPath(resolveAgentDir(), profileName);
|
|
158
|
+
}
|
|
159
|
+
|
|
132
160
|
/** Durable follower delivery journal, isolated by stable recipient binding. */
|
|
133
161
|
export function resolveTelegramFollowerJournalPath(
|
|
134
162
|
recipientBindingKey: string,
|
package/lib/polling.ts
CHANGED
|
@@ -28,8 +28,8 @@ const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
|
|
|
28
28
|
const TELEGRAM_POLLING_RETRY_MS = 3_000;
|
|
29
29
|
export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
|
|
30
30
|
|
|
31
|
-
// Standard Telegram DM polling does not expose ordinary message-deletion events
|
|
32
|
-
//
|
|
31
|
+
// Standard Telegram DM polling does not expose ordinary message-deletion events.
|
|
32
|
+
// Business deletions belong to a separate namespace and default routing ignores them.
|
|
33
33
|
export const TELEGRAM_ALLOWED_UPDATES = [
|
|
34
34
|
"message",
|
|
35
35
|
"edited_message",
|
package/lib/queue.ts
CHANGED
|
@@ -922,6 +922,28 @@ export function removeTelegramQueueItemsByMessageIds<TContext = unknown>(
|
|
|
922
922
|
};
|
|
923
923
|
}
|
|
924
924
|
|
|
925
|
+
export function removeTelegramQueuedGuestPromptByOrder<TContext = unknown>(
|
|
926
|
+
items: TelegramQueueItem<TContext>[],
|
|
927
|
+
queueOrder: number,
|
|
928
|
+
): {
|
|
929
|
+
items: TelegramQueueItem<TContext>[];
|
|
930
|
+
removedItems: PendingTelegramTurn[];
|
|
931
|
+
removedCount: number;
|
|
932
|
+
} {
|
|
933
|
+
const index = items.findIndex((item) => {
|
|
934
|
+
return isPendingTelegramTurn(item) &&
|
|
935
|
+
item.guestQueryId !== undefined &&
|
|
936
|
+
item.queueOrder === queueOrder;
|
|
937
|
+
});
|
|
938
|
+
if (index < 0) return { items, removedItems: [], removedCount: 0 };
|
|
939
|
+
const removedItem = items[index] as PendingTelegramTurn;
|
|
940
|
+
return {
|
|
941
|
+
items: [...items.slice(0, index), ...items.slice(index + 1)],
|
|
942
|
+
removedItems: [removedItem],
|
|
943
|
+
removedCount: 1,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
925
947
|
export function applyTelegramQueuePromptReactionDisposition<
|
|
926
948
|
TContext = unknown,
|
|
927
949
|
>(
|
|
@@ -1593,7 +1615,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
|
|
|
1593
1615
|
extractAssistant: (
|
|
1594
1616
|
messages: readonly TMessage[],
|
|
1595
1617
|
) => TelegramAgentEndAssistantResult;
|
|
1596
|
-
|
|
1618
|
+
isAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
|
|
1597
1619
|
getFoldQueuedPromptsIntoHistory: () => boolean;
|
|
1598
1620
|
resetRuntimeState: () => void;
|
|
1599
1621
|
isSessionActive?: (ctx: TContext) => boolean;
|
|
@@ -1739,8 +1761,7 @@ export function createTelegramAgentEndHook<
|
|
|
1739
1761
|
if (deps.isSessionActive?.(ctx) === false) return;
|
|
1740
1762
|
const turn = deps.getActiveTurn();
|
|
1741
1763
|
const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
|
|
1742
|
-
const assistant = extractedAssistant
|
|
1743
|
-
deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
|
|
1764
|
+
const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
|
|
1744
1765
|
? { stopReason: extractedAssistant.stopReason }
|
|
1745
1766
|
: extractedAssistant;
|
|
1746
1767
|
const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
|
|
@@ -2331,6 +2352,10 @@ export interface TelegramQueueMutationController<TContext> {
|
|
|
2331
2352
|
ctx: TContext,
|
|
2332
2353
|
scope?: TelegramQueueMessageScope,
|
|
2333
2354
|
) => number;
|
|
2355
|
+
removeGuestPromptByQueueOrder?: (
|
|
2356
|
+
queueOrder: number,
|
|
2357
|
+
ctx: TContext,
|
|
2358
|
+
) => boolean;
|
|
2334
2359
|
applyReactionByMessageId: (
|
|
2335
2360
|
messageId: number,
|
|
2336
2361
|
disposition: TelegramQueueReactionDisposition,
|
|
@@ -2604,6 +2629,11 @@ export function createTelegramQueueMutationController<TContext>(
|
|
|
2604
2629
|
buildRuntimeDeps(ctx),
|
|
2605
2630
|
scope,
|
|
2606
2631
|
),
|
|
2632
|
+
removeGuestPromptByQueueOrder: (queueOrder, ctx) =>
|
|
2633
|
+
removeTelegramQueuedGuestPromptByOrderRuntime(
|
|
2634
|
+
queueOrder,
|
|
2635
|
+
buildRuntimeDeps(ctx),
|
|
2636
|
+
),
|
|
2607
2637
|
applyReactionByMessageId: (messageId, disposition, ctx, scope) =>
|
|
2608
2638
|
applyTelegramQueuePromptReactionDispositionRuntime(
|
|
2609
2639
|
messageId,
|
|
@@ -2684,6 +2714,21 @@ export function removeTelegramQueueItemsByMessageIdsRuntime<TContext>(
|
|
|
2684
2714
|
return removedCount;
|
|
2685
2715
|
}
|
|
2686
2716
|
|
|
2717
|
+
export function removeTelegramQueuedGuestPromptByOrderRuntime<TContext>(
|
|
2718
|
+
queueOrder: number,
|
|
2719
|
+
deps: TelegramQueueMutationRuntimeDeps<TContext>,
|
|
2720
|
+
): boolean {
|
|
2721
|
+
const { items, removedItems, removedCount } =
|
|
2722
|
+
removeTelegramQueuedGuestPromptByOrder(deps.getQueuedItems(), queueOrder);
|
|
2723
|
+
if (removedCount === 0) return false;
|
|
2724
|
+
// A menu Skip means permanent discard, so settle durable admission before
|
|
2725
|
+
// removing the live item. A failed settlement keeps the prompt retryable.
|
|
2726
|
+
deps.onItemsDiscarded?.(removedItems, deps.ctx);
|
|
2727
|
+
deps.setQueuedItems(items);
|
|
2728
|
+
updateTelegramQueueStatusRuntime(deps);
|
|
2729
|
+
return true;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2687
2732
|
export function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(
|
|
2688
2733
|
messageId: number,
|
|
2689
2734
|
disposition: TelegramQueueReactionDisposition,
|
package/lib/replies.ts
CHANGED
|
@@ -1018,6 +1018,10 @@ export const TELEGRAM_GUEST_PLACEHOLDER_FRAMES = [
|
|
|
1018
1018
|
"<b>🌏 Working on it...</b>",
|
|
1019
1019
|
] as const;
|
|
1020
1020
|
|
|
1021
|
+
// Telegram does not expose deletion for inline messages. This non-printing,
|
|
1022
|
+
// non-empty edit visually clears a guest placeholder after its query is skipped.
|
|
1023
|
+
export const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
|
|
1024
|
+
|
|
1021
1025
|
export function buildTelegramGuestPlaceholderFrame(step: number): string {
|
|
1022
1026
|
const frames: readonly string[] = TELEGRAM_GUEST_PLACEHOLDER_FRAMES;
|
|
1023
1027
|
const index = ((step % frames.length) + frames.length) % frames.length;
|
|
@@ -1052,6 +1056,8 @@ export interface TelegramGuestPlaceholderRuntime {
|
|
|
1052
1056
|
stop: (inlineMessageId: string) => Promise<void>;
|
|
1053
1057
|
/** Cancels every loop without waiting for in-flight edits (session shutdown). */
|
|
1054
1058
|
stopAll: () => void;
|
|
1059
|
+
/** Stops rotation and visually clears the inline placeholder. */
|
|
1060
|
+
dismiss: (inlineMessageId: string) => Promise<void>;
|
|
1055
1061
|
}
|
|
1056
1062
|
|
|
1057
1063
|
interface TelegramGuestPlaceholderSession {
|
|
@@ -1202,5 +1208,18 @@ export function createTelegramGuestPlaceholderRuntime(
|
|
|
1202
1208
|
}
|
|
1203
1209
|
sessions.clear();
|
|
1204
1210
|
},
|
|
1211
|
+
async dismiss(inlineMessageId) {
|
|
1212
|
+
await stop(inlineMessageId);
|
|
1213
|
+
try {
|
|
1214
|
+
await deps.editGuestInlineMessage(inlineMessageId, {
|
|
1215
|
+
text: TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT,
|
|
1216
|
+
parseMode: "HTML",
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
deps.recordRuntimeEvent?.("guest", error, {
|
|
1220
|
+
phase: "guest-placeholder-dismiss",
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
},
|
|
1205
1224
|
};
|
|
1206
1225
|
}
|
package/lib/sync.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
|
|
8
8
|
import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
|
|
9
9
|
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
10
|
+
import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
|
|
10
11
|
import {
|
|
11
12
|
createTelegramWorkspaceAdmissionOperationId,
|
|
12
13
|
runWithTelegramWorkspaceAdmissionsAsync,
|
|
@@ -197,6 +198,47 @@ export function markTelegramConfigSyncChange<
|
|
|
197
198
|
return nextState;
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
export function createTelegramPreservedLeaderQuitHandler(deps: {
|
|
202
|
+
instanceId: string;
|
|
203
|
+
topicTargetStore: Pick<TelegramTopicTargetStore,
|
|
204
|
+
"load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
|
|
205
|
+
getCurrentLeaderEpoch: () => number | string | undefined;
|
|
206
|
+
getProfileName: () => string | undefined;
|
|
207
|
+
isPollingSuspended: () => boolean;
|
|
208
|
+
resolveAutomaticThreadCleanupEnabled: () => boolean | Promise<boolean>;
|
|
209
|
+
runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
|
|
210
|
+
}): (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined {
|
|
211
|
+
return (isSessionCurrent) => {
|
|
212
|
+
const epoch = deps.getCurrentLeaderEpoch();
|
|
213
|
+
const profile = deps.getProfileName();
|
|
214
|
+
if (epoch === undefined || !isSessionCurrent()) return undefined;
|
|
215
|
+
const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
|
|
216
|
+
deps.getProfileName() === profile && deps.isPollingSuspended();
|
|
217
|
+
return async () => {
|
|
218
|
+
if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false) return;
|
|
219
|
+
if (!isCurrent()) return;
|
|
220
|
+
await deps.runWorkspaceOperation({
|
|
221
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
222
|
+
operationKind: "workspace.preserve-leader-quit",
|
|
223
|
+
scopes: [{ kind: "profile" }],
|
|
224
|
+
}, async () => {
|
|
225
|
+
if (!isCurrent()) return;
|
|
226
|
+
await deps.topicTargetStore.load();
|
|
227
|
+
if (!isCurrent()) return;
|
|
228
|
+
const records = deps.topicTargetStore.list().filter((record) =>
|
|
229
|
+
record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
|
|
230
|
+
if (records.length !== 1) return;
|
|
231
|
+
const record = records[0]!;
|
|
232
|
+
if (deps.topicTargetStore.listPendingCleanups().some((intent) =>
|
|
233
|
+
intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId)) return;
|
|
234
|
+
if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
|
|
235
|
+
throw new Error("Telegram preserved leader detachment was not committed.");
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
200
242
|
export interface TelegramSessionRestartThreadCleanupDeps<
|
|
201
243
|
TSyncState extends TelegramSyncState,
|
|
202
244
|
> extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
|
|
@@ -650,15 +692,17 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
650
692
|
const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
|
|
651
693
|
const legacyLeaderRecord =
|
|
652
694
|
deps.topicTargetStore.getByProfileKey(leaderProfileKey);
|
|
695
|
+
let capacityUnavailable = false;
|
|
653
696
|
const workspaceIdentity = normalizedLeaderCwd
|
|
654
697
|
? deps.topicTargetStore.claimWorkspaceIdentity(
|
|
655
698
|
normalizedLeaderCwd,
|
|
656
699
|
deps.instanceId,
|
|
657
700
|
legacyLeaderRecord?.instanceId,
|
|
658
|
-
{ sessionId: deps.sessionId },
|
|
701
|
+
{ sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } },
|
|
659
702
|
)
|
|
660
703
|
: undefined;
|
|
661
704
|
if (deps.cwd && !workspaceIdentity) {
|
|
705
|
+
if (capacityUnavailable) throw new TelegramWorkspaceSlotUnavailableError();
|
|
662
706
|
throw new Error("Telegram Workspace identity is already claimed.");
|
|
663
707
|
}
|
|
664
708
|
const commitWorkspaceBinding = async (
|
|
@@ -684,7 +728,14 @@ export async function ensureTelegramLeaderThreadBinding(
|
|
|
684
728
|
assertLeaderEpoch("before-workspace-persist");
|
|
685
729
|
await deps.topicTargetStore.persist();
|
|
686
730
|
assertLeaderEpoch("after-workspace-persist");
|
|
687
|
-
|
|
731
|
+
const { target: _target, slot: _slot, threadName: _threadName, displayTitle: _displayTitle, ...rest } = result;
|
|
732
|
+
return {
|
|
733
|
+
...rest,
|
|
734
|
+
target: { ...committed.target },
|
|
735
|
+
slot: committed.slot ?? result.slot,
|
|
736
|
+
...(committed.threadName ? { threadName: committed.threadName } : {}),
|
|
737
|
+
...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
|
|
738
|
+
};
|
|
688
739
|
};
|
|
689
740
|
try {
|
|
690
741
|
const unavailableTargetKeys = new Set([
|
package/lib/telegram-api.ts
CHANGED
|
@@ -882,15 +882,18 @@ export function isTelegramApiCommitUnknownError(
|
|
|
882
882
|
class TelegramApiHttpError extends Error {
|
|
883
883
|
readonly status: number | undefined;
|
|
884
884
|
readonly retryAfterSeconds: number | undefined;
|
|
885
|
+
readonly rejectedRequestMethod: string | undefined;
|
|
885
886
|
requestTarget?: { chatId: number; threadId: number };
|
|
886
887
|
constructor(
|
|
887
888
|
message: string,
|
|
888
889
|
status: number | undefined,
|
|
889
890
|
retryAfterSeconds: number | undefined,
|
|
891
|
+
rejectedRequestMethod?: string,
|
|
890
892
|
) {
|
|
891
893
|
super(message);
|
|
892
894
|
this.status = status;
|
|
893
895
|
this.retryAfterSeconds = retryAfterSeconds;
|
|
896
|
+
this.rejectedRequestMethod = rejectedRequestMethod;
|
|
894
897
|
}
|
|
895
898
|
}
|
|
896
899
|
|
|
@@ -934,6 +937,12 @@ export function isTelegramStaleTargetHttpError(error: unknown): boolean {
|
|
|
934
937
|
return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
|
|
935
938
|
}
|
|
936
939
|
|
|
940
|
+
/** Only a parsed Telegram rejection of this method proves a request had no effect. */
|
|
941
|
+
export function isTelegramApiRequestRejected(error: unknown, method: string): boolean {
|
|
942
|
+
return error instanceof TelegramApiHttpError && error.rejectedRequestMethod !== undefined &&
|
|
943
|
+
error.rejectedRequestMethod === method;
|
|
944
|
+
}
|
|
945
|
+
|
|
937
946
|
export function isTelegramMessageNotModifiedError(error: unknown): boolean {
|
|
938
947
|
return (
|
|
939
948
|
error instanceof Error && error.message.includes("message is not modified")
|
|
@@ -1106,6 +1115,8 @@ async function parseTelegramApiResponse<TResponse>(
|
|
|
1106
1115
|
`Telegram API ${method} failed: ${status}${description}`,
|
|
1107
1116
|
response.status,
|
|
1108
1117
|
Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
|
|
1118
|
+
data?.ok === false && data.error_code === response.status &&
|
|
1119
|
+
[400, 401, 403, 404, 429].includes(response.status) ? method : undefined,
|
|
1109
1120
|
);
|
|
1110
1121
|
}
|
|
1111
1122
|
if (!data) {
|
|
@@ -1785,6 +1796,10 @@ export function buildTelegramAnswerGuestQueryBody(
|
|
|
1785
1796
|
return body;
|
|
1786
1797
|
}
|
|
1787
1798
|
|
|
1799
|
+
export type TelegramWorkspaceThreadDeletionTransport = (
|
|
1800
|
+
authorize: () => { chatId: number; threadId: number },
|
|
1801
|
+
) => Promise<void>;
|
|
1802
|
+
|
|
1788
1803
|
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
1789
1804
|
getBotToken: () => string | undefined;
|
|
1790
1805
|
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
|
@@ -1793,7 +1808,9 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1793
1808
|
workspaceAdmission?:
|
|
1794
1809
|
| TelegramApiWorkspaceAdmissionPort
|
|
1795
1810
|
| (() => TelegramApiWorkspaceAdmissionPort | undefined);
|
|
1796
|
-
}): TelegramBridgeApiRuntime {
|
|
1811
|
+
}): TelegramBridgeApiRuntime & {
|
|
1812
|
+
deleteWorkspaceThread: TelegramWorkspaceThreadDeletionTransport;
|
|
1813
|
+
} {
|
|
1797
1814
|
const client = createTelegramApiClient(deps.getBotToken, {
|
|
1798
1815
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1799
1816
|
});
|
|
@@ -1807,7 +1824,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1807
1824
|
},
|
|
1808
1825
|
})
|
|
1809
1826
|
: client;
|
|
1810
|
-
|
|
1827
|
+
const runtime = createTelegramBridgeApiRuntime({
|
|
1811
1828
|
client: deps.targetActivity
|
|
1812
1829
|
? createTelegramApiTargetTrackingClient(admittedClient, deps.targetActivity)
|
|
1813
1830
|
: admittedClient,
|
|
@@ -1817,6 +1834,17 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
|
|
1817
1834
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
1818
1835
|
captureRequestErrorHandler: deps.captureRequestErrorHandler,
|
|
1819
1836
|
});
|
|
1837
|
+
return {
|
|
1838
|
+
...runtime,
|
|
1839
|
+
async deleteWorkspaceThread(authorize) {
|
|
1840
|
+
// The exclusive deletion permit replaces ordinary target admission.
|
|
1841
|
+
const target = authorize();
|
|
1842
|
+
const deleted = await client.call<boolean>("deleteForumTopic", {
|
|
1843
|
+
chat_id: target.chatId, message_thread_id: target.threadId,
|
|
1844
|
+
}, { maxAttempts: 1, retrySafety: "non-idempotent" });
|
|
1845
|
+
if (deleted !== true) throw new Error("Telegram Workspace Thread deletion was not confirmed.");
|
|
1846
|
+
},
|
|
1847
|
+
};
|
|
1820
1848
|
}
|
|
1821
1849
|
|
|
1822
1850
|
export function createTelegramBridgeApiRuntime(
|
|
@@ -433,7 +433,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
|
|
|
433
433
|
publish(file); return { confirmed: true, entry: structuredClone(entry) };
|
|
434
434
|
});
|
|
435
435
|
},
|
|
436
|
-
list() {
|
|
436
|
+
list() {
|
|
437
|
+
return structuredClone(withTelegramFileTransaction(
|
|
438
|
+
`${options.path}.transaction`,
|
|
439
|
+
() => read().workSets,
|
|
440
|
+
));
|
|
441
|
+
},
|
|
437
442
|
};
|
|
438
443
|
}
|
|
439
444
|
|
|
@@ -542,8 +547,11 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
|
|
|
542
547
|
if (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
|
|
543
548
|
fence.owner.processBirthId !== owner.processBirthId) {
|
|
544
549
|
if (!deps.canAdoptFence(fence)) return undefined;
|
|
545
|
-
try {
|
|
546
|
-
{ owner, leaderEpoch });
|
|
550
|
+
try {
|
|
551
|
+
const adopted = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
|
|
552
|
+
if (adopted.destructiveKind !== "manual-thread-cleanup") return undefined;
|
|
553
|
+
fence = adopted;
|
|
554
|
+
} catch { return undefined; }
|
|
547
555
|
}
|
|
548
556
|
return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
|
|
549
557
|
};
|
package/lib/threads.ts
CHANGED
|
@@ -34,6 +34,7 @@ import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
|
34
34
|
import {
|
|
35
35
|
planTelegramWorkspaceSlotAllocation,
|
|
36
36
|
TELEGRAM_WORKSPACE_SLOTS,
|
|
37
|
+
TelegramWorkspaceSlotUnavailableError,
|
|
37
38
|
type TelegramWorkspaceSlotOccupancy,
|
|
38
39
|
} from "./workspace-slots.ts";
|
|
39
40
|
import {
|
|
@@ -458,6 +459,11 @@ export interface TelegramTopicTargetStore {
|
|
|
458
459
|
isCurrent: () => boolean,
|
|
459
460
|
lastSyncError: string,
|
|
460
461
|
) => Promise<boolean>;
|
|
462
|
+
/** Caller proves owner detachment; this does not assert Telegram Thread absence. */
|
|
463
|
+
detachTargetOwner: (
|
|
464
|
+
expected: TelegramTopicTargetRecord,
|
|
465
|
+
isCurrent: () => boolean,
|
|
466
|
+
) => Promise<boolean>;
|
|
461
467
|
list: () => TelegramTopicTargetRecord[];
|
|
462
468
|
getFollowerRecoveryHintByTarget?: (
|
|
463
469
|
target: TelegramTarget,
|
|
@@ -2108,20 +2114,33 @@ export function createTelegramTopicTargetStore(
|
|
|
2108
2114
|
mutationRevision += 1;
|
|
2109
2115
|
};
|
|
2110
2116
|
|
|
2111
|
-
const
|
|
2117
|
+
const markWorkspaceBindingInactiveByTarget = (target: TelegramTarget, inactiveSinceMs = getNowMs()): boolean => {
|
|
2118
|
+
if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
|
|
2119
|
+
for (const [key, binding] of workspaceBindings) {
|
|
2120
|
+
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
|
|
2121
|
+
workspaceBindings.set(key, { ...binding, inactiveSinceMs });
|
|
2122
|
+
markDirty();
|
|
2123
|
+
return true;
|
|
2124
|
+
}
|
|
2125
|
+
return false;
|
|
2126
|
+
};
|
|
2127
|
+
|
|
2128
|
+
const persistSnapshot = (transition?: {
|
|
2112
2129
|
target: TelegramTarget;
|
|
2113
2130
|
isCurrent: () => boolean;
|
|
2114
|
-
|
|
2115
|
-
|
|
2131
|
+
} & (
|
|
2132
|
+
| { kind: "invalidate"; lastSyncError: string }
|
|
2133
|
+
| { kind: "detach"; owner: TelegramTopicTargetRecord }
|
|
2134
|
+
)): Promise<boolean> => {
|
|
2116
2135
|
const persist = persistQueue.then(async () => {
|
|
2117
2136
|
const path = getPath();
|
|
2118
2137
|
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
2119
2138
|
if (options.canPersist && !options.canPersist()) {
|
|
2120
|
-
if (!
|
|
2139
|
+
if (!transition) await loadFromDisk();
|
|
2121
2140
|
return false;
|
|
2122
2141
|
}
|
|
2123
2142
|
if (!dirty || !loaded) await loadFromDisk();
|
|
2124
|
-
if (
|
|
2143
|
+
if (transition && !transition.isCurrent()) return false;
|
|
2125
2144
|
const nowMs = getNowMs();
|
|
2126
2145
|
reservations = reservations.filter(
|
|
2127
2146
|
(reservation) =>
|
|
@@ -2134,6 +2153,10 @@ export function createTelegramTopicTargetStore(
|
|
|
2134
2153
|
const currentRecords = Array.from(records.values())
|
|
2135
2154
|
.filter(isPersistedThreadRecord)
|
|
2136
2155
|
.map(cloneRecord);
|
|
2156
|
+
if (transition?.kind === "detach") {
|
|
2157
|
+
const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
|
|
2158
|
+
if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner)) return false;
|
|
2159
|
+
}
|
|
2137
2160
|
records = new Map(
|
|
2138
2161
|
currentRecords.map((record) => [
|
|
2139
2162
|
getRecordOwnerKey(record),
|
|
@@ -2176,20 +2199,32 @@ export function createTelegramTopicTargetStore(
|
|
|
2176
2199
|
return serialized;
|
|
2177
2200
|
}),
|
|
2178
2201
|
};
|
|
2179
|
-
if (
|
|
2180
|
-
const record = file.threads.find((record) => targetMatches(record.target,
|
|
2202
|
+
if (transition) {
|
|
2203
|
+
const record = file.threads.find((record) => targetMatches(record.target, transition.target));
|
|
2181
2204
|
if (!record) return false;
|
|
2205
|
+
if (transition.kind === "detach") {
|
|
2206
|
+
const bindings = file.workspaceBindings.filter((binding) => targetMatches(binding.target, record.target));
|
|
2207
|
+
if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
|
|
2208
|
+
bindings[0]!.slot !== record.slot) return false;
|
|
2209
|
+
}
|
|
2182
2210
|
file.threads = file.threads.filter((candidate) => candidate !== record);
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2211
|
+
for (const binding of file.workspaceBindings) {
|
|
2212
|
+
if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
|
|
2213
|
+
binding.inactiveSinceMs = nowMs;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
if (transition.kind === "invalidate") {
|
|
2217
|
+
file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
|
|
2218
|
+
file.syncObservations.push({
|
|
2219
|
+
target: { ...record.target },
|
|
2220
|
+
syncStatus: "deleted",
|
|
2221
|
+
observedAtMs: nowMs,
|
|
2222
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
2223
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
2224
|
+
lastSyncError: transition.lastSyncError,
|
|
2225
|
+
lastReconcileAction: "mark-stale",
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2193
2228
|
}
|
|
2194
2229
|
let persistedSemanticSnapshot: Record<string, unknown> | undefined;
|
|
2195
2230
|
try {
|
|
@@ -2201,7 +2236,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2201
2236
|
}
|
|
2202
2237
|
// Normalize optional fields to wire JSON; object key order is not a state change.
|
|
2203
2238
|
if (
|
|
2204
|
-
isDeepStrictEqual(
|
|
2239
|
+
!transition && isDeepStrictEqual(
|
|
2205
2240
|
persistedSemanticSnapshot,
|
|
2206
2241
|
getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
|
|
2207
2242
|
) &&
|
|
@@ -2221,17 +2256,20 @@ export function createTelegramTopicTargetStore(
|
|
|
2221
2256
|
});
|
|
2222
2257
|
await chmod(tempPath, 0o600);
|
|
2223
2258
|
try {
|
|
2224
|
-
if (
|
|
2259
|
+
if (transition) {
|
|
2225
2260
|
let applied = false;
|
|
2226
2261
|
const commit = () => {
|
|
2227
2262
|
if (getPath() !== path || mutationRevision !== persistedRevision ||
|
|
2228
|
-
statusRevision !== persistedStatusRevision || !
|
|
2263
|
+
statusRevision !== persistedStatusRevision || !transition.isCurrent()) return;
|
|
2229
2264
|
// Fence and rename share one synchronous commit boundary. No stale
|
|
2230
|
-
//
|
|
2265
|
+
// transition enters the live projection before durable commit.
|
|
2231
2266
|
renameSync(tempPath, path);
|
|
2232
2267
|
loadedPath = path;
|
|
2233
|
-
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target,
|
|
2268
|
+
records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
|
|
2234
2269
|
syncObservations = file.syncObservations;
|
|
2270
|
+
workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
|
|
2271
|
+
getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
|
|
2272
|
+
]));
|
|
2235
2273
|
mutationRevision += 1;
|
|
2236
2274
|
dirty = false;
|
|
2237
2275
|
applied = true;
|
|
@@ -2283,7 +2321,12 @@ export function createTelegramTopicTargetStore(
|
|
|
2283
2321
|
await persistSnapshot();
|
|
2284
2322
|
},
|
|
2285
2323
|
invalidateTarget(target, isCurrent, lastSyncError) {
|
|
2286
|
-
return persistSnapshot({ target, isCurrent, lastSyncError });
|
|
2324
|
+
return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
|
|
2325
|
+
},
|
|
2326
|
+
detachTargetOwner(expected, isCurrent) {
|
|
2327
|
+
const owner = normalizeRecord(expected);
|
|
2328
|
+
if (!owner) return Promise.resolve(false);
|
|
2329
|
+
return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
|
|
2287
2330
|
},
|
|
2288
2331
|
list() {
|
|
2289
2332
|
return Array.from(records.values()).map(cloneRecord);
|
|
@@ -2838,16 +2881,7 @@ export function createTelegramTopicTargetStore(
|
|
|
2838
2881
|
markDirty();
|
|
2839
2882
|
return true;
|
|
2840
2883
|
},
|
|
2841
|
-
markWorkspaceBindingInactiveByTarget
|
|
2842
|
-
if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
|
|
2843
|
-
for (const [key, binding] of workspaceBindings) {
|
|
2844
|
-
if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
|
|
2845
|
-
workspaceBindings.set(key, { ...binding, inactiveSinceMs });
|
|
2846
|
-
markDirty();
|
|
2847
|
-
return true;
|
|
2848
|
-
}
|
|
2849
|
-
return false;
|
|
2850
|
-
},
|
|
2884
|
+
markWorkspaceBindingInactiveByTarget,
|
|
2851
2885
|
markWorkspaceBindingActiveByTarget(target) {
|
|
2852
2886
|
if (hasWorkspaceRetirementConflict({ target })) return false;
|
|
2853
2887
|
for (const [key, binding] of workspaceBindings) {
|
|
@@ -3274,7 +3308,10 @@ export function createTelegramTopicTargetStore(
|
|
|
3274
3308
|
entry.target && targetMatches(entry.target, target),
|
|
3275
3309
|
) : undefined;
|
|
3276
3310
|
const source = record ?? pending;
|
|
3277
|
-
|
|
3311
|
+
// Confirmed absence ends this binding's active target lifetime, even after
|
|
3312
|
+
// an earlier stale observation already removed its live record.
|
|
3313
|
+
const inactive = syncStatus === "deleted" && markWorkspaceBindingInactiveByTarget(target);
|
|
3314
|
+
if (!source?.target) return inactive;
|
|
3278
3315
|
syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
|
|
3279
3316
|
syncObservations.push({
|
|
3280
3317
|
target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
|
|
@@ -4886,7 +4923,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
4886
4923
|
if (existing && isCurrentThreadRecord(existing)) {
|
|
4887
4924
|
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
4888
4925
|
if (!slot) {
|
|
4889
|
-
throw new
|
|
4926
|
+
throw new TelegramWorkspaceSlotUnavailableError();
|
|
4890
4927
|
}
|
|
4891
4928
|
const occupied = listOccupiedTelegramThreadIdentities({
|
|
4892
4929
|
records: deps.store.list(),
|
|
@@ -5015,7 +5052,7 @@ export function createTelegramTopicTargetProvisioner(
|
|
|
5015
5052
|
request.workspaceBindingKey,
|
|
5016
5053
|
);
|
|
5017
5054
|
if (!slot) {
|
|
5018
|
-
throw new
|
|
5055
|
+
throw new TelegramWorkspaceSlotUnavailableError();
|
|
5019
5056
|
}
|
|
5020
5057
|
const uniqueCandidate =
|
|
5021
5058
|
candidateThreadName &&
|