@llblab/pi-kit 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +8 -0
- package/node_modules/@llblab/pi-telegram/README.md +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +1 -2
- package/node_modules/@llblab/pi-telegram/index.ts +28 -7
- package/node_modules/@llblab/pi-telegram/lib/config.ts +28 -39
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +107 -2
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +4 -2
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +66 -24
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +0 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +11 -4
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@llblab/pi-kit` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.1.8 - 2026-08-23
|
|
6
|
+
|
|
7
|
+
- `Telegram Releases`: Advances `@llblab/pi-telegram` to `0.37.1`, bringing journal-owned polling admission state from `0.37.0` plus the settings-manager compatibility hotfix for `/start`, model, and queue menus.
|
|
8
|
+
- `Release Verification`: Allows up to ten minutes for Trusted Publisher releases to become publicly readable before package identity and bundled inventory verification, avoiding false failures from npm registry propagation delay.
|
|
9
|
+
|
|
5
10
|
## 0.1.7 - 2026-08-22
|
|
6
11
|
|
|
7
12
|
- `Telegram Hotfix`: Advances `@llblab/pi-telegram` to `0.36.11`, bringing at-most-once durable prompt dispatch that prevents session or process replacement from automatically replaying an already-admitted Telegram prompt.
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
| `@llblab/pi-actors` | `0.50.0` | Extension and Skills |
|
|
10
10
|
| `@llblab/pi-codex-usage` | `0.9.3` | Extension |
|
|
11
11
|
| `@llblab/pi-grow-loop` | `0.7.2` | Extension and Skills |
|
|
12
|
-
| `@llblab/pi-telegram` | `0.
|
|
12
|
+
| `@llblab/pi-telegram` | `0.37.1` | Extension and Skills |
|
|
13
13
|
|
|
14
14
|
Versions are exact by design. Updating an extension does not change an installed kit until this repository explicitly advances that dependency and publishes a new kit version.
|
|
15
15
|
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.37.1: Settings Manager Compatibility Hotfix
|
|
6
|
+
|
|
7
|
+
- `Menu Compatibility`: Keeps `/start`, model, and queue menu rendering compatible with Pi settings-manager implementations that do not expose `reload()`. Hosts with reload retain explicit refresh behavior; other hosts use the freshly constructed settings snapshot instead of failing with `settingsManager.reload is not a function`.
|
|
8
|
+
|
|
9
|
+
## 0.37.0: Journal-Owned Telegram Admission
|
|
10
|
+
|
|
11
|
+
- `Configuration-Only State`: Moves the per-profile polling cursor out of `telegram.json` into atomic durable journal revisions, including cursor-only initial sync, compaction, reconstruction, and recovery. A journal-first one-shot cutover removes legacy config state idempotently, exact bot/profile fences remain enforced, status reads journal authority, and unsafe downgrade now fails closed.
|
|
12
|
+
|
|
5
13
|
## 0.36.11: At-Most-Once Prompt Dispatch Hotfix
|
|
6
14
|
|
|
7
15
|
- `At-Most-Once Prompt Dispatch`: Commits each exact durable Telegram receipt synchronously before Pi model admission and blocks dispatch when that commit fails, closing the session/process-replacement replay window that could deliver an already-processed old prompt again; the narrow commit-before-admission crash boundary now favors no duplicate over retry.
|
|
@@ -274,7 +274,7 @@ Stable public entrypoints are documented in [Public API](./docs/public-api.md),
|
|
|
274
274
|
|
|
275
275
|
## Safety Boundaries
|
|
276
276
|
|
|
277
|
-
Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.
|
|
277
|
+
Durable inbound admission is a **process-crash recovery** guarantee. Atomic private-file replacement preserves acknowledged journal authority and its journal-owned `acceptedThroughUpdateId` polling cursor across ordinary process exit, crash, kill, and replacement, but the extension does not flush files or parent directories for host/kernel/filesystem/device/power-loss durability. `telegram.json` contains configuration only. Keep `~/.pi/agent` on appropriately managed storage and backups if that stronger operational guarantee is required. Before downgrading below `0.37.0`, run `node scripts/check-downgrade.mjs`; any retained cursor-schema journal blocks downgrade because an older runtime could repoll admitted updates. See [Durable Admission And Recovery](./docs/architecture.md#durable-admission-and-recovery).
|
|
278
278
|
|
|
279
279
|
`pi-telegram` intentionally does not:
|
|
280
280
|
|
|
@@ -116,9 +116,9 @@ Mirrored domain regressions live in `/tests/*.test.ts`. Shared test fixtures sho
|
|
|
116
116
|
|
|
117
117
|
## Configuration And Ownership
|
|
118
118
|
|
|
119
|
-
Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId
|
|
119
|
+
Telegram configuration lives in `~/.pi/agent/telegram.json`. Bot/session identity (`botToken`, `botUsername`, `botId`, `allowedUserId`) persists only under `profiles.default` or `profiles.<name>`; shared handlers and assistant/voice/time settings stay top-level. Per-profile polling/admission state lives only in the durable update journal as `acceptedThroughUpdateId`. Authoritative transport ownership lives separately in the pi-telegram-private `~/.pi/agent/tmp/telegram/owners.json` store. Its top-level slots are `default` and validated named profile names; unrelated extensions never read or write this file.
|
|
120
120
|
|
|
121
|
-
`telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers
|
|
121
|
+
`telegram.json` is one global cross-instance configuration document. Ordinary reads rely on atomic publication and do not take the mutation guard. Every cooperating Pi instance persists only its recursive delta from the snapshot it loaded, merges that delta into the latest disk document inside `telegram.json.transaction`, and publishes atomically only when the semantic result differs; a no-op merge adopts the newer disk snapshot in memory without replacing the file. Unrelated global and profile changes therefore survive stale writers. Two serialized writers changing the same leaf use commit order, so the later local delta wins. A non-transactional external editor cannot participate in that conflict protocol: it should write through same-directory atomic replacement while Pi is idle, then let instances reload; an editor racing the transaction may lose its same-leaf change and must retry from the resulting file.
|
|
122
122
|
|
|
123
123
|
### Setup Flow
|
|
124
124
|
|
|
@@ -161,9 +161,9 @@ Version `0.24.0` intentionally does not read or migrate the former agent-level `
|
|
|
161
161
|
|
|
162
162
|
Telegram private-chat Threaded Mode is the public switch for multi-instance Telegram operation. Classic single-DM polling is the base mode. When Telegram private-chat threads are available for the bot, the bridge enables the local leader/follower bus automatically; when threads are unavailable or later disabled, the bridge returns to classic single-DM polling as a first-class mode. Before a non-owner `/telegram-connect` chooses follower registration or singleton takeover, it discards process-local status/capability projections and reads the current owner-published mode: `enabled` registers a follower without a takeover prompt, while `disabled` uses the classic confirmation flow.
|
|
163
163
|
|
|
164
|
-
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId
|
|
164
|
+
Named Telegram profiles are orthogonal to Threaded Mode. The selected profile chooses the bot/session slice (`botToken`, `botId`, `botUsername`, `allowedUserId`) and scopes its durable journal cursor, the `owners.json` slot, diagnostics logs, state files, thread/bus ownership, and leader/follower IPC endpoints; it must not change the Threaded Mode rules. Within one selected profile, leader/follower election, bus transport, thread provisioning, routing, ownership forwarding, cleanup, and runtime diagnostics behave exactly as they do for the `default` slot. A different selected profile is a parallel bot runtime: its owner slot, `tmp/telegram/state.<profile>.json`, `tmp/telegram/logs.<profile>.jsonl`, `tmp/telegram/logs.<profile>._prev.jsonl`, thread bindings, Unix sockets, and Windows named pipes are isolated from the default profile and other named profiles while shared bridge settings remain top-level/global.
|
|
165
165
|
|
|
166
|
-
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so
|
|
166
|
+
Profile reality follows three explicit storage classes. `telegram.json` shared settings and extension registries are process-global platform configuration; `profiles.default` and `profiles.<name>` bot/session fields plus observable transport/routing authority are profile-scoped; queues, active turns, ownership caches, menu state, and runtime controllers are session-local memory. Config persistence serializes cross-process writers and applies each recursive mutation delta to the latest disk snapshot, so unrelated global/profile updates do not stale-replace one another. Each profile's journal transaction independently publishes its monotonic admission cursor together with admitted work; polling never writes runtime state through config persistence. Runtime profile switching follows stop-old/commit-new ordering: reload keeps the selected identity stable, old polling/lock/bus teardown finishes while all dynamic resolvers still point at the old profile, and only then may activation expose the new token, lock key, state path, target namespace, and IPC endpoint. Downloaded attachments use UUID-prefixed names in the shared Telegram scratch directory and are session artifacts rather than identity or routing authority, so cross-profile cleanup recognizes only those UUID-prefixed scratch files. It never age-deletes journals, ownership, state, logs, or other top-level runtime files and therefore cannot redirect live traffic or orphan immutable journal segments.
|
|
167
167
|
|
|
168
168
|
When Threaded Mode is active, the current polling owner is also the Telegram bus leader. The leader owns the local bus endpoint (Unix-domain socket on Unix-like platforms, named pipe on native Windows), polls `getUpdates`, performs direct Bot API calls, records follower heartbeats, prunes stale followers, and provisions Telegram UI thread targets through live runtime/bus state. Follower liveness is intentionally fast because heartbeat traffic is local IPC: followers heartbeat every `1s`, the leader treats them as stale after `2s`, and the prune loop runs every `1s` so stopped followers are detected promptly while active forwarded updates/API calls still refresh liveness. Heartbeat pruning is silent liveness bookkeeping and does not send a Telegram-visible disconnected notice, because the common cause may be leader reload or IPC handoff rather than a dead follower. Pruning alone preserves the binding; when Thread cleanup is enabled, only a subsequent OS check that confirms the exact registered PID absent may create fenced cleanup intent, and that cleanup serializes ahead of replacement registration. Successful follower target reuse refreshes the binding's recovery timestamp. Absent follower bindings remain durable restoration hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; process absence and heartbeat pruning alone do not remove them. If an authenticated live follower carries an exact target that is absent from current bindings, the leader recovers it only behind a synchronous visibility probe: success activates it, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. A carried slot is restored only when it is not already occupied. `tmp/telegram/logs.jsonl` is a session-local redacted runtime evidence stream for race debugging; it resets on extension start and runtime scope changes, and must not become routing/provisioning authority. `tmp/telegram/state.json` is an extension+bot observable/debug snapshot aligned with status diagnostics: `source: "snapshot"` and `writtenAtMs` mark it as observational, not authoritative. All instances on one Telegram profile read the same snapshot, but only the active transport lock owner persists it; followers become writers only after promotion. Status-only persistence reloads current disk bindings before serialization, preventing a stale follower/status view from erasing newer leader-owned targets. Fresh capability observations may skip redundant startup probes, but stale snapshots re-probe before suppressing bus/thread behavior. Top-level `bot` mirrors bot-wide capabilities such as thread mode, `runtime` describes process role/status, `liveRoster` mirrors followers/current targets/reservations, `diagnostics` mirrors recent status/debug signals including the latest thread-reconciler phase/counts, `threads` stores current routeable bindings, TTL-bounded reservations explain short-lived slot collision guards, and TTL-pruned `pendingProvisions` protects in-flight topic creation slots from cleanup/allocation races. Fresh provisioning writes pending state before the Bot API create call, adds the returned target to the pending record, persists a `starting` binding, then promotes it to `active` and clears pending state. If final binding persistence fails after Telegram returns a thread id, the targeted pending provision remains as cleanup/retry evidence. Once targeted pending provisions expire, they are retained for `thread-reconciler` close/delete cleanup and pending scratchpad removal after a successful cleanup apply; untargeted expired pending records can prune without cleanup because no Telegram thread id exists. Runtime events coalesce status-snapshot writes so transient bus/API/update failures remain inspectable even when the operator has not opened `/telegram-status`. The bridge must not keep a durable `telegram-targets.json` target history; stale/offline/failed thread observations are pruned instead of reused. Previous-process leader bindings that still probe alive become reservations/collision guards, not routeable active threads, so a reloaded leader can take the next free slot without duplicating the same visible tab name. The thread chat is always the private bot DM with the paired owner (`allowedUserId`). In Telegram private-chat Threaded Mode, the leader creates/reuses its own thread before polling — it is a real bound instance, not a dispatcher. Followers authenticate bus envelopes with the leader-minted capability secret stored in the active lock entry. Bot capability monitoring does not probe through the bus until the process either owns that direct lock or has completed authenticated follower registration. Leader lock entries also carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned cleanup/provisioning plans stamp that epoch, and Thread Reconciler apply skips destructive work if leadership has moved on before side effects run. Followers own their own Pi session state, queue, active turns, previews, menus, and lifecycle hooks, but route allowlisted, target-scoped Telegram API calls through the leader. When a follower promotes after heartbeat loss, status/state diagnostics expose only the transient `electing` lifecycle phase; stable `leader`/`follower` identity stays in the bus role so diagnostics do not duplicate role state. The TUI status bar and `/telegram-status` report `leader` or `follower` role so a registered follower is not shown as generically disconnected. Terminal status identity and the `[telegram|thread:name]` prompt label use the same target-aware current-instance resolver: registered local metadata wins over a stale shared binding for the matching target, while the binding remains a fallback for partial metadata.
|
|
169
169
|
|
|
@@ -240,7 +240,7 @@ The canonical update transition contract is:
|
|
|
240
240
|
|
|
241
241
|
The worker executes at most 64 eligible entries from one validated journal snapshot, commits ordinary completions through one journal transaction, then yields through a generation-checked event-loop boundary. Retry, queue, or prior-generation boundaries first flush completed ids and force a fresh snapshot, preserving exact state-transition atomicity without per-entry parse/rewrite churn. A deterministic 2,048-entry stress gate requires exactly 32 completion publications, 33 reads including the final empty snapshot, continued 1ms timer progress, and less than 250ms maximum observed heartbeat delay. Byte-capacity tests cover failed and retry-wait diagnostics, queue receipt/owner and handoff metadata, and operator dispositions; every rejected growth leaves the prior authority bytes unchanged. It still scans later independent entries after retry or terminal persistence. An unresolved reaction remains a queue-mutation dependency even in `retry-wait` or `failed`, but dispatch checks that dependency against the candidate queue item's exact chat and source message ids instead of globally blocking unrelated targets. Successful replay or an exact discard disposition releases the dependency. Worker state, debug status, state snapshots, and redacted runtime events expose journal depth, retry/terminal counts, the next retry, latest terminal identity, copyable operator commands, and the exact first foreign queued owner identity (instance, PID/birth, session, and acquisition) when semantic authority belongs to another process.
|
|
242
242
|
|
|
243
|
-
Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.
|
|
243
|
+
The journal is the sole polling/admission authority. Each atomic journal revision publishes the admitted batch and monotonic `acceptedThroughUpdateId` together; cursor-only initial synchronization uses an empty batch revision. Existing config cursors are transferred once before polling: journal publication precedes config removal, restart retries are idempotent, established journal authority never regresses, and an unprovable non-empty journal fails closed. Upgrades create journals lazily before the first post-upgrade offset advance. A bot/profile identity change with unresolved authority fails closed. Once reconstructed authority is empty, the next read atomically rebinds profile and bot identity under the journal transaction and removes redundant old-identity segments best-effort; stable-`botId` token rotation remains valid even with entries. Downgrading below `0.37.0` with a cursor-schema journal is unsafe because an older runtime cannot recover `acceptedThroughUpdateId` and could repoll admitted updates. Run `node scripts/check-downgrade.mjs [agent-dir]`; the conservative older-schema validator rejects that retained authority even when entries are drained. Runtime state from an older release must recover without deleting `telegram.json`, ownership state, or journal authority.
|
|
244
244
|
|
|
245
245
|
Polling and inbound-worker diagnostics remain separate so an executing, deferred, locally queued, foreign-queued, or blocked journal head cannot masquerade as a stalled `getUpdates` request.
|
|
246
246
|
|
|
@@ -84,7 +84,6 @@ interface TelegramBotProfile {
|
|
|
84
84
|
botUsername?: string; // runtime-managed
|
|
85
85
|
botId?: number; // runtime-managed
|
|
86
86
|
allowedUserId?: number;
|
|
87
|
-
lastUpdateId?: number; // runtime-managed
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
interface TelegramConfig {
|
|
@@ -114,7 +113,7 @@ interface TelegramConfig {
|
|
|
114
113
|
|
|
115
114
|
Bot/session identity always persists under `profiles.<name>`. The ordinary setup path uses `profiles.default`; `/telegram-setup default` and `/telegram-connect default` are exact aliases for the bare commands. Named profiles use the same shape. Shared handlers plus `assistant`, `voice`, `time`, and `threads` remain top-level. On the first `0.24.0` load, unambiguous legacy root identity moves atomically into `profiles.default`; identical duplicates collapse, complementary fields merge, and conflicting values fail closed without modifying the file.
|
|
116
115
|
|
|
117
|
-
The file is global across Pi instances. Cooperating instances serialize recursive delta merges through `telegram.json.transaction
|
|
116
|
+
The file is global across Pi instances and contains configuration only. The per-profile polling/admission cursor is `acceptedThroughUpdateId` in that profile's private durable update journal; it is not a config key. On first connection after this cut, a legacy config cursor is transferred directly into the journal before polling and then removed from config. Journal publication failure preserves the legacy source; config publication failure leaves the journal authoritative so retry is idempotent. Cooperating instances serialize recursive config delta merges through `telegram.json.transaction` and preserve unrelated global/profile changes from newer disk snapshots. A semantically unchanged merge adopts the latest disk state in memory without replacing the file; later commits win when two deltas intentionally change the same leaf. Same-parent temp-file replacement retries bounded transient `EPERM`, `EACCES`, and `EBUSY` destination contention without deleting the live config or leaving transaction serialization. For manual edits, stop or idle the connected instances, publish a complete valid file atomically, and let them reload. A non-transactional editor racing Pi persistence has no same-leaf conflict guarantee.
|
|
118
117
|
|
|
119
118
|
Hidden/default semantics are represented by absence:
|
|
120
119
|
|
|
@@ -302,11 +302,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
302
302
|
persist: configStore.persist,
|
|
303
303
|
markConfigChange: telegramSyncStateRuntime.markConfigChange,
|
|
304
304
|
});
|
|
305
|
-
const persistTelegramPollingOffset =
|
|
306
|
-
Config.createTelegramPollingOffsetPersister(
|
|
307
|
-
configStore,
|
|
308
|
-
persistTelegramConfigWithSync,
|
|
309
|
-
);
|
|
310
305
|
const {
|
|
311
306
|
current: currentInstanceThreadRuntime,
|
|
312
307
|
status: threadStatusProjectionRuntime,
|
|
@@ -353,6 +348,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
353
348
|
getInboundWorkerState() {
|
|
354
349
|
return updateAdmissionRuntimeBinding.getActive()?.getState();
|
|
355
350
|
},
|
|
351
|
+
getAcceptedThroughUpdateId() {
|
|
352
|
+
return resolveTelegramUpdateJournalBinding()?.journal.read()
|
|
353
|
+
.acceptedThroughUpdateId;
|
|
354
|
+
},
|
|
356
355
|
getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds,
|
|
357
356
|
hasActiveTurn: activeTurnRuntime.has,
|
|
358
357
|
hasDispatchPending: lifecycle.hasDispatchPending,
|
|
@@ -1009,14 +1008,36 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1009
1008
|
hasBotToken: configStore.hasBotToken,
|
|
1010
1009
|
deleteWebhook,
|
|
1011
1010
|
getUpdates,
|
|
1012
|
-
persistConfig:
|
|
1011
|
+
persistConfig: persistTelegramConfigWithSync,
|
|
1013
1012
|
prepareUpdateBatch: textGroupRuntime.prepareUpdateBatch,
|
|
1014
1013
|
journal: {
|
|
1015
|
-
appendBatch(updates) {
|
|
1014
|
+
appendBatch(updates, acceptedThroughUpdateId) {
|
|
1016
1015
|
return updateAdmissionLifecycleRuntime.appendBatch(
|
|
1017
1016
|
updates as Journal.TelegramJournaledUpdate[],
|
|
1017
|
+
acceptedThroughUpdateId,
|
|
1018
1018
|
);
|
|
1019
1019
|
},
|
|
1020
|
+
getAcceptedThroughUpdateId() {
|
|
1021
|
+
return resolveTelegramUpdateJournalBinding()?.journal.read()
|
|
1022
|
+
.acceptedThroughUpdateId;
|
|
1023
|
+
},
|
|
1024
|
+
async prepareCursorCutover() {
|
|
1025
|
+
const binding = resolveTelegramUpdateJournalBinding();
|
|
1026
|
+
if (!binding) {
|
|
1027
|
+
throw new Error("Telegram update journal binding is unavailable.");
|
|
1028
|
+
}
|
|
1029
|
+
await Polling.cutOverTelegramPollingCursor({
|
|
1030
|
+
getLegacyCursor: configStore.getLegacyPollingCursor,
|
|
1031
|
+
readJournal: binding.journal.read,
|
|
1032
|
+
publishJournalCursor(acceptedThroughUpdateId) {
|
|
1033
|
+
binding.journal.appendBatch([], acceptedThroughUpdateId);
|
|
1034
|
+
},
|
|
1035
|
+
async removeLegacyCursor() {
|
|
1036
|
+
configStore.removeLegacyPollingCursor();
|
|
1037
|
+
await persistTelegramConfigWithSync();
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
},
|
|
1020
1041
|
getEntryCount: updateAdmissionLifecycleRuntime.getJournalEntryCount,
|
|
1021
1042
|
signalWorker: updateAdmissionLifecycleRuntime.signal,
|
|
1022
1043
|
getBootstrapEntryCount() {
|
|
@@ -99,8 +99,6 @@ export interface TelegramConfig {
|
|
|
99
99
|
botId?: number;
|
|
100
100
|
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
101
101
|
allowedUserId?: number;
|
|
102
|
-
/** @deprecated persisted identity belongs in profiles.default; retained for effective/legacy views */
|
|
103
|
-
lastUpdateId?: number;
|
|
104
102
|
inboundHandlers?: TelegramInboundHandlerConfig[];
|
|
105
103
|
attachmentHandlers?: TelegramInboundHandlerConfig[];
|
|
106
104
|
outboundHandlers?: TelegramOutboundHandlerConfig[];
|
|
@@ -144,6 +142,9 @@ export interface TelegramBotProfile {
|
|
|
144
142
|
botUsername?: string;
|
|
145
143
|
botId?: number;
|
|
146
144
|
allowedUserId?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface TelegramLegacyCursorCarrier {
|
|
147
148
|
lastUpdateId?: number;
|
|
148
149
|
}
|
|
149
150
|
|
|
@@ -177,6 +178,8 @@ export interface TelegramConfigStore {
|
|
|
177
178
|
getBotToken: () => string | undefined;
|
|
178
179
|
hasBotToken: () => boolean;
|
|
179
180
|
getAllowedUserId: () => number | undefined;
|
|
181
|
+
getLegacyPollingCursor: () => number | undefined;
|
|
182
|
+
removeLegacyPollingCursor: () => void;
|
|
180
183
|
getInboundHandlers: () => TelegramInboundHandlerConfig[] | undefined;
|
|
181
184
|
getAttachmentHandlers: () => TelegramInboundHandlerConfig[] | undefined;
|
|
182
185
|
getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
|
|
@@ -368,15 +371,7 @@ function mergeTelegramConfigDelta(
|
|
|
368
371
|
continue;
|
|
369
372
|
}
|
|
370
373
|
if (!desiredHas) {
|
|
371
|
-
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
if (
|
|
375
|
-
key === "lastUpdateId" &&
|
|
376
|
-
typeof desiredValue === "number" &&
|
|
377
|
-
typeof merged[key] === "number"
|
|
378
|
-
) {
|
|
379
|
-
merged[key] = Math.max(desiredValue, merged[key] as number);
|
|
374
|
+
delete merged[key];
|
|
380
375
|
continue;
|
|
381
376
|
}
|
|
382
377
|
if (
|
|
@@ -433,6 +428,8 @@ export function getTelegramProfileFields(
|
|
|
433
428
|
): TelegramBotProfile | undefined {
|
|
434
429
|
const token = config.botToken?.trim();
|
|
435
430
|
if (!token) return undefined;
|
|
431
|
+
const legacyCursor = (config as TelegramConfig & TelegramLegacyCursorCarrier)
|
|
432
|
+
.lastUpdateId;
|
|
436
433
|
return {
|
|
437
434
|
botToken: token,
|
|
438
435
|
...(config.botUsername !== undefined
|
|
@@ -442,9 +439,7 @@ export function getTelegramProfileFields(
|
|
|
442
439
|
...(config.allowedUserId !== undefined
|
|
443
440
|
? { allowedUserId: config.allowedUserId }
|
|
444
441
|
: {}),
|
|
445
|
-
...(
|
|
446
|
-
? { lastUpdateId: config.lastUpdateId }
|
|
447
|
-
: {}),
|
|
442
|
+
...(legacyCursor !== undefined ? { lastUpdateId: legacyCursor } : {}),
|
|
448
443
|
};
|
|
449
444
|
}
|
|
450
445
|
|
|
@@ -456,7 +451,7 @@ function omitTelegramRootProfileFields(config: TelegramConfig): TelegramConfig {
|
|
|
456
451
|
allowedUserId: _allowedUserId,
|
|
457
452
|
lastUpdateId: _lastUpdateId,
|
|
458
453
|
...sharedConfig
|
|
459
|
-
} = config;
|
|
454
|
+
} = config as TelegramConfig & TelegramLegacyCursorCarrier;
|
|
460
455
|
return sharedConfig;
|
|
461
456
|
}
|
|
462
457
|
|
|
@@ -477,7 +472,8 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
|
|
|
477
472
|
if (Object.hasOwn(config, "botToken") && !legacyToken) {
|
|
478
473
|
throw new Error("Legacy Telegram default profile has no bot token");
|
|
479
474
|
}
|
|
480
|
-
const legacyProfile: Partial<TelegramBotProfile>
|
|
475
|
+
const legacyProfile: Partial<TelegramBotProfile> &
|
|
476
|
+
TelegramLegacyCursorCarrier = {
|
|
481
477
|
...(legacyToken ? { botToken: legacyToken } : {}),
|
|
482
478
|
...(config.botUsername !== undefined
|
|
483
479
|
? { botUsername: config.botUsername }
|
|
@@ -486,8 +482,12 @@ export function normalizeTelegramDefaultProfileConfig(config: TelegramConfig): {
|
|
|
486
482
|
...(config.allowedUserId !== undefined
|
|
487
483
|
? { allowedUserId: config.allowedUserId }
|
|
488
484
|
: {}),
|
|
489
|
-
...(config
|
|
490
|
-
|
|
485
|
+
...((config as TelegramConfig & TelegramLegacyCursorCarrier)
|
|
486
|
+
.lastUpdateId !== undefined
|
|
487
|
+
? {
|
|
488
|
+
lastUpdateId: (config as TelegramConfig & TelegramLegacyCursorCarrier)
|
|
489
|
+
.lastUpdateId,
|
|
490
|
+
}
|
|
491
491
|
: {}),
|
|
492
492
|
};
|
|
493
493
|
if (!canonicalProfile && !legacyToken) {
|
|
@@ -609,6 +609,16 @@ export function createTelegramConfigStore(
|
|
|
609
609
|
getBotToken: () => getEffectiveConfig().botToken,
|
|
610
610
|
hasBotToken: () => !!getEffectiveConfig().botToken,
|
|
611
611
|
getAllowedUserId: () => getEffectiveConfig().allowedUserId,
|
|
612
|
+
getLegacyPollingCursor: () =>
|
|
613
|
+
(getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier)
|
|
614
|
+
.lastUpdateId,
|
|
615
|
+
removeLegacyPollingCursor: () => {
|
|
616
|
+
const next = {
|
|
617
|
+
...(getEffectiveConfig() as TelegramConfig & TelegramLegacyCursorCarrier),
|
|
618
|
+
};
|
|
619
|
+
delete next.lastUpdateId;
|
|
620
|
+
setEffectiveConfig(next);
|
|
621
|
+
},
|
|
612
622
|
getInboundHandlers: () => [
|
|
613
623
|
...(config.inboundHandlers ?? []),
|
|
614
624
|
...(config.attachmentHandlers ?? []),
|
|
@@ -703,27 +713,6 @@ export function createTelegramConfigStore(
|
|
|
703
713
|
};
|
|
704
714
|
}
|
|
705
715
|
|
|
706
|
-
export function createTelegramPollingOffsetPersister(
|
|
707
|
-
configStore: Pick<TelegramConfigStore, "get" | "set" | "persist">,
|
|
708
|
-
persist: () => Promise<void> = () => configStore.persist(),
|
|
709
|
-
): (pollingConfig: { lastUpdateId?: number }) => Promise<void> {
|
|
710
|
-
return async (pollingConfig) => {
|
|
711
|
-
const nextOffset = pollingConfig.lastUpdateId;
|
|
712
|
-
if (typeof nextOffset === "number") {
|
|
713
|
-
const current = configStore.get();
|
|
714
|
-
const currentOffset = current.lastUpdateId;
|
|
715
|
-
configStore.set({
|
|
716
|
-
...current,
|
|
717
|
-
lastUpdateId:
|
|
718
|
-
typeof currentOffset === "number"
|
|
719
|
-
? Math.max(currentOffset, nextOffset)
|
|
720
|
-
: nextOffset,
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
await persist();
|
|
724
|
-
};
|
|
725
|
-
}
|
|
726
|
-
|
|
727
716
|
export function createTelegramProactivePushChecker(
|
|
728
717
|
configStore: Pick<TelegramConfigStore, "get">,
|
|
729
718
|
): () => boolean {
|
|
@@ -157,6 +157,7 @@ export interface TelegramUpdateJournalEntry {
|
|
|
157
157
|
export interface TelegramUpdateJournalFile {
|
|
158
158
|
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
159
159
|
revision?: number;
|
|
160
|
+
acceptedThroughUpdateId?: number;
|
|
160
161
|
profile: string;
|
|
161
162
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
162
163
|
entries: TelegramUpdateJournalEntry[];
|
|
@@ -312,6 +313,7 @@ export interface TelegramUpdateJournalStore {
|
|
|
312
313
|
read(): TelegramUpdateJournalSnapshot;
|
|
313
314
|
appendBatch<TUpdate extends TelegramUpdateJournalInput>(
|
|
314
315
|
updates: readonly TUpdate[],
|
|
316
|
+
acceptedThroughUpdateId?: number,
|
|
315
317
|
): TelegramUpdateJournalAppendResult;
|
|
316
318
|
markQueued(
|
|
317
319
|
receipt: TelegramUpdateJournalQueueReceipt,
|
|
@@ -385,6 +387,7 @@ export interface TelegramUpdateJournalSegment {
|
|
|
385
387
|
version: typeof TELEGRAM_UPDATE_JOURNAL_VERSION;
|
|
386
388
|
revision: number;
|
|
387
389
|
previousRevision: number;
|
|
390
|
+
acceptedThroughUpdateId?: number;
|
|
388
391
|
profile: string;
|
|
389
392
|
botIdentity: TelegramUpdateJournalBotIdentity;
|
|
390
393
|
upsertedEntries: TelegramUpdateJournalEntry[];
|
|
@@ -975,6 +978,7 @@ function parseJournalFile(
|
|
|
975
978
|
!hasOnlyKeys(value, [
|
|
976
979
|
"version",
|
|
977
980
|
"revision",
|
|
981
|
+
"acceptedThroughUpdateId",
|
|
978
982
|
"profile",
|
|
979
983
|
"botIdentity",
|
|
980
984
|
"entries",
|
|
@@ -983,6 +987,8 @@ function parseJournalFile(
|
|
|
983
987
|
!Number.isSafeInteger(value.version) ||
|
|
984
988
|
(value.revision !== undefined &&
|
|
985
989
|
(!isSafeNonNegativeInteger(value.revision) || value.revision === 0)) ||
|
|
990
|
+
(value.acceptedThroughUpdateId !== undefined &&
|
|
991
|
+
!isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
|
|
986
992
|
!isNonEmptyString(value.profile) ||
|
|
987
993
|
!Array.isArray(value.entries) ||
|
|
988
994
|
(value.operatorDispositions !== undefined &&
|
|
@@ -1009,6 +1015,17 @@ function parseJournalFile(
|
|
|
1009
1015
|
);
|
|
1010
1016
|
}
|
|
1011
1017
|
}
|
|
1018
|
+
if (
|
|
1019
|
+
entries.length > 0 &&
|
|
1020
|
+
value.acceptedThroughUpdateId !== undefined &&
|
|
1021
|
+
value.acceptedThroughUpdateId < entries.at(-1)!.updateId
|
|
1022
|
+
) {
|
|
1023
|
+
throw createJournalError(
|
|
1024
|
+
"invalid",
|
|
1025
|
+
path,
|
|
1026
|
+
"has an admission cursor behind its active entries",
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1012
1029
|
const queuedReceipts = new Map<
|
|
1013
1030
|
string,
|
|
1014
1031
|
{
|
|
@@ -1096,6 +1113,9 @@ function parseJournalFile(
|
|
|
1096
1113
|
...(value.revision !== undefined
|
|
1097
1114
|
? { revision: value.revision as number }
|
|
1098
1115
|
: {}),
|
|
1116
|
+
...(value.acceptedThroughUpdateId !== undefined
|
|
1117
|
+
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1118
|
+
: {}),
|
|
1099
1119
|
profile: value.profile,
|
|
1100
1120
|
botIdentity: validateBotIdentity(value.botIdentity, path),
|
|
1101
1121
|
entries,
|
|
@@ -1113,6 +1133,7 @@ function parseJournalSegment(
|
|
|
1113
1133
|
"version",
|
|
1114
1134
|
"revision",
|
|
1115
1135
|
"previousRevision",
|
|
1136
|
+
"acceptedThroughUpdateId",
|
|
1116
1137
|
"profile",
|
|
1117
1138
|
"botIdentity",
|
|
1118
1139
|
"upsertedEntries",
|
|
@@ -1122,6 +1143,8 @@ function parseJournalSegment(
|
|
|
1122
1143
|
value.version !== TELEGRAM_UPDATE_JOURNAL_VERSION ||
|
|
1123
1144
|
!isSafePositiveInteger(value.revision) ||
|
|
1124
1145
|
!isSafeNonNegativeInteger(value.previousRevision) ||
|
|
1146
|
+
(value.acceptedThroughUpdateId !== undefined &&
|
|
1147
|
+
!isSafeNonNegativeInteger(value.acceptedThroughUpdateId)) ||
|
|
1125
1148
|
!isNonEmptyString(value.profile) ||
|
|
1126
1149
|
!Array.isArray(value.upsertedEntries) ||
|
|
1127
1150
|
!Array.isArray(value.removedUpdateIds) ||
|
|
@@ -1140,6 +1163,18 @@ function parseJournalSegment(
|
|
|
1140
1163
|
}
|
|
1141
1164
|
upsertedIds.add(entry.updateId);
|
|
1142
1165
|
}
|
|
1166
|
+
if (
|
|
1167
|
+
upsertedEntries.length > 0 &&
|
|
1168
|
+
value.acceptedThroughUpdateId !== undefined &&
|
|
1169
|
+
value.acceptedThroughUpdateId <
|
|
1170
|
+
Math.max(...upsertedEntries.map((entry) => entry.updateId))
|
|
1171
|
+
) {
|
|
1172
|
+
throw createJournalError(
|
|
1173
|
+
"invalid",
|
|
1174
|
+
path,
|
|
1175
|
+
"has an admission cursor behind its segment upserts",
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1143
1178
|
const removedUpdateIds: number[] = [];
|
|
1144
1179
|
const removedIds = new Set<number>();
|
|
1145
1180
|
for (const updateId of value.removedUpdateIds) {
|
|
@@ -1162,6 +1197,9 @@ function parseJournalSegment(
|
|
|
1162
1197
|
version: TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
1163
1198
|
revision: value.revision,
|
|
1164
1199
|
previousRevision: value.previousRevision,
|
|
1200
|
+
...(value.acceptedThroughUpdateId !== undefined
|
|
1201
|
+
? { acceptedThroughUpdateId: value.acceptedThroughUpdateId as number }
|
|
1202
|
+
: {}),
|
|
1165
1203
|
profile: value.profile,
|
|
1166
1204
|
botIdentity: validateBotIdentity(value.botIdentity, path),
|
|
1167
1205
|
upsertedEntries,
|
|
@@ -1190,6 +1228,9 @@ function cloneFile(file: TelegramUpdateJournalFile): TelegramUpdateJournalFile {
|
|
|
1190
1228
|
return {
|
|
1191
1229
|
version: TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
1192
1230
|
...(file.revision !== undefined ? { revision: file.revision } : {}),
|
|
1231
|
+
...(file.acceptedThroughUpdateId !== undefined
|
|
1232
|
+
? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
|
|
1233
|
+
: {}),
|
|
1193
1234
|
profile: file.profile,
|
|
1194
1235
|
botIdentity: { ...file.botIdentity },
|
|
1195
1236
|
entries: file.entries.map(cloneEntry),
|
|
@@ -1879,6 +1920,17 @@ export function createTelegramUpdateJournalStore(
|
|
|
1879
1920
|
`has a revision gap after ${revision}`,
|
|
1880
1921
|
);
|
|
1881
1922
|
}
|
|
1923
|
+
if (
|
|
1924
|
+
segment.acceptedThroughUpdateId !== undefined &&
|
|
1925
|
+
file.acceptedThroughUpdateId !== undefined &&
|
|
1926
|
+
segment.acceptedThroughUpdateId < file.acceptedThroughUpdateId
|
|
1927
|
+
) {
|
|
1928
|
+
throw createJournalError(
|
|
1929
|
+
"invalid",
|
|
1930
|
+
segmentPath,
|
|
1931
|
+
"regresses the admission cursor",
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1882
1934
|
if (
|
|
1883
1935
|
segment.profile !== storedProfile ||
|
|
1884
1936
|
!identitiesMatch(segment.botIdentity, storedIdentity)
|
|
@@ -1907,6 +1959,11 @@ export function createTelegramUpdateJournalStore(
|
|
|
1907
1959
|
{
|
|
1908
1960
|
version: TELEGRAM_UPDATE_JOURNAL_VERSION,
|
|
1909
1961
|
revision: segment.revision,
|
|
1962
|
+
...(segment.acceptedThroughUpdateId !== undefined
|
|
1963
|
+
? { acceptedThroughUpdateId: segment.acceptedThroughUpdateId }
|
|
1964
|
+
: file.acceptedThroughUpdateId !== undefined
|
|
1965
|
+
? { acceptedThroughUpdateId: file.acceptedThroughUpdateId }
|
|
1966
|
+
: {}),
|
|
1910
1967
|
profile: storedProfile,
|
|
1911
1968
|
botIdentity: mergeBotIdentity(file.botIdentity, segment.botIdentity),
|
|
1912
1969
|
entries: [...entriesById.values()].sort(
|
|
@@ -2136,6 +2193,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2136
2193
|
entries: TelegramUpdateJournalEntry[],
|
|
2137
2194
|
contentChanged: boolean,
|
|
2138
2195
|
operatorDispositions = current.file.operatorDispositions,
|
|
2196
|
+
acceptedThroughUpdateId = current.file.acceptedThroughUpdateId,
|
|
2139
2197
|
): { file: TelegramUpdateJournalFile; serializedBytes: number } => {
|
|
2140
2198
|
const botIdentity = mergeBotIdentity(
|
|
2141
2199
|
current.file.botIdentity,
|
|
@@ -2147,7 +2205,8 @@ export function createTelegramUpdateJournalStore(
|
|
|
2147
2205
|
isDeepStrictEqual(
|
|
2148
2206
|
current.file.operatorDispositions ?? [],
|
|
2149
2207
|
operatorDispositions ?? [],
|
|
2150
|
-
)
|
|
2208
|
+
) &&
|
|
2209
|
+
current.file.acceptedThroughUpdateId === acceptedThroughUpdateId
|
|
2151
2210
|
) {
|
|
2152
2211
|
return { file: current.file, serializedBytes: current.serializedBytes };
|
|
2153
2212
|
}
|
|
@@ -2156,6 +2215,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
2156
2215
|
profile,
|
|
2157
2216
|
botIdentity,
|
|
2158
2217
|
entries,
|
|
2218
|
+
...(acceptedThroughUpdateId !== undefined
|
|
2219
|
+
? { acceptedThroughUpdateId }
|
|
2220
|
+
: {}),
|
|
2159
2221
|
...(operatorDispositions?.length
|
|
2160
2222
|
? { operatorDispositions }
|
|
2161
2223
|
: {}),
|
|
@@ -2164,6 +2226,7 @@ export function createTelegramUpdateJournalStore(
|
|
|
2164
2226
|
const serializedBytes = assertCapacity(file, serialized);
|
|
2165
2227
|
const changed =
|
|
2166
2228
|
contentChanged ||
|
|
2229
|
+
current.file.acceptedThroughUpdateId !== acceptedThroughUpdateId ||
|
|
2167
2230
|
!isDeepStrictEqual(current.file.botIdentity, file.botIdentity);
|
|
2168
2231
|
if (!changed) {
|
|
2169
2232
|
return { file, serializedBytes: current.serializedBytes };
|
|
@@ -2193,6 +2256,9 @@ export function createTelegramUpdateJournalStore(
|
|
|
2193
2256
|
botIdentity: file.botIdentity,
|
|
2194
2257
|
upsertedEntries,
|
|
2195
2258
|
removedUpdateIds,
|
|
2259
|
+
...(acceptedThroughUpdateId !== undefined
|
|
2260
|
+
? { acceptedThroughUpdateId }
|
|
2261
|
+
: {}),
|
|
2196
2262
|
...(!isDeepStrictEqual(
|
|
2197
2263
|
current.file.operatorDispositions ?? [],
|
|
2198
2264
|
operatorDispositions ?? [],
|
|
@@ -2260,8 +2326,18 @@ export function createTelegramUpdateJournalStore(
|
|
|
2260
2326
|
};
|
|
2261
2327
|
});
|
|
2262
2328
|
},
|
|
2263
|
-
appendBatch(updates) {
|
|
2329
|
+
appendBatch(updates, requestedAcceptedThroughUpdateId) {
|
|
2264
2330
|
return runMutation(() => {
|
|
2331
|
+
if (
|
|
2332
|
+
requestedAcceptedThroughUpdateId !== undefined &&
|
|
2333
|
+
!isSafeNonNegativeInteger(requestedAcceptedThroughUpdateId)
|
|
2334
|
+
) {
|
|
2335
|
+
throw createJournalError(
|
|
2336
|
+
"invalid",
|
|
2337
|
+
path,
|
|
2338
|
+
"received an invalid admission cursor",
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2265
2341
|
const current = readCurrent();
|
|
2266
2342
|
const normalizedUpdates: TelegramJournaledUpdate[] = [];
|
|
2267
2343
|
for (const update of updates) {
|
|
@@ -2290,6 +2366,8 @@ export function createTelegramUpdateJournalStore(
|
|
|
2290
2366
|
const entriesById = new Map(
|
|
2291
2367
|
current.file.entries.map((entry) => [entry.updateId, entry]),
|
|
2292
2368
|
);
|
|
2369
|
+
const previousAcceptedThroughUpdateId =
|
|
2370
|
+
current.file.acceptedThroughUpdateId;
|
|
2293
2371
|
const discardedUpdateIds = new Set(
|
|
2294
2372
|
(current.file.operatorDispositions ?? [])
|
|
2295
2373
|
.filter((disposition) => disposition.action === "discard")
|
|
@@ -2335,6 +2413,31 @@ export function createTelegramUpdateJournalStore(
|
|
|
2335
2413
|
addedUpdateIds.push(entry.updateId);
|
|
2336
2414
|
}
|
|
2337
2415
|
|
|
2416
|
+
const batchLastUpdateId = normalizedUpdates.at(-1)?.update_id;
|
|
2417
|
+
if (
|
|
2418
|
+
requestedAcceptedThroughUpdateId !== undefined &&
|
|
2419
|
+
batchLastUpdateId !== undefined &&
|
|
2420
|
+
requestedAcceptedThroughUpdateId < batchLastUpdateId
|
|
2421
|
+
) {
|
|
2422
|
+
throw createJournalError(
|
|
2423
|
+
"invalid",
|
|
2424
|
+
path,
|
|
2425
|
+
"received an admission cursor behind its batch",
|
|
2426
|
+
);
|
|
2427
|
+
}
|
|
2428
|
+
if (
|
|
2429
|
+
requestedAcceptedThroughUpdateId !== undefined &&
|
|
2430
|
+
previousAcceptedThroughUpdateId !== undefined &&
|
|
2431
|
+
requestedAcceptedThroughUpdateId < previousAcceptedThroughUpdateId
|
|
2432
|
+
) {
|
|
2433
|
+
throw createJournalError(
|
|
2434
|
+
"conflict",
|
|
2435
|
+
path,
|
|
2436
|
+
"received a regressing admission cursor",
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
const acceptedThroughUpdateId =
|
|
2440
|
+
requestedAcceptedThroughUpdateId ?? previousAcceptedThroughUpdateId;
|
|
2338
2441
|
const contentChanged = addedUpdateIds.length > 0;
|
|
2339
2442
|
const published = publishMutation(
|
|
2340
2443
|
current,
|
|
@@ -2344,6 +2447,8 @@ export function createTelegramUpdateJournalStore(
|
|
|
2344
2447
|
)
|
|
2345
2448
|
: current.file.entries,
|
|
2346
2449
|
contentChanged,
|
|
2450
|
+
current.file.operatorDispositions,
|
|
2451
|
+
acceptedThroughUpdateId,
|
|
2347
2452
|
);
|
|
2348
2453
|
return {
|
|
2349
2454
|
addedUpdateIds,
|
|
@@ -103,7 +103,7 @@ export interface TelegramModelMenuRuntimeOptions<
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export interface MenuSettingsManager {
|
|
106
|
-
reload
|
|
106
|
+
reload?: () => Promise<void>;
|
|
107
107
|
flush?: () => Promise<void>;
|
|
108
108
|
getEnabledModels: () => string[] | undefined;
|
|
109
109
|
setEnabledModels?: (patterns: string[] | undefined) => void;
|
|
@@ -498,7 +498,9 @@ export function createTelegramModelMenuStateBuilder<
|
|
|
498
498
|
threadId,
|
|
499
499
|
activeModel: deps.getActiveModel(ctx),
|
|
500
500
|
ctx,
|
|
501
|
-
reloadSettings: () =>
|
|
501
|
+
reloadSettings: async () => {
|
|
502
|
+
await settingsManager.reload?.();
|
|
503
|
+
},
|
|
502
504
|
getConfiguredScopedModelPatterns: () =>
|
|
503
505
|
settingsManager.getEnabledModels(),
|
|
504
506
|
});
|
|
@@ -8,7 +8,6 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
8
8
|
|
|
9
9
|
export interface TelegramPollingConfig {
|
|
10
10
|
botToken?: string;
|
|
11
|
-
lastUpdateId?: number;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
export interface TelegramUpdate {
|
|
@@ -227,6 +226,7 @@ export interface TelegramPollingAdmissionRuntime<TContext> {
|
|
|
227
226
|
|
|
228
227
|
export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
|
|
229
228
|
polling: TelegramPollingController<TContext>;
|
|
229
|
+
prepareStart?: () => MaybePromise<void>;
|
|
230
230
|
validateStart?: () => void;
|
|
231
231
|
worker: {
|
|
232
232
|
onSessionStart: (ctx: TContext) => Promise<void>;
|
|
@@ -235,6 +235,7 @@ export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
|
|
|
235
235
|
return {
|
|
236
236
|
isActive: deps.polling.isActive,
|
|
237
237
|
async start(ctx) {
|
|
238
|
+
await deps.prepareStart?.();
|
|
238
239
|
deps.validateStart?.();
|
|
239
240
|
await deps.worker.onSessionStart(ctx);
|
|
240
241
|
await deps.polling.start(ctx);
|
|
@@ -256,7 +257,12 @@ export type TelegramDurablePollingRuntimeAssemblyDeps<
|
|
|
256
257
|
"appendUpdateBatch" | "getJournalEntryCount" | "signalUpdateWorker"
|
|
257
258
|
> & {
|
|
258
259
|
journal: {
|
|
259
|
-
appendBatch: (
|
|
260
|
+
appendBatch: (
|
|
261
|
+
updates: readonly TUpdate[],
|
|
262
|
+
acceptedThroughUpdateId?: number,
|
|
263
|
+
) => MaybePromise<unknown>;
|
|
264
|
+
getAcceptedThroughUpdateId: () => number | undefined;
|
|
265
|
+
prepareCursorCutover?: () => MaybePromise<void>;
|
|
260
266
|
getEntryCount: () => number;
|
|
261
267
|
signalWorker: () => void;
|
|
262
268
|
getBootstrapEntryCount: () => number;
|
|
@@ -274,13 +280,15 @@ export function createTelegramDurablePollingRuntimeAssembly<
|
|
|
274
280
|
const controller = createTelegramPollingControllerRuntime({
|
|
275
281
|
...deps,
|
|
276
282
|
appendUpdateBatch: deps.journal.appendBatch,
|
|
283
|
+
getAcceptedThroughUpdateId: deps.journal.getAcceptedThroughUpdateId,
|
|
277
284
|
getJournalEntryCount: deps.journal.getEntryCount,
|
|
278
285
|
signalUpdateWorker: deps.journal.signalWorker,
|
|
279
286
|
});
|
|
280
287
|
const admission = createTelegramPollingAdmissionRuntime({
|
|
281
288
|
polling: controller,
|
|
289
|
+
prepareStart: deps.journal.prepareCursorCutover,
|
|
282
290
|
validateStart() {
|
|
283
|
-
if (deps.
|
|
291
|
+
if (deps.journal.getAcceptedThroughUpdateId() !== undefined) return;
|
|
284
292
|
if (deps.journal.getBootstrapEntryCount() === 0) return;
|
|
285
293
|
throw new TelegramPollingCursorBootstrapError(
|
|
286
294
|
"Telegram polling cursor is missing while the durable update journal is non-empty.",
|
|
@@ -356,6 +364,7 @@ export function createTelegramPollingControllerRuntime<
|
|
|
356
364
|
getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
|
|
357
365
|
persistConfig: deps.persistConfig,
|
|
358
366
|
appendUpdateBatch: deps.appendUpdateBatch,
|
|
367
|
+
getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
|
|
359
368
|
getJournalEntryCount: deps.getJournalEntryCount,
|
|
360
369
|
signalUpdateWorker: deps.signalUpdateWorker,
|
|
361
370
|
prepareUpdateBatch: deps.prepareUpdateBatch,
|
|
@@ -1185,6 +1194,32 @@ export class TelegramPollingCursorBootstrapError extends Error {
|
|
|
1185
1194
|
}
|
|
1186
1195
|
}
|
|
1187
1196
|
|
|
1197
|
+
export interface TelegramPollingCursorCutoverDeps {
|
|
1198
|
+
getLegacyCursor: () => number | undefined;
|
|
1199
|
+
readJournal: () => {
|
|
1200
|
+
acceptedThroughUpdateId?: number;
|
|
1201
|
+
entries: readonly { updateId: number }[];
|
|
1202
|
+
};
|
|
1203
|
+
publishJournalCursor: (acceptedThroughUpdateId: number) => MaybePromise<void>;
|
|
1204
|
+
removeLegacyCursor: () => MaybePromise<void>;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Transfer one legacy config cursor into journal authority before deleting it. */
|
|
1208
|
+
export async function cutOverTelegramPollingCursor(
|
|
1209
|
+
deps: TelegramPollingCursorCutoverDeps,
|
|
1210
|
+
): Promise<void> {
|
|
1211
|
+
const legacyCursor = deps.getLegacyCursor();
|
|
1212
|
+
if (legacyCursor === undefined) return;
|
|
1213
|
+
const snapshot = deps.readJournal();
|
|
1214
|
+
if (snapshot.acceptedThroughUpdateId === undefined) {
|
|
1215
|
+
const provenEntryCursor = snapshot.entries.at(-1)?.updateId;
|
|
1216
|
+
await deps.publishJournalCursor(
|
|
1217
|
+
Math.max(legacyCursor, provenEntryCursor ?? legacyCursor),
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
await deps.removeLegacyCursor();
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1188
1223
|
export interface TelegramPollingBatchAdmissionResult {
|
|
1189
1224
|
updateCount: number;
|
|
1190
1225
|
latestUpdateId?: number;
|
|
@@ -1195,7 +1230,11 @@ export interface TelegramPollingBatchAdmissionDeps<
|
|
|
1195
1230
|
> extends TelegramRuntimeEventRecorderPort {
|
|
1196
1231
|
updates: readonly TUpdate[];
|
|
1197
1232
|
config: TelegramPollingConfig;
|
|
1198
|
-
appendBatch: (
|
|
1233
|
+
appendBatch: (
|
|
1234
|
+
updates: readonly TUpdate[],
|
|
1235
|
+
acceptedThroughUpdateId?: number,
|
|
1236
|
+
) => MaybePromise<unknown>;
|
|
1237
|
+
getAcceptedThroughUpdateId?: () => number | undefined;
|
|
1199
1238
|
persistConfig: (config: TelegramPollingConfig) => Promise<void>;
|
|
1200
1239
|
signalWorker: () => void;
|
|
1201
1240
|
onPhaseChange?: (
|
|
@@ -1229,7 +1268,8 @@ export async function admitTelegramPollingUpdateBatch<
|
|
|
1229
1268
|
deps: TelegramPollingBatchAdmissionDeps<TUpdate>,
|
|
1230
1269
|
): Promise<TelegramPollingBatchAdmissionResult> {
|
|
1231
1270
|
if (deps.updates.length === 0) return { updateCount: 0 };
|
|
1232
|
-
|
|
1271
|
+
const acceptedThroughUpdateId = deps.getAcceptedThroughUpdateId?.();
|
|
1272
|
+
validateTelegramPollingBatch(deps.updates, acceptedThroughUpdateId);
|
|
1233
1273
|
const latestUpdateId = getLatestTelegramUpdateId(deps.updates);
|
|
1234
1274
|
if (latestUpdateId === undefined) return { updateCount: 0 };
|
|
1235
1275
|
reportTelegramPollingPhase(
|
|
@@ -1237,18 +1277,7 @@ export async function admitTelegramPollingUpdateBatch<
|
|
|
1237
1277
|
"persisting-journal",
|
|
1238
1278
|
deps.updates[0]?.update_id,
|
|
1239
1279
|
);
|
|
1240
|
-
await deps.appendBatch(deps.updates);
|
|
1241
|
-
reportTelegramPollingPhase(deps, "persisting-offset", latestUpdateId);
|
|
1242
|
-
const previousUpdateId = deps.config.lastUpdateId;
|
|
1243
|
-
deps.config.lastUpdateId = latestUpdateId;
|
|
1244
|
-
try {
|
|
1245
|
-
await deps.persistConfig(deps.config);
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
if (deps.config.lastUpdateId === latestUpdateId) {
|
|
1248
|
-
deps.config.lastUpdateId = previousUpdateId;
|
|
1249
|
-
}
|
|
1250
|
-
throw error;
|
|
1251
|
-
}
|
|
1280
|
+
await deps.appendBatch(deps.updates, latestUpdateId);
|
|
1252
1281
|
try {
|
|
1253
1282
|
deps.signalWorker();
|
|
1254
1283
|
} catch (error) {
|
|
@@ -1275,7 +1304,11 @@ export interface TelegramPollLoopDeps<
|
|
|
1275
1304
|
) => Promise<TUpdate[]>;
|
|
1276
1305
|
getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
|
|
1277
1306
|
persistConfig: (config: TelegramPollingConfig) => Promise<void>;
|
|
1278
|
-
appendUpdateBatch: (
|
|
1307
|
+
appendUpdateBatch: (
|
|
1308
|
+
updates: readonly TUpdate[],
|
|
1309
|
+
acceptedThroughUpdateId?: number,
|
|
1310
|
+
) => MaybePromise<unknown>;
|
|
1311
|
+
getAcceptedThroughUpdateId?: () => number | undefined;
|
|
1279
1312
|
getJournalEntryCount: () => number;
|
|
1280
1313
|
signalUpdateWorker: () => void;
|
|
1281
1314
|
prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
|
|
@@ -1301,7 +1334,11 @@ export interface TelegramPollLoopRunnerDeps<
|
|
|
1301
1334
|
) => Promise<TUpdate[]>;
|
|
1302
1335
|
getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
|
|
1303
1336
|
persistConfig: (config: TelegramPollingConfig) => Promise<void>;
|
|
1304
|
-
appendUpdateBatch: (
|
|
1337
|
+
appendUpdateBatch: (
|
|
1338
|
+
updates: readonly TUpdate[],
|
|
1339
|
+
acceptedThroughUpdateId?: number,
|
|
1340
|
+
) => MaybePromise<unknown>;
|
|
1341
|
+
getAcceptedThroughUpdateId?: () => number | undefined;
|
|
1305
1342
|
getJournalEntryCount: () => number;
|
|
1306
1343
|
signalUpdateWorker: () => void;
|
|
1307
1344
|
prepareUpdateBatch?: (updates: readonly TUpdate[]) => void;
|
|
@@ -1356,6 +1393,7 @@ export function createTelegramPollLoopRunner<
|
|
|
1356
1393
|
getUpdatesRequestBudgetMs: deps.getUpdatesRequestBudgetMs,
|
|
1357
1394
|
persistConfig: deps.persistConfig,
|
|
1358
1395
|
appendUpdateBatch: deps.appendUpdateBatch,
|
|
1396
|
+
getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
|
|
1359
1397
|
getJournalEntryCount: deps.getJournalEntryCount,
|
|
1360
1398
|
signalUpdateWorker: deps.signalUpdateWorker,
|
|
1361
1399
|
prepareUpdateBatch: deps.prepareUpdateBatch,
|
|
@@ -1494,14 +1532,16 @@ export async function runTelegramPollLoop<
|
|
|
1494
1532
|
// ignore
|
|
1495
1533
|
}
|
|
1496
1534
|
if (
|
|
1497
|
-
deps.
|
|
1535
|
+
deps.getAcceptedThroughUpdateId?.() === undefined &&
|
|
1498
1536
|
deps.getJournalEntryCount() > 0
|
|
1499
1537
|
) {
|
|
1500
1538
|
throw new TelegramPollingCursorBootstrapError(
|
|
1501
1539
|
"Telegram polling cursor is missing while the durable update journal is non-empty.",
|
|
1502
1540
|
);
|
|
1503
1541
|
}
|
|
1504
|
-
if (
|
|
1542
|
+
if (
|
|
1543
|
+
deps.getAcceptedThroughUpdateId?.() === undefined
|
|
1544
|
+
) {
|
|
1505
1545
|
try {
|
|
1506
1546
|
const request = buildTelegramInitialSyncRequest();
|
|
1507
1547
|
reportTelegramPollingPhase(deps, "long-poll");
|
|
@@ -1514,8 +1554,7 @@ export async function runTelegramPollLoop<
|
|
|
1514
1554
|
"persisting-offset",
|
|
1515
1555
|
lastUpdateId,
|
|
1516
1556
|
);
|
|
1517
|
-
deps.
|
|
1518
|
-
await deps.persistConfig(deps.config);
|
|
1557
|
+
await deps.appendUpdateBatch([], lastUpdateId);
|
|
1519
1558
|
deps.recordRuntimeEvent?.(
|
|
1520
1559
|
"polling",
|
|
1521
1560
|
new Error("Initialized Telegram cursor without executing history."),
|
|
@@ -1538,7 +1577,9 @@ export async function runTelegramPollLoop<
|
|
|
1538
1577
|
while (!deps.signal.aborted) {
|
|
1539
1578
|
try {
|
|
1540
1579
|
currentUpdateId = undefined;
|
|
1541
|
-
const request = buildTelegramLongPollRequest(
|
|
1580
|
+
const request = buildTelegramLongPollRequest(
|
|
1581
|
+
deps.getAcceptedThroughUpdateId?.(),
|
|
1582
|
+
);
|
|
1542
1583
|
reportTelegramPollingPhase(deps, "long-poll");
|
|
1543
1584
|
const updates = await requestTelegramUpdatesWithinBudget(deps, request);
|
|
1544
1585
|
reportTelegramPollingResponse(deps, updates.length);
|
|
@@ -1549,6 +1590,7 @@ export async function runTelegramPollLoop<
|
|
|
1549
1590
|
updates,
|
|
1550
1591
|
config: deps.config,
|
|
1551
1592
|
appendBatch: deps.appendUpdateBatch,
|
|
1593
|
+
getAcceptedThroughUpdateId: deps.getAcceptedThroughUpdateId,
|
|
1552
1594
|
persistConfig: deps.persistConfig,
|
|
1553
1595
|
signalWorker: deps.signalUpdateWorker,
|
|
1554
1596
|
onPhaseChange: deps.onPhaseChange,
|
|
@@ -321,7 +321,6 @@ export interface TelegramBridgeStatusConfig {
|
|
|
321
321
|
botToken?: string;
|
|
322
322
|
botUsername?: string;
|
|
323
323
|
allowedUserId?: number;
|
|
324
|
-
lastUpdateId?: number;
|
|
325
324
|
}
|
|
326
325
|
|
|
327
326
|
export interface TelegramBridgeStatusRuntimeDeps<
|
|
@@ -337,6 +336,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
|
|
337
336
|
isPollingActive: () => boolean;
|
|
338
337
|
getPollingState?: () => TelegramBridgePollingState;
|
|
339
338
|
getInboundWorkerState?: () => TelegramBridgeInboundWorkerState | undefined;
|
|
339
|
+
getAcceptedThroughUpdateId?: () => number | undefined;
|
|
340
340
|
getActiveSourceMessageIds: () => number[] | undefined;
|
|
341
341
|
hasActiveTurn: () => boolean;
|
|
342
342
|
hasDispatchPending: () => boolean;
|
|
@@ -717,7 +717,7 @@ export function createTelegramBridgeStatusRuntime<
|
|
|
717
717
|
...(deps.getInboundWorkerState
|
|
718
718
|
? { inboundWorker: deps.getInboundWorkerState() }
|
|
719
719
|
: {}),
|
|
720
|
-
lastUpdateId:
|
|
720
|
+
lastUpdateId: deps.getAcceptedThroughUpdateId?.(),
|
|
721
721
|
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
|
|
722
722
|
pendingDispatch: deps.hasDispatchPending(),
|
|
723
723
|
compactionInProgress: deps.isCompactionInProgress(),
|
|
@@ -1685,6 +1685,7 @@ export interface TelegramUpdateWorkerStateSnapshot {
|
|
|
1685
1685
|
}
|
|
1686
1686
|
|
|
1687
1687
|
export interface TelegramUpdateWorkerJournalSnapshot {
|
|
1688
|
+
acceptedThroughUpdateId?: number;
|
|
1688
1689
|
entries: readonly {
|
|
1689
1690
|
updateId: number;
|
|
1690
1691
|
update: TelegramJournaledUpdate;
|
|
@@ -3669,7 +3670,10 @@ export interface TelegramUpdateAdmissionLifecycleJournalBinding {
|
|
|
3669
3670
|
runtimeKey: string;
|
|
3670
3671
|
recoveryKey: string;
|
|
3671
3672
|
journal: TelegramUpdateWorkerJournalPort & {
|
|
3672
|
-
appendBatch: (
|
|
3673
|
+
appendBatch: (
|
|
3674
|
+
updates: readonly TelegramJournaledUpdate[],
|
|
3675
|
+
acceptedThroughUpdateId?: number,
|
|
3676
|
+
) => unknown;
|
|
3673
3677
|
applyOperatorDisposition?: (
|
|
3674
3678
|
input: TelegramUpdateJournalOperatorDispositionInput,
|
|
3675
3679
|
) => TelegramUpdateJournalOperatorDispositionResult;
|
|
@@ -4303,7 +4307,10 @@ export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
|
|
|
4303
4307
|
onSessionStart: (ctx: TContext) => Promise<void>;
|
|
4304
4308
|
onSessionShutdown: () => Promise<void>;
|
|
4305
4309
|
onTransportChanged: (ctx?: TContext) => Promise<void>;
|
|
4306
|
-
appendBatch: (
|
|
4310
|
+
appendBatch: (
|
|
4311
|
+
updates: readonly TelegramJournaledUpdate[],
|
|
4312
|
+
acceptedThroughUpdateId?: number,
|
|
4313
|
+
) => unknown;
|
|
4307
4314
|
discardQueueReceipt: (input: {
|
|
4308
4315
|
queueKind: "prompt" | "control";
|
|
4309
4316
|
receiptId: string;
|
|
@@ -4603,11 +4610,11 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
|
|
|
4603
4610
|
await stopCurrent(true);
|
|
4604
4611
|
if (ctx !== undefined) await bind(ctx);
|
|
4605
4612
|
}),
|
|
4606
|
-
appendBatch(updates) {
|
|
4613
|
+
appendBatch(updates, acceptedThroughUpdateId) {
|
|
4607
4614
|
if (!journal || !worker) {
|
|
4608
4615
|
throw new Error("Telegram update admission worker is not active.");
|
|
4609
4616
|
}
|
|
4610
|
-
return journal.appendBatch(updates);
|
|
4617
|
+
return journal.appendBatch(updates, acceptedThroughUpdateId);
|
|
4611
4618
|
},
|
|
4612
4619
|
discardQueueReceipt(input) {
|
|
4613
4620
|
if (!journal || !worker || !journal.discardQueued) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@llblab/pi-actors": "0.50.0",
|
|
44
44
|
"@llblab/pi-codex-usage": "0.9.3",
|
|
45
45
|
"@llblab/pi-grow-loop": "0.7.2",
|
|
46
|
-
"@llblab/pi-telegram": "0.
|
|
46
|
+
"@llblab/pi-telegram": "0.37.1"
|
|
47
47
|
},
|
|
48
48
|
"bundledDependencies": [
|
|
49
49
|
"@llblab/pi-actors",
|