@llblab/pi-telegram 0.46.0 → 0.48.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 +4 -2
- package/BACKLOG.md +0 -6
- package/CHANGELOG.md +14 -0
- package/README.md +5 -2
- package/dist/api/activity.d.ts +6 -0
- package/dist/api/activity.js +6 -0
- package/dist/api/commands.d.ts +6 -0
- package/dist/api/commands.js +6 -0
- package/dist/api/delivery.d.ts +6 -0
- package/dist/api/delivery.js +6 -0
- package/dist/api/inbound.d.ts +6 -0
- package/dist/api/inbound.js +6 -0
- package/dist/api/keyboard.d.ts +6 -0
- package/dist/api/keyboard.js +6 -0
- package/dist/api/outbound.d.ts +6 -0
- package/dist/api/outbound.js +6 -0
- package/dist/api/sections.d.ts +7 -0
- package/dist/api/sections.js +6 -0
- package/dist/api/status.d.ts +6 -0
- package/dist/api/status.js +6 -0
- package/dist/api/updates.d.ts +6 -0
- package/dist/api/updates.js +6 -0
- package/dist/api/voice.d.ts +6 -0
- package/dist/api/voice.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/activity-verbosity.d.ts +52 -0
- package/dist/lib/activity-verbosity.js +596 -0
- package/dist/lib/activity.d.ts +220 -0
- package/dist/lib/activity.js +574 -0
- package/dist/lib/agent-messages.d.ts +28 -0
- package/dist/lib/agent-messages.js +86 -0
- package/dist/lib/bindings.d.ts +250 -0
- package/dist/lib/bindings.js +936 -0
- package/dist/lib/bus-api.d.ts +18 -0
- package/dist/lib/bus-api.js +254 -0
- package/dist/lib/bus-follower.d.ts +464 -0
- package/dist/lib/bus-follower.js +1686 -0
- package/dist/lib/bus-leader.d.ts +292 -0
- package/dist/lib/bus-leader.js +2242 -0
- package/dist/lib/bus-transport.d.ts +64 -0
- package/dist/lib/bus-transport.js +140 -0
- package/dist/lib/bus.d.ts +518 -0
- package/dist/lib/bus.js +2055 -0
- package/dist/lib/channel-posts.d.ts +170 -0
- package/dist/lib/channel-posts.js +611 -0
- package/dist/lib/command-templates.d.ts +70 -0
- package/dist/lib/command-templates.js +744 -0
- package/dist/lib/commands.d.ts +541 -0
- package/dist/lib/commands.js +1155 -0
- package/dist/lib/config.d.ts +252 -0
- package/dist/lib/config.js +889 -0
- package/dist/lib/delivery.d.ts +163 -0
- package/dist/lib/delivery.js +542 -0
- package/dist/lib/extension.d.ts +7 -0
- package/dist/lib/extension.js +1604 -0
- package/dist/lib/generative-app-worker.mjs +104 -0
- package/dist/lib/generative-apps.d.ts +212 -0
- package/dist/lib/generative-apps.js +1006 -0
- package/dist/lib/inbound.d.ts +91 -0
- package/dist/lib/inbound.js +502 -0
- package/dist/lib/journal.d.ts +665 -0
- package/dist/lib/journal.js +3701 -0
- package/dist/lib/keyboard.d.ts +23 -0
- package/dist/lib/keyboard.js +37 -0
- package/dist/lib/lifecycle.d.ts +157 -0
- package/dist/lib/lifecycle.js +395 -0
- package/dist/lib/locks.d.ts +164 -0
- package/dist/lib/locks.js +1208 -0
- package/dist/lib/logging.d.ts +50 -0
- package/dist/lib/logging.js +274 -0
- package/dist/lib/media.d.ts +181 -0
- package/dist/lib/media.js +602 -0
- package/dist/lib/menu-model.d.ts +217 -0
- package/dist/lib/menu-model.js +663 -0
- package/dist/lib/menu-queue.d.ts +37 -0
- package/dist/lib/menu-queue.js +408 -0
- package/dist/lib/menu-settings.d.ts +115 -0
- package/dist/lib/menu-settings.js +595 -0
- package/dist/lib/menu-status.d.ts +32 -0
- package/dist/lib/menu-status.js +112 -0
- package/dist/lib/menu-thinking.d.ts +29 -0
- package/dist/lib/menu-thinking.js +82 -0
- package/dist/lib/menu.d.ts +171 -0
- package/dist/lib/menu.js +323 -0
- package/dist/lib/model.d.ts +127 -0
- package/dist/lib/model.js +409 -0
- package/dist/lib/outbound-attachments.d.ts +241 -0
- package/dist/lib/outbound-attachments.js +639 -0
- package/dist/lib/outbound-buttons.d.ts +69 -0
- package/dist/lib/outbound-buttons.js +248 -0
- package/dist/lib/outbound-markup.d.ts +42 -0
- package/dist/lib/outbound-markup.js +678 -0
- package/dist/lib/outbound-voice.d.ts +55 -0
- package/dist/lib/outbound-voice.js +152 -0
- package/dist/lib/outbound.d.ts +185 -0
- package/dist/lib/outbound.js +516 -0
- package/dist/lib/ownership.d.ts +77 -0
- package/dist/lib/ownership.js +174 -0
- package/dist/lib/paths.d.ts +40 -0
- package/dist/lib/paths.js +94 -0
- package/dist/lib/pi.d.ts +72 -0
- package/dist/lib/pi.js +121 -0
- package/dist/lib/polling.d.ts +351 -0
- package/dist/lib/polling.js +1196 -0
- package/dist/lib/preview.d.ts +192 -0
- package/dist/lib/preview.js +614 -0
- package/dist/lib/prompt-templates.d.ts +24 -0
- package/dist/lib/prompt-templates.js +118 -0
- package/dist/lib/prompts.d.ts +57 -0
- package/dist/lib/prompts.js +167 -0
- package/dist/lib/queue.d.ts +766 -0
- package/dist/lib/queue.js +1965 -0
- package/dist/lib/recovery.d.ts +86 -0
- package/dist/lib/recovery.js +285 -0
- package/dist/lib/rendering.d.ts +20 -0
- package/dist/lib/rendering.js +983 -0
- package/dist/lib/replies.d.ts +214 -0
- package/dist/lib/replies.js +737 -0
- package/dist/lib/routing.d.ts +207 -0
- package/dist/lib/routing.js +2282 -0
- package/dist/lib/runtime.d.ts +172 -0
- package/dist/lib/runtime.js +402 -0
- package/dist/lib/sections.d.ts +165 -0
- package/dist/lib/sections.js +327 -0
- package/dist/lib/setup.d.ts +82 -0
- package/dist/lib/setup.js +150 -0
- package/dist/lib/skills.d.ts +8 -0
- package/dist/lib/skills.js +12 -0
- package/dist/lib/status.d.ts +442 -0
- package/dist/lib/status.js +1008 -0
- package/dist/lib/sync.d.ts +179 -0
- package/dist/lib/sync.js +782 -0
- package/dist/lib/target.d.ts +20 -0
- package/dist/lib/target.js +27 -0
- package/dist/lib/telegram-api.d.ts +541 -0
- package/dist/lib/telegram-api.js +1159 -0
- package/dist/lib/text-groups.d.ts +89 -0
- package/dist/lib/text-groups.js +317 -0
- package/dist/lib/thread-cleanup-manager.d.ts +288 -0
- package/dist/lib/thread-cleanup-manager.js +560 -0
- package/dist/lib/thread-display.d.ts +46 -0
- package/dist/lib/thread-display.js +257 -0
- package/dist/lib/thread-naming.d.ts +46 -0
- package/dist/lib/thread-naming.js +78 -0
- package/dist/lib/thread-reconciler.d.ts +239 -0
- package/dist/lib/thread-reconciler.js +644 -0
- package/dist/lib/threads.d.ts +621 -0
- package/dist/lib/threads.js +3679 -0
- package/dist/lib/time-injection.d.ts +15 -0
- package/dist/lib/time-injection.js +56 -0
- package/dist/lib/turns.d.ts +109 -0
- package/dist/lib/turns.js +500 -0
- package/dist/lib/updates.d.ts +1290 -0
- package/dist/lib/updates.js +3634 -0
- package/dist/lib/voice.d.ts +117 -0
- package/dist/lib/voice.js +174 -0
- package/dist/lib/workspace-admission.d.ts +264 -0
- package/dist/lib/workspace-admission.js +1136 -0
- package/dist/lib/workspace-retirement.d.ts +219 -0
- package/dist/lib/workspace-retirement.js +587 -0
- package/dist/lib/workspace-slots.d.ts +30 -0
- package/dist/lib/workspace-slots.js +54 -0
- package/dist/package.json +126 -0
- package/dist/pi-telegram/index.js +1 -0
- package/dist/skills/generated-control-surface/SKILL.md +107 -0
- package/dist/skills/generated-control-surface/references/capability-adapters.md +27 -0
- package/dist/skills/generated-control-surface/references/layout-and-state.md +37 -0
- package/dist/skills/generative-apps/SKILL.md +115 -0
- package/dist/skills/show-me/SKILL.md +166 -0
- package/dist/skills/show-me/references/telegram-surfaces.md +43 -0
- package/dist/skills/telegram-bridge/SKILL.md +129 -0
- package/dist/skills/telegram-bridge/references/configuration.md +15 -0
- package/dist/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
- package/dist/skills/telegram-bridge/references/diagnosis.md +18 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +1 -1
- package/docs/compact-matrix-literal.md +10 -3
- package/docs/generative-apps.md +15 -13
- package/docs/multi-instance-bus.md +6 -4
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +3 -3
- package/lib/bindings.ts +89 -6
- package/lib/bus-follower.ts +7 -3
- package/lib/bus-leader.ts +44 -19
- package/lib/bus.ts +4 -1
- package/lib/commands.ts +2 -2
- package/lib/config.ts +14 -7
- package/lib/delivery.ts +38 -6
- package/lib/extension.ts +7 -0
- package/lib/generative-apps.ts +379 -13
- package/lib/menu-settings.ts +20 -6
- package/lib/outbound-markup.ts +16 -7
- package/lib/status.ts +15 -12
- package/lib/telegram-api.ts +12 -1
- package/lib/thread-display.ts +96 -18
- package/package.json +56 -13
- package/scripts/build-dist.mjs +44 -0
- package/scripts/measure-bus.mjs +8 -1
- package/scripts/measure-workspace.mjs +8 -1
- package/skills/generative-apps/SKILL.md +1 -1
- package/skills/show-me/SKILL.md +1 -1
- package/skills/show-me/references/telegram-surfaces.md +1 -1
- package/skills/telegram-bridge/SKILL.md +1 -1
package/dist/lib/sync.js
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram synchronization helpers
|
|
3
|
+
* Zones: Telegram bot reality mirror, demand-driven reconciliation, status diagnostics
|
|
4
|
+
* Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
|
|
5
|
+
*/
|
|
6
|
+
import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.js";
|
|
7
|
+
import { getTelegramTargetKey } from "./target.js";
|
|
8
|
+
import * as ThreadReconciler from "./thread-reconciler.js";
|
|
9
|
+
import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
|
|
10
|
+
import { createTelegramCleanupTargetProtection, commitTelegramWorkspaceProvisionBinding, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, normalizeTelegramWorkspacePath, provisionOwnBusTopic, } from "./threads.js";
|
|
11
|
+
export function markTelegramConfigSyncChange(state, action, options) {
|
|
12
|
+
const nowMs = options?.nowMs ?? Date.now();
|
|
13
|
+
let nextState = markTelegramSyncSliceFresh(state, "pairing", {
|
|
14
|
+
nowMs,
|
|
15
|
+
action,
|
|
16
|
+
});
|
|
17
|
+
nextState = markTelegramSyncSliceFresh(nextState, "allowed-user", {
|
|
18
|
+
nowMs,
|
|
19
|
+
action,
|
|
20
|
+
});
|
|
21
|
+
nextState = markTelegramSyncSliceFresh(nextState, "bot-identity", {
|
|
22
|
+
nowMs,
|
|
23
|
+
action,
|
|
24
|
+
});
|
|
25
|
+
return nextState;
|
|
26
|
+
}
|
|
27
|
+
export function createTelegramSessionRestartThreadCleanupHandler(deps) {
|
|
28
|
+
return createTelegramManualThreadDisconnectHandler({
|
|
29
|
+
...deps,
|
|
30
|
+
workspaceOperationKind: "workspace.cleanup-session-restart",
|
|
31
|
+
async stopPolling() {
|
|
32
|
+
await deps.suspendPolling();
|
|
33
|
+
return "Telegram bridge suspended for session restart.";
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export function createTelegramThreadDisconnectAssembly(deps) {
|
|
38
|
+
return {
|
|
39
|
+
disconnect: createTelegramManualThreadDisconnectHandler({
|
|
40
|
+
...deps,
|
|
41
|
+
workspaceOperationKind: "workspace.disconnect-thread",
|
|
42
|
+
stopPolling: deps.stopPolling,
|
|
43
|
+
}),
|
|
44
|
+
cleanupForSessionRestart: createTelegramSessionRestartThreadCleanupHandler({
|
|
45
|
+
...deps,
|
|
46
|
+
suspendPolling: deps.suspendPolling,
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
51
|
+
const operation = async () => {
|
|
52
|
+
const currentRecord = deps.getCurrentThreadRecord();
|
|
53
|
+
let cleanupPending = false;
|
|
54
|
+
if (currentRecord?.target.threadId) {
|
|
55
|
+
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
56
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
57
|
+
const ownsLeader = deps.getCurrentLeaderEpoch
|
|
58
|
+
? leaderEpoch !== undefined
|
|
59
|
+
: !isManualFollower;
|
|
60
|
+
if (isManualFollower && !ownsLeader) {
|
|
61
|
+
if (deps.disconnectFollowerThread) {
|
|
62
|
+
const disconnected = await deps.disconnectFollowerThread();
|
|
63
|
+
if (!disconnected) {
|
|
64
|
+
throw new Error("Telegram follower thread deletion requires a live leader registration.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const target = currentRecord.target;
|
|
70
|
+
const runtimeGeneration = currentRecord.instanceId ?? deps.instanceId;
|
|
71
|
+
const intent = {
|
|
72
|
+
id: `cleanup:${deps.instanceId}:${runtimeGeneration}:${target.chatId}:${target.threadId}`,
|
|
73
|
+
owner: isManualFollower ? "manual-follower" : "leader",
|
|
74
|
+
instanceId: deps.instanceId,
|
|
75
|
+
runtimeGeneration,
|
|
76
|
+
...(currentRecord.profileKey
|
|
77
|
+
? { profileKey: currentRecord.profileKey }
|
|
78
|
+
: {}),
|
|
79
|
+
target,
|
|
80
|
+
requestedAtMs: (deps.getNowMs ?? Date.now)(),
|
|
81
|
+
};
|
|
82
|
+
const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
|
|
83
|
+
record.target.chatId === target.chatId && record.target.threadId === target.threadId);
|
|
84
|
+
const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
|
|
85
|
+
deps.topicTargetStore.upsertPendingCleanup(intent);
|
|
86
|
+
await deps.topicTargetStore.persist();
|
|
87
|
+
const cleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
88
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
89
|
+
currentLeaderEpoch: leaderEpoch,
|
|
90
|
+
records: [],
|
|
91
|
+
pendingCleanups: [intent],
|
|
92
|
+
});
|
|
93
|
+
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(cleanupPlan, {
|
|
94
|
+
isCleanupTargetProtected,
|
|
95
|
+
callApi(method, body) {
|
|
96
|
+
return deps.callApi(method, body);
|
|
97
|
+
},
|
|
98
|
+
markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
|
|
99
|
+
return deps.topicTargetStore.markStaleByTarget(targetToMark, syncStatus, lastSyncError);
|
|
100
|
+
},
|
|
101
|
+
removeCleanupIntentById(id) {
|
|
102
|
+
return deps.topicTargetStore.removePendingCleanup(id);
|
|
103
|
+
},
|
|
104
|
+
persist() {
|
|
105
|
+
return deps.topicTargetStore.persist();
|
|
106
|
+
},
|
|
107
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
108
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
109
|
+
});
|
|
110
|
+
if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
|
|
111
|
+
return "Thread disconnect superseded by a new binding.";
|
|
112
|
+
}
|
|
113
|
+
cleanupPending = Boolean(cleanup.incompleteActions?.length);
|
|
114
|
+
}
|
|
115
|
+
const leaderTarget = deps.getLeaderTarget();
|
|
116
|
+
if (leaderTarget?.chatId === currentRecord.target.chatId &&
|
|
117
|
+
leaderTarget.threadId === currentRecord.target.threadId) {
|
|
118
|
+
deps.clearLeaderTarget();
|
|
119
|
+
}
|
|
120
|
+
deps.setSyncState(markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
|
|
121
|
+
nowMs: (deps.getNowMs ?? Date.now)(),
|
|
122
|
+
action: "manual-disconnect",
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
const stopped = await deps.stopPolling();
|
|
126
|
+
return cleanupPending
|
|
127
|
+
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
|
128
|
+
: stopped;
|
|
129
|
+
};
|
|
130
|
+
return () => deps.runWorkspaceOperation({
|
|
131
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
132
|
+
operationKind: deps.workspaceOperationKind ?? "workspace.disconnect-thread",
|
|
133
|
+
scopes: [{ kind: "profile" }],
|
|
134
|
+
}, operation);
|
|
135
|
+
}
|
|
136
|
+
export function createTelegramLeaderHealthRuntime(deps) {
|
|
137
|
+
const intervalMs = deps.intervalMs ?? 60_000;
|
|
138
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
139
|
+
let interval;
|
|
140
|
+
let generation = 0;
|
|
141
|
+
let tickPromise;
|
|
142
|
+
const markFresh = () => {
|
|
143
|
+
let state = markTelegramSyncSliceFresh(deps.getSyncState(), "transport-health", { nowMs: getNowMs(), action: "leader-health-tick" });
|
|
144
|
+
state = markTelegramSyncSliceFresh(state, "bot-identity", {
|
|
145
|
+
nowMs: getNowMs(),
|
|
146
|
+
action: "leader-health-tick",
|
|
147
|
+
});
|
|
148
|
+
deps.setSyncState(state);
|
|
149
|
+
};
|
|
150
|
+
const markSuspect = (error) => {
|
|
151
|
+
deps.setSyncState(markTelegramSyncSliceSuspect(deps.getSyncState(), "transport-health", {
|
|
152
|
+
nowMs: getNowMs(),
|
|
153
|
+
reason: String(error),
|
|
154
|
+
action: "leader-health-tick",
|
|
155
|
+
}));
|
|
156
|
+
try {
|
|
157
|
+
deps.recordEvent("telegram", error, { phase: "leader-health-tick" });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Health diagnostics cannot create an unhandled timer rejection.
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const stop = () => {
|
|
164
|
+
generation += 1;
|
|
165
|
+
if (interval)
|
|
166
|
+
clearInterval(interval);
|
|
167
|
+
interval = undefined;
|
|
168
|
+
tickPromise = undefined;
|
|
169
|
+
};
|
|
170
|
+
const requestTick = () => {
|
|
171
|
+
if (tickPromise)
|
|
172
|
+
return tickPromise;
|
|
173
|
+
const expectedGeneration = generation;
|
|
174
|
+
let tracked;
|
|
175
|
+
tracked = Promise.resolve()
|
|
176
|
+
.then(deps.callGetMe)
|
|
177
|
+
.then(() => {
|
|
178
|
+
if (generation !== expectedGeneration)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
markFresh();
|
|
182
|
+
}
|
|
183
|
+
catch (stateError) {
|
|
184
|
+
try {
|
|
185
|
+
deps.recordEvent("telegram", stateError, {
|
|
186
|
+
phase: "leader-health-state",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// State and diagnostic failure remain contained by this owner.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}, (error) => {
|
|
194
|
+
if (generation !== expectedGeneration)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
markSuspect(error);
|
|
198
|
+
}
|
|
199
|
+
catch (stateError) {
|
|
200
|
+
try {
|
|
201
|
+
deps.recordEvent("telegram", stateError, {
|
|
202
|
+
phase: "leader-health-state",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// State and diagnostic failure remain contained by this owner.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
.finally(() => {
|
|
211
|
+
if (tickPromise === tracked)
|
|
212
|
+
tickPromise = undefined;
|
|
213
|
+
});
|
|
214
|
+
tickPromise = tracked;
|
|
215
|
+
return tracked;
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
start() {
|
|
219
|
+
stop();
|
|
220
|
+
interval = setInterval(() => {
|
|
221
|
+
void requestTick();
|
|
222
|
+
}, intervalMs);
|
|
223
|
+
interval.unref?.();
|
|
224
|
+
},
|
|
225
|
+
stop,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function captureTelegramStaleTargetRequestRecovery(body, deps) {
|
|
229
|
+
const target = getTelegramTargetFromApiBody(body);
|
|
230
|
+
const epoch = deps.getCurrentLeaderEpoch();
|
|
231
|
+
if (!target || epoch === undefined)
|
|
232
|
+
return undefined;
|
|
233
|
+
const key = getTelegramTargetKey(target);
|
|
234
|
+
const record = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
235
|
+
if (!record)
|
|
236
|
+
return undefined;
|
|
237
|
+
const generation = deps.getSessionGeneration();
|
|
238
|
+
const profile = deps.getProfileName();
|
|
239
|
+
const isAuthorityCurrent = () => deps.getCurrentLeaderEpoch() === epoch &&
|
|
240
|
+
deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
|
|
241
|
+
const isCurrent = () => {
|
|
242
|
+
const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
|
|
243
|
+
return isAuthorityCurrent() &&
|
|
244
|
+
current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
|
|
245
|
+
current?.updatedAtMs === record.updatedAtMs && current?.createdAtMs === record.createdAtMs;
|
|
246
|
+
};
|
|
247
|
+
return async (error) => {
|
|
248
|
+
const requestTarget = getTelegramApiErrorRequestTarget(error);
|
|
249
|
+
if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
|
|
250
|
+
getTelegramTargetKey(requestTarget) !== key || !isCurrent())
|
|
251
|
+
return;
|
|
252
|
+
if (await recoverStaleTelegramTopicApiError({ chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent })) {
|
|
253
|
+
deps.onRecovered();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
export function createTelegramStaleTopicApiErrorRecoveryRuntime(deps) {
|
|
258
|
+
return (apiBody, error) => recoverStaleTelegramTopicApiError(apiBody, error, deps);
|
|
259
|
+
}
|
|
260
|
+
export async function settleStaleTelegramTopicExecutionFailure(error, deps) {
|
|
261
|
+
const target = getTelegramApiErrorRequestTarget(error);
|
|
262
|
+
if (!target || !isTelegramTopicTargetStaleError(error))
|
|
263
|
+
return false;
|
|
264
|
+
await recoverStaleTelegramTopicApiError({ chat_id: target.chatId, message_thread_id: target.threadId }, error, deps);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
export async function recoverStaleTelegramTopicApiError(apiBody, error, deps) {
|
|
268
|
+
const target = getTelegramTargetFromApiBody(apiBody);
|
|
269
|
+
if (!target ||
|
|
270
|
+
!isTelegramTopicTargetStaleError(error) ||
|
|
271
|
+
deps.isCurrent?.() === false)
|
|
272
|
+
return false;
|
|
273
|
+
const recover = async () => {
|
|
274
|
+
if (deps.isCurrent) {
|
|
275
|
+
if (!deps.topicTargetStore.invalidateTarget ||
|
|
276
|
+
!await deps.topicTargetStore.invalidateTarget(target, deps.isCurrent, String(error)))
|
|
277
|
+
return false;
|
|
278
|
+
if (deps.isAuthorityCurrent?.() === false)
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
await deps.topicTargetStore.load();
|
|
283
|
+
if (!deps.topicTargetStore.markStaleByTarget(target, "deleted", String(error)))
|
|
284
|
+
return false;
|
|
285
|
+
await deps.topicTargetStore.persist();
|
|
286
|
+
}
|
|
287
|
+
const nowMs = (deps.getNowMs ?? Date.now)();
|
|
288
|
+
let state = markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
289
|
+
nowMs,
|
|
290
|
+
reason: "stale-api-error",
|
|
291
|
+
action: "topic-target-stale",
|
|
292
|
+
});
|
|
293
|
+
state = markTelegramSyncSliceSuspect(state, "transport-health", {
|
|
294
|
+
nowMs,
|
|
295
|
+
reason: "stale-api-error",
|
|
296
|
+
action: "topic-target-stale",
|
|
297
|
+
});
|
|
298
|
+
state = markTelegramSyncSliceSuspect(state, "target-bindings", {
|
|
299
|
+
nowMs,
|
|
300
|
+
reason: "stale-api-error",
|
|
301
|
+
action: "topic-target-stale",
|
|
302
|
+
});
|
|
303
|
+
deps.setSyncState(state);
|
|
304
|
+
deps.recordEvent("bus", error, {
|
|
305
|
+
phase: "topic-target-stale",
|
|
306
|
+
chatId: target.chatId,
|
|
307
|
+
threadId: target.threadId,
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
};
|
|
311
|
+
if (!deps.getWorkspaceAdmission)
|
|
312
|
+
return recover();
|
|
313
|
+
const admission = deps.getWorkspaceAdmission();
|
|
314
|
+
if (!admission) {
|
|
315
|
+
throw new Error("Telegram Workspace admission authority is unavailable.");
|
|
316
|
+
}
|
|
317
|
+
return runWithTelegramWorkspaceAdmissionsAsync({
|
|
318
|
+
ledger: admission,
|
|
319
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
320
|
+
operationKind: "workspace.recover-stale-target",
|
|
321
|
+
scopes: [{ kind: "target", target }],
|
|
322
|
+
operation: recover,
|
|
323
|
+
onReleaseError(releaseError) {
|
|
324
|
+
deps.recordEvent("bus", releaseError, {
|
|
325
|
+
phase: "workspace-admission-release",
|
|
326
|
+
operationKind: "workspace.recover-stale-target",
|
|
327
|
+
chatId: target.chatId,
|
|
328
|
+
threadId: target.threadId,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
export async function ensureTelegramLeaderThreadBinding(deps) {
|
|
334
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
335
|
+
const assertLeaderEpoch = (phase) => {
|
|
336
|
+
if (deps.getCurrentLeaderEpoch &&
|
|
337
|
+
(leaderEpoch === undefined ||
|
|
338
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch)) {
|
|
339
|
+
throw new Error(`Telegram leader thread binding lost ownership (${phase}).`);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
assertLeaderEpoch("start");
|
|
343
|
+
await deps.topicTargetStore.load();
|
|
344
|
+
assertLeaderEpoch("after-load");
|
|
345
|
+
const normalizedLeaderCwd = deps.cwd
|
|
346
|
+
? normalizeTelegramWorkspacePath(deps.cwd)
|
|
347
|
+
: undefined;
|
|
348
|
+
const leaderOwner = {
|
|
349
|
+
kind: "leader",
|
|
350
|
+
cwd: normalizedLeaderCwd,
|
|
351
|
+
instanceId: deps.instanceId,
|
|
352
|
+
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
|
|
353
|
+
};
|
|
354
|
+
const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
|
|
355
|
+
const legacyLeaderRecord = deps.topicTargetStore.getByProfileKey(leaderProfileKey);
|
|
356
|
+
const workspaceIdentity = normalizedLeaderCwd
|
|
357
|
+
? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, { sessionId: deps.sessionId })
|
|
358
|
+
: undefined;
|
|
359
|
+
if (deps.cwd && !workspaceIdentity) {
|
|
360
|
+
throw new Error("Telegram Workspace identity is already claimed.");
|
|
361
|
+
}
|
|
362
|
+
const commitWorkspaceBinding = async (result) => {
|
|
363
|
+
if (!workspaceIdentity)
|
|
364
|
+
return result;
|
|
365
|
+
const committed = commitTelegramWorkspaceProvisionBinding({
|
|
366
|
+
store: deps.topicTargetStore,
|
|
367
|
+
instanceId: deps.instanceId,
|
|
368
|
+
profileKey: leaderProfileKey,
|
|
369
|
+
displayTitle: result.displayTitle,
|
|
370
|
+
binding: {
|
|
371
|
+
...workspaceIdentity,
|
|
372
|
+
target: { ...result.target },
|
|
373
|
+
...(result.threadName ? { threadName: result.threadName } : {}),
|
|
374
|
+
...(result.slot ? { slot: result.slot } : {}),
|
|
375
|
+
journalBindingKeys: [],
|
|
376
|
+
journalBindingsComplete: true,
|
|
377
|
+
updatedAtMs: deps.getNowMs?.() ?? Date.now(),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
deps.topicTargetStore.markWorkspaceBindingActiveByTarget(result.target);
|
|
381
|
+
assertLeaderEpoch("before-workspace-persist");
|
|
382
|
+
await deps.topicTargetStore.persist();
|
|
383
|
+
assertLeaderEpoch("after-workspace-persist");
|
|
384
|
+
return { ...result, ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}) };
|
|
385
|
+
};
|
|
386
|
+
try {
|
|
387
|
+
const unavailableTargetKeys = new Set([
|
|
388
|
+
...deps.topicTargetStore
|
|
389
|
+
.listSyncObservations()
|
|
390
|
+
.filter((observation) => observation.syncStatus === "deleted")
|
|
391
|
+
.map((observation) => getTelegramTargetKey(observation.target)),
|
|
392
|
+
...deps.topicTargetStore
|
|
393
|
+
.listPendingCleanups()
|
|
394
|
+
.map((intent) => getTelegramTargetKey(intent.target)),
|
|
395
|
+
]);
|
|
396
|
+
let invalidatedUnavailableTarget = false;
|
|
397
|
+
for (const record of deps.topicTargetStore.list()) {
|
|
398
|
+
if (record.instanceId !== deps.instanceId ||
|
|
399
|
+
!unavailableTargetKeys.has(getTelegramTargetKey(record.target))) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
invalidatedUnavailableTarget =
|
|
403
|
+
deps.topicTargetStore.markStaleByTarget(record.target) ||
|
|
404
|
+
invalidatedUnavailableTarget;
|
|
405
|
+
}
|
|
406
|
+
if (invalidatedUnavailableTarget) {
|
|
407
|
+
assertLeaderEpoch("before-unavailable-persist");
|
|
408
|
+
await deps.topicTargetStore.persist();
|
|
409
|
+
assertLeaderEpoch("after-unavailable-persist");
|
|
410
|
+
}
|
|
411
|
+
const persistedWorkspaceBinding = workspaceIdentity
|
|
412
|
+
? deps.topicTargetStore.getWorkspaceBinding(workspaceIdentity.cwd, workspaceIdentity.instanceSlot, workspaceIdentity.sessionId)
|
|
413
|
+
: undefined;
|
|
414
|
+
const legacyWorkspaceBinding = workspaceIdentity?.instanceSlot === "a" &&
|
|
415
|
+
!persistedWorkspaceBinding &&
|
|
416
|
+
typeof legacyLeaderRecord?.target.threadId === "number"
|
|
417
|
+
? {
|
|
418
|
+
...workspaceIdentity,
|
|
419
|
+
target: { ...legacyLeaderRecord.target },
|
|
420
|
+
...(legacyLeaderRecord.threadName
|
|
421
|
+
? { threadName: legacyLeaderRecord.threadName }
|
|
422
|
+
: {}),
|
|
423
|
+
...(legacyLeaderRecord.slot ? { slot: legacyLeaderRecord.slot } : {}),
|
|
424
|
+
updatedAtMs: legacyLeaderRecord.updatedAtMs,
|
|
425
|
+
}
|
|
426
|
+
: undefined;
|
|
427
|
+
const recoverableWorkspaceBinding = persistedWorkspaceBinding ?? legacyWorkspaceBinding;
|
|
428
|
+
const recoverableTargetKey = recoverableWorkspaceBinding
|
|
429
|
+
? getTelegramTargetKey(recoverableWorkspaceBinding.target)
|
|
430
|
+
: undefined;
|
|
431
|
+
const recoverableRecord = recoverableWorkspaceBinding
|
|
432
|
+
? deps.topicTargetStore
|
|
433
|
+
.list()
|
|
434
|
+
.find((record) => getTelegramTargetKey(record.target) === recoverableTargetKey)
|
|
435
|
+
: undefined;
|
|
436
|
+
const sameProcessWorkspaceBinding = !!recoverableRecord &&
|
|
437
|
+
(recoverableRecord.instanceId === deps.instanceId ||
|
|
438
|
+
isSameTelegramProcessInstance(recoverableRecord.instanceId, deps.instanceId));
|
|
439
|
+
if (recoverableWorkspaceBinding &&
|
|
440
|
+
!unavailableTargetKeys.has(recoverableTargetKey ?? "") &&
|
|
441
|
+
(!deps.forceFreshUnnamed || recoverableWorkspaceBinding.threadName) &&
|
|
442
|
+
(sameProcessWorkspaceBinding || deps.probeWorkspaceBinding)) {
|
|
443
|
+
let workspaceTargetVisible = sameProcessWorkspaceBinding;
|
|
444
|
+
if (!workspaceTargetVisible && deps.probeWorkspaceBinding) {
|
|
445
|
+
assertLeaderEpoch("before-workspace-probe");
|
|
446
|
+
try {
|
|
447
|
+
await deps.probeWorkspaceBinding(recoverableWorkspaceBinding);
|
|
448
|
+
assertLeaderEpoch("after-workspace-probe");
|
|
449
|
+
workspaceTargetVisible = true;
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (!isTelegramTopicTargetStaleError(error))
|
|
453
|
+
throw error;
|
|
454
|
+
deps.topicTargetStore.markStaleByTarget(recoverableWorkspaceBinding.target, "deleted", error instanceof Error ? error.message : String(error));
|
|
455
|
+
await deps.topicTargetStore.persist();
|
|
456
|
+
deps.recordEvent("bus", error, {
|
|
457
|
+
phase: "leader-workspace-target-stale",
|
|
458
|
+
chatId: recoverableWorkspaceBinding.target.chatId,
|
|
459
|
+
threadId: recoverableWorkspaceBinding.target.threadId,
|
|
460
|
+
instanceId: deps.instanceId,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (workspaceTargetVisible) {
|
|
465
|
+
const nowMs = deps.getNowMs?.() ?? Date.now();
|
|
466
|
+
const slot = workspaceIdentity?.slot;
|
|
467
|
+
if (!slot) {
|
|
468
|
+
throw new Error("Telegram Thread slot authority is unavailable.");
|
|
469
|
+
}
|
|
470
|
+
const recovered = await commitWorkspaceBinding({
|
|
471
|
+
target: { ...recoverableWorkspaceBinding.target },
|
|
472
|
+
slot,
|
|
473
|
+
...(recoverableWorkspaceBinding.threadName
|
|
474
|
+
? { threadName: recoverableWorkspaceBinding.threadName }
|
|
475
|
+
: {}),
|
|
476
|
+
reused: true,
|
|
477
|
+
});
|
|
478
|
+
deps.topicTargetStore.upsert({
|
|
479
|
+
profileKey: leaderProfileKey,
|
|
480
|
+
owner: leaderOwner,
|
|
481
|
+
target: { ...recovered.target },
|
|
482
|
+
status: "active",
|
|
483
|
+
createdAtMs: recoverableRecord?.createdAtMs ?? nowMs,
|
|
484
|
+
updatedAtMs: nowMs,
|
|
485
|
+
...(recovered.threadName ? { threadName: recovered.threadName } : {}),
|
|
486
|
+
instanceId: deps.instanceId,
|
|
487
|
+
slot: recovered.slot,
|
|
488
|
+
syncStatus: "open",
|
|
489
|
+
lastSyncObservedAtMs: nowMs,
|
|
490
|
+
lastReconcileAction: "leader-workspace-binding-recovered",
|
|
491
|
+
});
|
|
492
|
+
assertLeaderEpoch("before-recovered-record-persist");
|
|
493
|
+
await deps.topicTargetStore.persist();
|
|
494
|
+
assertLeaderEpoch("after-recovered-record-persist");
|
|
495
|
+
deps.recordEvent("telegram", "Leader thread preserved after Workspace recovery", {
|
|
496
|
+
phase: "leader-thread-reused",
|
|
497
|
+
instanceId: deps.instanceId,
|
|
498
|
+
chatId: recovered.target.chatId,
|
|
499
|
+
threadId: recovered.target.threadId,
|
|
500
|
+
slot: recovered.slot,
|
|
501
|
+
});
|
|
502
|
+
return recovered;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const priorTargets = deps.topicTargetStore.list().filter((record) => {
|
|
506
|
+
return (record.instanceId === deps.instanceId &&
|
|
507
|
+
(record.status === "active" || record.status === "starting"));
|
|
508
|
+
});
|
|
509
|
+
// Short-circuit: when the instance already has an active thread and we are not
|
|
510
|
+
// force-freshing, reuse it without re-provisioning. A thread belongs to the
|
|
511
|
+
// live instance binding, not to one transient Pi session lifecycle.
|
|
512
|
+
if (!deps.forceFreshUnnamed && priorTargets.length > 0) {
|
|
513
|
+
const record = priorTargets[0];
|
|
514
|
+
deps.recordEvent("telegram", "Leader thread preserved after session lifecycle change", {
|
|
515
|
+
phase: "leader-thread-reused",
|
|
516
|
+
instanceId: deps.instanceId,
|
|
517
|
+
chatId: record.target.chatId,
|
|
518
|
+
threadId: record.target.threadId,
|
|
519
|
+
slot: record.slot,
|
|
520
|
+
});
|
|
521
|
+
assertLeaderEpoch("before-reuse");
|
|
522
|
+
if (!record.slot) {
|
|
523
|
+
throw new Error("Telegram Thread slot authority is unavailable.");
|
|
524
|
+
}
|
|
525
|
+
return await commitWorkspaceBinding({
|
|
526
|
+
target: record.target,
|
|
527
|
+
slot: record.slot,
|
|
528
|
+
...(record.threadName ? { threadName: record.threadName } : {}),
|
|
529
|
+
reused: true,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
let forcedUnnamedStale = false;
|
|
533
|
+
if (deps.forceFreshUnnamed) {
|
|
534
|
+
for (const record of priorTargets) {
|
|
535
|
+
const isLeaderOwned = record.owner?.kind === "leader" ||
|
|
536
|
+
(!record.owner &&
|
|
537
|
+
(record.profileKey.startsWith("cwd:") ||
|
|
538
|
+
record.profileKey.startsWith("leader:")));
|
|
539
|
+
if (!isLeaderOwned)
|
|
540
|
+
continue;
|
|
541
|
+
if (record.threadName)
|
|
542
|
+
continue;
|
|
543
|
+
forcedUnnamedStale =
|
|
544
|
+
deps.topicTargetStore.markStaleByTarget(record.target) ||
|
|
545
|
+
forcedUnnamedStale;
|
|
546
|
+
deps.recordEvent("telegram", "Unnamed leader thread binding refreshed", {
|
|
547
|
+
phase: "leader-thread-force-fresh-unnamed",
|
|
548
|
+
instanceId: deps.instanceId,
|
|
549
|
+
chatId: record.target.chatId,
|
|
550
|
+
threadId: record.target.threadId,
|
|
551
|
+
slot: record.slot,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
if (forcedUnnamedStale)
|
|
555
|
+
await deps.topicTargetStore.persist();
|
|
556
|
+
}
|
|
557
|
+
assertLeaderEpoch("before-provision");
|
|
558
|
+
const ownTarget = await provisionOwnBusTopic({
|
|
559
|
+
getAllowedUserId: deps.getAllowedUserId,
|
|
560
|
+
instanceId: deps.instanceId,
|
|
561
|
+
cwd: normalizedLeaderCwd,
|
|
562
|
+
telegramProfile: deps.telegramProfile,
|
|
563
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
564
|
+
getThreadReconciliationMachineState: deps.getThreadReconciliationMachineState,
|
|
565
|
+
recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
|
|
566
|
+
store: deps.topicTargetStore,
|
|
567
|
+
callApi: deps.callApi,
|
|
568
|
+
getNowMs: deps.getNowMs,
|
|
569
|
+
getRandom: deps.getRandom,
|
|
570
|
+
requestedThreadName: recoverableWorkspaceBinding?.threadName ?? deps.requestedThreadName,
|
|
571
|
+
workspaceBindingKey: workspaceIdentity?.bindingKey,
|
|
572
|
+
preferredSlot: workspaceIdentity?.slot,
|
|
573
|
+
resolveInitialWorkspaceDisplayTitle: deps.resolveInitialWorkspaceDisplayTitle,
|
|
574
|
+
recordEvent: deps.recordEvent,
|
|
575
|
+
});
|
|
576
|
+
assertLeaderEpoch("after-provision");
|
|
577
|
+
if (!ownTarget)
|
|
578
|
+
return undefined;
|
|
579
|
+
const replacementPlan = ThreadReconciler.planThreadReconciliation({
|
|
580
|
+
nowMs: Date.now(),
|
|
581
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
582
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
583
|
+
records: priorTargets,
|
|
584
|
+
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
|
|
585
|
+
replacedBindings: [
|
|
586
|
+
{
|
|
587
|
+
instanceId: deps.instanceId,
|
|
588
|
+
replacementTarget: ownTarget.target,
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
});
|
|
592
|
+
deps.recordThreadReconciliationPlan?.(replacementPlan);
|
|
593
|
+
await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
|
|
594
|
+
isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
|
|
595
|
+
callApi: deps.callApi,
|
|
596
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) => deps.topicTargetStore.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
597
|
+
persist: () => deps.topicTargetStore.persist(),
|
|
598
|
+
removePendingProvisionById: (id) => deps.topicTargetStore.removePendingProvision(id),
|
|
599
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
600
|
+
recordRuntimeEvent: deps.recordEvent,
|
|
601
|
+
});
|
|
602
|
+
assertLeaderEpoch("before-final-persist");
|
|
603
|
+
await deps.topicTargetStore.persist();
|
|
604
|
+
assertLeaderEpoch("after-final-persist");
|
|
605
|
+
return await commitWorkspaceBinding(ownTarget);
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
deps.topicTargetStore.releaseWorkspaceClaim(deps.instanceId);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
export const TELEGRAM_SYNC_SLICE_TARGET_BINDINGS = "target-bindings";
|
|
612
|
+
export const TELEGRAM_SYNC_SLICES = [
|
|
613
|
+
"bot-identity",
|
|
614
|
+
"bot-capabilities",
|
|
615
|
+
"pairing",
|
|
616
|
+
"allowed-user",
|
|
617
|
+
"topic-capability",
|
|
618
|
+
"topic-state",
|
|
619
|
+
TELEGRAM_SYNC_SLICE_TARGET_BINDINGS,
|
|
620
|
+
"reservations",
|
|
621
|
+
"transport-health",
|
|
622
|
+
];
|
|
623
|
+
export function createUnknownTelegramSyncState() {
|
|
624
|
+
return Object.fromEntries(TELEGRAM_SYNC_SLICES.map((slice) => [slice, { status: "unknown" }]));
|
|
625
|
+
}
|
|
626
|
+
export function createTelegramConfigSyncPersister(deps) {
|
|
627
|
+
return async (config) => {
|
|
628
|
+
await deps.persist(config);
|
|
629
|
+
deps.markConfigChange("config-persist");
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
export function createTelegramSyncStateRuntime(initialState = createUnknownTelegramSyncState()) {
|
|
633
|
+
let state = initialState;
|
|
634
|
+
return {
|
|
635
|
+
getState: () => state,
|
|
636
|
+
setState(nextState) {
|
|
637
|
+
state = nextState;
|
|
638
|
+
},
|
|
639
|
+
markConfigChange(action) {
|
|
640
|
+
state = markTelegramConfigSyncChange(state, action);
|
|
641
|
+
},
|
|
642
|
+
markSliceFresh(slice, options) {
|
|
643
|
+
state = markTelegramSyncSliceFresh(state, slice, options);
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
export function createTelegramProvisioningActivityRuntime() {
|
|
648
|
+
let activeCount = 0;
|
|
649
|
+
return {
|
|
650
|
+
isActive: () => activeCount > 0,
|
|
651
|
+
start() {
|
|
652
|
+
activeCount += 1;
|
|
653
|
+
},
|
|
654
|
+
end() {
|
|
655
|
+
activeCount = Math.max(0, activeCount - 1);
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
const RECONCILE_TRIGGERS = new Set([
|
|
660
|
+
"startup",
|
|
661
|
+
"reload",
|
|
662
|
+
"topic-lifecycle",
|
|
663
|
+
"stale-api-error",
|
|
664
|
+
"setup-change",
|
|
665
|
+
"pairing-change",
|
|
666
|
+
"follower-register",
|
|
667
|
+
"follower-prune",
|
|
668
|
+
"status-request",
|
|
669
|
+
"leader-health-tick",
|
|
670
|
+
]);
|
|
671
|
+
export function shouldReconcileTelegramSync(trigger) {
|
|
672
|
+
return RECONCILE_TRIGGERS.has(trigger);
|
|
673
|
+
}
|
|
674
|
+
export function markTelegramSyncSliceSuspect(state, slice, input) {
|
|
675
|
+
return {
|
|
676
|
+
...state,
|
|
677
|
+
[slice]: {
|
|
678
|
+
...(state[slice] ?? { status: "unknown" }),
|
|
679
|
+
status: "suspect",
|
|
680
|
+
suspectAtMs: input.nowMs,
|
|
681
|
+
reason: input.reason,
|
|
682
|
+
lastReconcileAction: input.action,
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
export function markTelegramSyncSliceFresh(state, slice, input) {
|
|
687
|
+
return {
|
|
688
|
+
...state,
|
|
689
|
+
[slice]: {
|
|
690
|
+
status: "fresh",
|
|
691
|
+
updatedAtMs: input.nowMs,
|
|
692
|
+
lastReconcileAction: input.action,
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
export function createTelegramObservedTopicLifecycleSyncHandler(deps) {
|
|
697
|
+
const syncTopicLifecycle = createTelegramTopicLifecycleSyncHandler(deps);
|
|
698
|
+
const operation = async (lifecycle) => {
|
|
699
|
+
const nowMs = deps.getNowMs ?? Date.now;
|
|
700
|
+
deps.assertExecutionCurrent?.(lifecycle.message);
|
|
701
|
+
deps.setSyncState(markTelegramSyncSliceSuspect(deps.getSyncState(), "topic-state", {
|
|
702
|
+
nowMs: nowMs(),
|
|
703
|
+
reason: `topic-${lifecycle.kind}`,
|
|
704
|
+
action: "topic-lifecycle",
|
|
705
|
+
}));
|
|
706
|
+
await syncTopicLifecycle(lifecycle);
|
|
707
|
+
deps.assertExecutionCurrent?.(lifecycle.message);
|
|
708
|
+
deps.setSyncState(markTelegramSyncSliceFresh(deps.getSyncState(), "topic-state", {
|
|
709
|
+
nowMs: nowMs(),
|
|
710
|
+
action: "topic-lifecycle",
|
|
711
|
+
}));
|
|
712
|
+
};
|
|
713
|
+
return (lifecycle) => deps.runWorkspaceOperation({
|
|
714
|
+
operationId: createTelegramWorkspaceAdmissionOperationId(),
|
|
715
|
+
operationKind: "workspace.sync-topic-lifecycle",
|
|
716
|
+
scopes: [{ kind: "profile" }],
|
|
717
|
+
}, () => operation(lifecycle));
|
|
718
|
+
}
|
|
719
|
+
export function createTelegramTopicLifecycleSyncHandler(deps) {
|
|
720
|
+
return async (lifecycle) => {
|
|
721
|
+
deps.assertExecutionCurrent?.(lifecycle.message);
|
|
722
|
+
await deps.topicTargetStore.load();
|
|
723
|
+
deps.assertExecutionCurrent?.(lifecycle.message);
|
|
724
|
+
const nowMs = Date.now();
|
|
725
|
+
const plan = ThreadReconciler.planThreadReconciliation({
|
|
726
|
+
nowMs,
|
|
727
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
728
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
729
|
+
records: deps.topicTargetStore.list(),
|
|
730
|
+
reservations: deps.topicTargetStore.listReservations(),
|
|
731
|
+
pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
|
|
732
|
+
observations: [
|
|
733
|
+
{
|
|
734
|
+
target: lifecycle.target,
|
|
735
|
+
syncStatus: lifecycle.kind === "closed" ? "closed" : "open",
|
|
736
|
+
observedAtMs: nowMs,
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
});
|
|
740
|
+
deps.recordThreadReconciliationPlan?.(plan);
|
|
741
|
+
const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
|
|
742
|
+
markActiveByTarget: (target) => deps.topicTargetStore.markActiveByTarget(target),
|
|
743
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) => deps.topicTargetStore.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
744
|
+
persist: () => deps.topicTargetStore.persist(),
|
|
745
|
+
removePendingProvisionById: (id) => deps.topicTargetStore.removePendingProvision(id),
|
|
746
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
747
|
+
recordRuntimeEvent: deps.recordEvent,
|
|
748
|
+
});
|
|
749
|
+
const changed = result.changed;
|
|
750
|
+
if (lifecycle.kind === "created" && deps.isBusEnabled()) {
|
|
751
|
+
const target = lifecycle.target;
|
|
752
|
+
const isKnownInRecords = deps.topicTargetStore.list().some((record) => {
|
|
753
|
+
return (record.target.chatId === target.chatId &&
|
|
754
|
+
record.target.threadId === target.threadId);
|
|
755
|
+
});
|
|
756
|
+
const isKnownInReservations = deps.topicTargetStore
|
|
757
|
+
.listReservations()
|
|
758
|
+
.some((reservation) => {
|
|
759
|
+
return (reservation.target.chatId === target.chatId &&
|
|
760
|
+
reservation.target.threadId === target.threadId);
|
|
761
|
+
});
|
|
762
|
+
if (!isKnownInRecords && !isKnownInReservations) {
|
|
763
|
+
deps.recordEvent?.("telegram", deps.isTopicProvisioningActive?.()
|
|
764
|
+
? "Telegram unknown topic creation observed during provisioning"
|
|
765
|
+
: "Telegram unknown topic creation observed", {
|
|
766
|
+
phase: deps.isTopicProvisioningActive?.()
|
|
767
|
+
? "topic-lifecycle-provisioning-skip"
|
|
768
|
+
: "topic-lifecycle-unknown-created-observed",
|
|
769
|
+
chatId: target.chatId,
|
|
770
|
+
threadId: target.threadId,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
deps.recordEvent?.("telegram", "Telegram topic lifecycle update", {
|
|
775
|
+
phase: "topic-lifecycle",
|
|
776
|
+
lifecycle: lifecycle.kind,
|
|
777
|
+
chatId: lifecycle.target.chatId,
|
|
778
|
+
threadId: lifecycle.target.threadId,
|
|
779
|
+
changed,
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
}
|