@llblab/pi-kit 0.36.2 → 0.36.3
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/BACKLOG.md +27 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +22 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +16 -14
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +6 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +7 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +22 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +19 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +4 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +3 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +5 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +1 -15
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +2 -15
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +15 -1
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +18 -17
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +11 -4
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +8 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +34 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +21 -10
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +1 -1
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +4 -4
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +2 -26
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/scripts/run-macos-tests.mjs +43 -0
- 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.36.3: Bundled Package Updates (2026-10-24)
|
|
6
|
+
|
|
7
|
+
- `Exact pins`: `@llblab/pi-telegram` 0.57.7 → 0.57.11.
|
|
8
|
+
- `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
|
|
9
|
+
|
|
5
10
|
## 0.36.2: Bundled Package Updates (2026-10-23)
|
|
6
11
|
|
|
7
12
|
- `Exact pins`: `@llblab/pi-claude-usage` 0.2.2 → 0.2.3; `@llblab/pi-codex-usage` 0.13.1 → 0.13.2; `@llblab/pi-telegram` 0.57.3 → 0.57.7.
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Package links lead to the owning repositories for usage, documentation, issues,
|
|
|
18
18
|
| [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.2` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
|
|
19
19
|
| [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
|
|
20
20
|
| [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.26.5` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
|
|
21
|
-
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.
|
|
21
|
+
| [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.57.11` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
|
|
22
22
|
| [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
|
|
23
23
|
|
|
24
24
|
Versions are exact by design. An upstream release does not change an installed kit until this repository explicitly advances the dependency and publishes a new kit version. Runtime defects and package-specific feature requests belong in the linked repository; package selection and kit installation issues belong here.
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
_This file owns unresolved project work only. Completed behavior belongs in `CHANGELOG.md`; durable contracts belong in `AGENTS.md` and `/docs`._
|
|
4
4
|
|
|
5
|
+
## 0.57.11 publication checkpoint
|
|
6
|
+
|
|
7
|
+
- [ ] Validate and publish only the pre-publication follower reply-target fix through the protected dev-to-main release path. Require fresh Ubuntu, Windows and both macOS shard checks before merge, exact candidate/merged-tree equality, one immutable version tag and the repository-owned release workflow.
|
|
8
|
+
- [ ] Confirm the published GitHub Release and public npm version, tag/gitHead identity, release notes and packed runtime/skills before closing this checkpoint. Keep post-refusal recovery and disposable-tab live acceptance open; do not reload the running session.
|
|
9
|
+
|
|
5
10
|
## Disposable Thread live acceptance
|
|
6
11
|
|
|
7
12
|
- [ ] After separate operator authorization, use disposable Telegram tabs to check leader Settings review/Back, one confirmed cleanup of an inactive slotless binding, preservation of its replacement letter holder, and visible outcome/recovery copy. Native filesystem and injected-transport tests do not prove live Telegram/client or native Windows acceptance. Never use production history as the disposable fixture.
|
|
@@ -17,7 +22,28 @@ The prompt-cache report remains open for the reporter's offered live Anthropic c
|
|
|
17
22
|
|
|
18
23
|
Evidence (2026-10-23 07:11–07:16): a `/start` sent in a former leader tab moved into a follower's journal as a raw original. After that follower's Thread was re-created by the restore probe, every execution attempt failed with `Telegram bus API call is not allowed for this follower` and retried every 60 s indefinitely, because the reply targeted a Thread the follower no longer owned. Terminal settlement alone is unsafe: the same rejection can be a benign race during a live-rebind pre-dispatch.
|
|
19
24
|
|
|
20
|
-
-
|
|
25
|
+
### Post-refusal reply-only recovery — deferred contract gate
|
|
26
|
+
|
|
27
|
+
Remaining scope is recovery after a reply is refused or recipient authority changes during publication, not initial destination selection. The latter is specified under the [reply ownership boundary](./docs/multi-instance-bus.md#reply-recovery-ownership-boundary); its native regression covers a stale source address, not the historical refusal itself or live Telegram restoration.
|
|
28
|
+
|
|
29
|
+
A target-scoped reply refusal cannot prove that a command made no prior effects. Keep the handler-replay containment while preparing a replacement: unresolved inputs must remain in ordinary durable retry state, not be silently completed or retargeted as whole commands. Ordinary journal execution remains at-least-once, not globally exactly-once.
|
|
30
|
+
|
|
31
|
+
- [ ] Obtain explicit approval for a post-refusal recipient-delivery contract, then implement recipient-scoped reply-only recovery under the [reply ownership boundary](./docs/multi-instance-bus.md#reply-recovery-ownership-boundary), outside the containment hotfix. No ordinary recipient-scoped send grant is composed today: the local execution fence and queue-owner identity are insufficient, and receipt/admission namespaces cannot substitute for the registered recipient profile key. Ordinary sends return only a message ID, so the menu/delivery owner must participate in any destination change. Reuse selected-menu delivery only with its exact retained live-rebind/source grant; a raw journaled command cannot borrow it. Capture and recheck recipient/profile/session/registration authority at both ends, with no widening of the ordinary proxy.
|
|
32
|
+
- [ ] Prove the reply-only path with the real follower API caller and worker: one replacement send before any reply chunk is acknowledged, consistent callback/message ownership and destination across every chunk, no stale reply anchor, no recovery for deletion/editing/unknown or lost ACKs, and no second send after authority changes. A command effect before a refused reply must execute once within that admission attempt; original journal evidence stays unchanged and diagnostics cannot change a successful outcome. Run typecheck and the full suite at the stable boundary. If these proofs cannot be supplied, retain the input without handler re-entry.
|
|
33
|
+
- [ ] Live acceptance after the reply-only recovery checkpoint: a forwarded command whose follower Thread was re-created is answered in the current Thread (disposable tabs only).
|
|
34
|
+
|
|
35
|
+
### Remaining routing investigation
|
|
36
|
+
|
|
21
37
|
- [ ] Find why the temporary tab's selected `/start` stayed unresolved until expiry, and why that Restore moved custody without applying the rebind.
|
|
22
38
|
- [ ] Route an input that arrives in a tab bound to nothing through a fresh temporary routing tab (the All flow) instead of publishing the chooser in the orphan, deleting the orphan with it once nothing unspent remains. Needs its own durable-metadata design.
|
|
23
39
|
- [ ] Live acceptance on disposable tabs only: Cancel on an orphan tab deletes it; a second unspent input keeps it until the last Cancel; a plain `std::map` option button is delivered as an ordinary prompt.
|
|
40
|
+
|
|
41
|
+
## macOS process-exit test flake
|
|
42
|
+
|
|
43
|
+
The 0.57.8 tag validation failed `Child process sharing the agent dir does not poll while parent owns Telegram lock` even when that file ran alone: `Child process did not exit. stdout= stderr=` at its existing child deadline. The other seven process tests and the identical pre-merge tree passed. That historical failure has no child-stage trace, so cold import, startup and a retained runtime handle remain competing explanations. Bounded diagnostic sampling is exhausted; passing traces showed imports up to 4.37 seconds against the five-second non-Windows child budget and natural exit within 8–14 ms after the event-loop checkpoint. These traces do not identify the original cause.
|
|
44
|
+
- [ ] Retain bounded child-stage/resource diagnostics for the next genuinely failing passive-child trace; the original timeout is still unexplained. Do not spend more unchanged-head diagnostic samples. The parent records explicit shutdown separately; do not add a shutdown call to the passive child to make it pass. Correct a fixture/runtime cause only when the failing stage evidence supports it, and require fresh platform CI before resuming release recovery. Do not widen deadlines or describe the runner as the proven cause without measurement.
|
|
45
|
+
|
|
46
|
+
## Windows Workspace-gate fixture timeout
|
|
47
|
+
|
|
48
|
+
`Leader assembly serializes follower provisioning through its shared Workspace gate` once exceeded its generic one-second bus-response deadline. It sends `follower.register` directly, outside the changed API caller; production registration has a separate budget. The later native standalone witness acknowledged at about 90 ms, and the full suite passed, but the original failure has no gate/persistence trace. A synthetic delayed-write probe verifies diagnostic capture, not the native cause.
|
|
49
|
+
- [ ] Use the next genuinely failing native trace to distinguish gate wait, persistence and acknowledgement delay before correcting this fixture or runtime. Retain its bounded stage evidence and standalone/full-suite execution; do not spend unchanged-head reruns or infer a cause from passing checks. Any deadline-policy correction must be justified against the registration owner's contract, not a guessed larger timeout.
|
|
@@ -4,6 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.57.11: Follower Reply Target
|
|
8
|
+
|
|
9
|
+
- `Follower command replies`: Status/model/settings and text replies from an admitted follower command select its acknowledged Thread before publication, not the old input Thread. A changed Thread drops the reply anchor; delivery, menu state and ownership use one destination. The journaled original and explicit selected-menu grants stay intact; no refused-send recovery, handler replay or proxy widening is added.
|
|
10
|
+
|
|
11
|
+
## 0.57.10: Follower Request Identity
|
|
12
|
+
|
|
13
|
+
- `Follower request identity`: Follower API arguments use one JSON-wire snapshot before registration waits. Later caller mutation no longer changes the issued method/body or relabels a refused target and slow-call method. Ordinary and multipart calls retain wire JSON semantics and authority fences; refusal evidence still grants no resend or whole-command replay.
|
|
14
|
+
- `Windows CI diagnostics`: The Workspace-gate registration fixture records bounded gate and persistence stage timings, including them in failure context without paths or credentials. It also runs in the standalone Windows lifecycle probe before the full concurrent suite. Existing assertions, response deadline and job budgets are unchanged; instrumentation does not claim to solve the observed timeout.
|
|
15
|
+
|
|
16
|
+
## 0.57.9: Reply Safety and CI Recovery
|
|
17
|
+
|
|
18
|
+
- `Recipient safety`: Ships the containment prepared for unpublished 0.57.8: a refused follower reply no longer immediately reruns its whole command handler and repeats earlier effects. The original input stays unchanged in durable retry state, without silent completion. Automatic reply relocation remains withdrawn; stale-Thread commands may still retry and ordinary execution remains at-least-once.
|
|
19
|
+
- `Follower refusals`: Structured bus acknowledgements identify requests blocked before Telegram API delegation, without granting resend authority. Legacy error text, mismatched methods and lost acknowledgements cannot prove non-execution. Reply chunks keep their captured destination and ownership; refused chunks acquire no message ownership.
|
|
20
|
+
- `CI sharding`: macOS distributes its serialized test files across two deterministic jobs to avoid the measured whole-job overrun. A checked inventory includes every file exactly once, with real-process tests isolated and the local Pi CLI on PATH. Assertions, test deadlines and the 30-minute job budget are unchanged; publication requires every platform and shard to pass.
|
|
21
|
+
- `Process diagnostics`: Child-exit checks report bounded lifecycle timings and active resource types without logging fixture credentials or paths or adding referenced timers. Import, startup and natural-exit stages can now distinguish future failures. The original macOS child timeout remains unexplained; passing traces do not establish its cause.
|
|
22
|
+
|
|
23
|
+
## 0.57.8: Reply Recovery Safety
|
|
24
|
+
|
|
25
|
+
- `Recipient safety`: A refused follower reply no longer immediately re-runs the whole command handler, which could repeat changes made before the failed send. The original input stays unchanged in durable retry state, without silent completion. Automatic reply relocation is withdrawn pending transport-level safety proof; a stale-Thread command may still retry and ordinary execution remains at-least-once.
|
|
26
|
+
- `Follower refusals`: Structured bus acknowledgements distinguish requests blocked before Telegram API delegation from errors returned after execution. Legacy error text, mismatched methods and lost acknowledgements cannot certify non-execution or authorize a resend. Reply chunks retain their captured destination and ownership; refused chunks acquire no message ownership.
|
|
27
|
+
- `CI`: macOS runs real-process shutdown tests separately and the remaining test files one at a time, keeping the local Pi CLI on PATH. Assertions and deadlines are unchanged. This reduces observed runner-contention failures without claiming the process-exit flake is eliminated.
|
|
28
|
+
|
|
7
29
|
## 0.57.7: Routing Cleanup and Recipient Recovery
|
|
8
30
|
|
|
9
31
|
- `Recipient recovery`: A forwarded input journaled for a follower whose Thread was re-created no longer retries forever against the old Thread. After the leader refuses that reply, the input re-runs once against the follower's current Thread; a second refusal or any other failure keeps the durable retry state, and nothing is dropped.
|
|
@@ -955,6 +955,11 @@ export function createTelegramBusFollowerApiCaller(deps) {
|
|
|
955
955
|
const assertion = localOptions?.assertAuthority;
|
|
956
956
|
const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
|
|
957
957
|
assertCurrent();
|
|
958
|
+
// Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
|
|
959
|
+
const requestArgs = JSON.parse(JSON.stringify(args));
|
|
960
|
+
const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
|
|
961
|
+
? requestArgs[0] : method;
|
|
962
|
+
timing.apiMethod = apiMethod;
|
|
958
963
|
const registration = await resolveTelegramBusFollowerRegistration(deps, timeoutMs);
|
|
959
964
|
timing.registeredAtMs = getNowMs();
|
|
960
965
|
assertCurrent();
|
|
@@ -968,17 +973,13 @@ export function createTelegramBusFollowerApiCaller(deps) {
|
|
|
968
973
|
instanceId: deps.instanceId,
|
|
969
974
|
registrationGeneration: registration.generation,
|
|
970
975
|
method,
|
|
971
|
-
args,
|
|
976
|
+
args: requestArgs,
|
|
972
977
|
sentAtMs: getNowMs(),
|
|
973
978
|
}, assertion ? assertCurrent : undefined);
|
|
974
979
|
}
|
|
975
980
|
catch (error) {
|
|
976
981
|
if (error instanceof TelegramBusLocalAuthorityError)
|
|
977
982
|
throw new TelegramApiAuthorityError(error.requestIssued);
|
|
978
|
-
const apiMethod = (method === "call" || method === "callMultipart") &&
|
|
979
|
-
typeof args[0] === "string"
|
|
980
|
-
? args[0]
|
|
981
|
-
: method;
|
|
982
983
|
if (!isTelegramApiMethodRetrySafe(apiMethod)) {
|
|
983
984
|
throw new TelegramApiCommitUnknownError(apiMethod, error);
|
|
984
985
|
}
|
|
@@ -1002,15 +1003,19 @@ export function createTelegramBusFollowerApiCaller(deps) {
|
|
|
1002
1003
|
response.error?.code === "commit-unknown") {
|
|
1003
1004
|
throw new TelegramApiCommitUnknownError(response.error.method ?? method, new Error(message ?? "Telegram bus API call result is ambiguous."));
|
|
1004
1005
|
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1006
|
+
const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
|
|
1007
|
+
response.error?.code === "follower-api-not-allowed" &&
|
|
1008
|
+
response.error.method === apiMethod
|
|
1009
|
+
? apiMethod : undefined;
|
|
1010
|
+
if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
|
|
1011
|
+
const body = (method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
|
|
1012
|
+
? requestArgs[1]
|
|
1008
1013
|
: undefined;
|
|
1009
1014
|
const chatId = Number(body?.chat_id);
|
|
1010
1015
|
const threadId = Number(body?.message_thread_id);
|
|
1011
|
-
throw new TelegramApiFollowerNotAllowedError(message, Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
|
|
1016
|
+
throw new TelegramApiFollowerNotAllowedError(message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE, Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
|
|
1012
1017
|
? { chatId, threadId }
|
|
1013
|
-
: undefined);
|
|
1018
|
+
: undefined, refusedMethod);
|
|
1014
1019
|
}
|
|
1015
1020
|
throw new Error(message ?? "Telegram bus API call failed.");
|
|
1016
1021
|
};
|
|
@@ -1028,10 +1033,7 @@ export function createTelegramBusFollowerApiCaller(deps) {
|
|
|
1028
1033
|
finally {
|
|
1029
1034
|
const elapsedMs = getNowMs() - startedAtMs;
|
|
1030
1035
|
if (elapsedMs >= slowCallMs) {
|
|
1031
|
-
const apiMethod =
|
|
1032
|
-
typeof args[0] === "string"
|
|
1033
|
-
? args[0]
|
|
1034
|
-
: method;
|
|
1036
|
+
const apiMethod = timing.apiMethod ?? method;
|
|
1035
1037
|
try {
|
|
1036
1038
|
deps.recordRuntimeEvent?.("bus", `Telegram bus follower API call was slow (${elapsedMs}ms).`, {
|
|
1037
1039
|
phase: "follower-api-slow",
|
|
@@ -2770,7 +2770,14 @@ async function handleFollowerApiCall(envelope, deps) {
|
|
|
2770
2770
|
method: envelope.method,
|
|
2771
2771
|
args: envelope.args,
|
|
2772
2772
|
})) {
|
|
2773
|
-
|
|
2773
|
+
const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
|
|
2774
|
+
return {
|
|
2775
|
+
...rejectTelegramBusRequest(envelope.requestId, TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE),
|
|
2776
|
+
// Only this pre-dispatch authorizer refusal certifies that the API method was not called.
|
|
2777
|
+
...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
|
|
2778
|
+
? { error: { code: "follower-api-not-allowed", method: apiMethod } }
|
|
2779
|
+
: {}),
|
|
2780
|
+
};
|
|
2774
2781
|
}
|
|
2775
2782
|
if (!deps.callApi) {
|
|
2776
2783
|
return rejectTelegramBusRequest(envelope.requestId, "Telegram bus leader does not expose API calling.");
|
|
@@ -548,7 +548,7 @@ export type TelegramBusEnvelope = ({
|
|
|
548
548
|
result?: unknown;
|
|
549
549
|
protocol?: TelegramBusProtocolIdentity;
|
|
550
550
|
error?: {
|
|
551
|
-
code: "commit-unknown" | "request-id-collision" | "ledger-overloaded" | "incompatible-protocol" | "stale-target" | "workspace-binding-unavailable";
|
|
551
|
+
code: "commit-unknown" | "request-id-collision" | "ledger-overloaded" | "incompatible-protocol" | "stale-target" | "follower-api-not-allowed" | "workspace-binding-unavailable";
|
|
552
552
|
method?: string;
|
|
553
553
|
chatId?: number;
|
|
554
554
|
threadId?: number;
|
|
@@ -2657,9 +2657,15 @@ function parseAckEnvelope(value, requestId) {
|
|
|
2657
2657
|
code === "ledger-overloaded" ||
|
|
2658
2658
|
code === "incompatible-protocol" ||
|
|
2659
2659
|
code === "stale-target" ||
|
|
2660
|
+
code === "follower-api-not-allowed" ||
|
|
2660
2661
|
code === "workspace-binding-unavailable") {
|
|
2661
2662
|
const chatId = value.error.chatId;
|
|
2662
2663
|
const threadId = value.error.threadId;
|
|
2664
|
+
if (code === "follower-api-not-allowed" &&
|
|
2665
|
+
(value.ok || Object.hasOwn(value, "result") ||
|
|
2666
|
+
typeof value.error.method !== "string" ||
|
|
2667
|
+
!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method)))
|
|
2668
|
+
return undefined;
|
|
2663
2669
|
if (code === "stale-target" &&
|
|
2664
2670
|
(!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))) {
|
|
2665
2671
|
return undefined;
|
|
@@ -11,6 +11,7 @@ import type { TelegramPromptTemplateCommand } from "./prompt-templates.ts";
|
|
|
11
11
|
import { createTelegramControlItemBuilder, type PendingTelegramControlItem, type TelegramControlQueueController, type TelegramControlQueueControllerDeps, type TelegramQueueAdmissionReceipt } from "./queue.ts";
|
|
12
12
|
import { type TelegramBridgeStatusLineOptions } from "./status.ts";
|
|
13
13
|
import { type TelegramApiCallOptions } from "./telegram-api.ts";
|
|
14
|
+
import type { TelegramTarget } from "./target.ts";
|
|
14
15
|
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
15
16
|
import { type TelegramDeferredSourceEvidence, type TelegramLiveSourceCompletionReadiness, type TelegramQueueAdmissionReceiptLike } from "./updates.ts";
|
|
16
17
|
export type TelegramHeldCommandName = "status" | "start" | "help" | "abort" | "stop" | "next" | "continue";
|
|
@@ -383,11 +384,13 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
|
|
|
383
384
|
enqueueControlItem: (target: TelegramCommandMessageTarget, ctx: TContext, controlType: TelegramControlCommandType, statusSummary: string, execute: (ctx: TContext) => Promise<void>, admissionReceipts?: TelegramQueueAdmissionReceipt[], onQueued?: (item: PendingTelegramControlItem<TContext>) => void) => void;
|
|
384
385
|
getAdmissionScope?: () => string | undefined;
|
|
385
386
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
387
|
+
/** Ordinary reply destination selected before publication, not resend authority. */
|
|
388
|
+
getReplyTarget?: (message: TelegramCommandRuntimeMessage) => TelegramTarget | undefined;
|
|
386
389
|
onControlQueued?: (message: TelegramCommandRuntimeMessage, receipt: TelegramQueueAdmissionReceipt) => void;
|
|
387
|
-
showStatus: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
388
|
-
openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
389
|
-
openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
390
|
-
sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
|
|
390
|
+
showStatus: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
391
|
+
openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
392
|
+
openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
393
|
+
sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
|
|
391
394
|
parseMode?: "HTML";
|
|
392
395
|
target?: {
|
|
393
396
|
chatId: number;
|
|
@@ -629,6 +629,7 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
|
|
|
629
629
|
createControlItem,
|
|
630
630
|
enqueueControlItem: controlQueueController.enqueue,
|
|
631
631
|
}),
|
|
632
|
+
getReplyTarget: deps.getReplyTarget,
|
|
632
633
|
getAdmissionScope: deps.getAdmissionScope,
|
|
633
634
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
634
635
|
onControlQueued: deps.onControlQueued,
|
|
@@ -639,6 +640,22 @@ export function createTelegramCommandTargetQueueRuntime(deps) {
|
|
|
639
640
|
});
|
|
640
641
|
}
|
|
641
642
|
export function createTelegramCommandTargetRuntime(deps) {
|
|
643
|
+
const getReplyTarget = (message, options) => {
|
|
644
|
+
const source = getTelegramCommandMessageTarget(message);
|
|
645
|
+
const recipient = options?.assertAuthority
|
|
646
|
+
? undefined
|
|
647
|
+
: deps.getReplyTarget?.(message);
|
|
648
|
+
if (!recipient)
|
|
649
|
+
return source;
|
|
650
|
+
if (recipient.chatId !== source.chatId)
|
|
651
|
+
throw new Error("Telegram command reply cannot change its recipient chat.");
|
|
652
|
+
if (source.threadId !== undefined && recipient.threadId === undefined)
|
|
653
|
+
throw new Error("Telegram command cannot move a Thread reply to All.");
|
|
654
|
+
return {
|
|
655
|
+
...recipient,
|
|
656
|
+
replyToMessageId: recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
|
|
657
|
+
};
|
|
658
|
+
};
|
|
642
659
|
return {
|
|
643
660
|
enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
|
|
644
661
|
const sourceUpdateId = message.pi_telegram_source_update_id;
|
|
@@ -659,15 +676,15 @@ export function createTelegramCommandTargetRuntime(deps) {
|
|
|
659
676
|
deps.enqueueControlItem(getTelegramCommandMessageTarget(message), ctx, controlType, statusSummary, execute, receipt ? [receipt] : undefined, receipt ? () => deps.onControlQueued?.(message, receipt) : undefined);
|
|
660
677
|
},
|
|
661
678
|
showStatus: (message, ctx, options) => {
|
|
662
|
-
const target =
|
|
679
|
+
const target = getReplyTarget(message, options);
|
|
663
680
|
return deps.showStatus(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
|
|
664
681
|
},
|
|
665
682
|
openModelMenu: (message, ctx, options) => {
|
|
666
|
-
const target =
|
|
683
|
+
const target = getReplyTarget(message, options);
|
|
667
684
|
return deps.openModelMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
|
|
668
685
|
},
|
|
669
686
|
openSettingsMenu: async (message, ctx, options) => {
|
|
670
|
-
const target =
|
|
687
|
+
const target = getReplyTarget(message, options);
|
|
671
688
|
if (!deps.openSettingsMenu) {
|
|
672
689
|
await deps.sendTextReply(target.chatId, target.replyToMessageId, formatTelegramInformationHeading("🚫", "Settings menu is unavailable."), { target, parseMode: "HTML" });
|
|
673
690
|
return;
|
|
@@ -675,7 +692,7 @@ export function createTelegramCommandTargetRuntime(deps) {
|
|
|
675
692
|
await deps.openSettingsMenu(target.chatId, target.replyToMessageId, ctx, target.threadId, options);
|
|
676
693
|
},
|
|
677
694
|
sendTextReply: async (message, text, options) => {
|
|
678
|
-
const target =
|
|
695
|
+
const target = getReplyTarget(message, options);
|
|
679
696
|
const assertAuthority = options?.assertAuthority, send = deps.sendTextReply;
|
|
680
697
|
const capturedOptions = { ...options, target };
|
|
681
698
|
assertAuthority?.();
|
|
@@ -1061,6 +1078,7 @@ export function createTelegramCommandHandlerTargetRuntime(deps) {
|
|
|
1061
1078
|
}),
|
|
1062
1079
|
appendControlItem: deps.appendControlItem,
|
|
1063
1080
|
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
1081
|
+
getReplyTarget: deps.getReplyTarget,
|
|
1064
1082
|
getAdmissionScope: deps.getAdmissionScope,
|
|
1065
1083
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
1066
1084
|
onControlQueued: deps.onControlQueued,
|
|
@@ -1093,6 +1093,25 @@ export default function (pi) {
|
|
|
1093
1093
|
},
|
|
1094
1094
|
getAdmissionScope: getTelegramUpdateAdmissionScope,
|
|
1095
1095
|
getAdmissionJournalBinding: getTelegramQueueJournalBinding,
|
|
1096
|
+
getCommandReplyTarget(message) {
|
|
1097
|
+
if (message.chat.type !== "private" ||
|
|
1098
|
+
"business_connection_id" in message ||
|
|
1099
|
+
!Updates.getTelegramUpdateExecutionFence(message) ||
|
|
1100
|
+
lockRuntime.owns())
|
|
1101
|
+
return undefined;
|
|
1102
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1103
|
+
const binding = resolveTelegramFollowerJournalBinding();
|
|
1104
|
+
const target = telegramBusFollowerRegistrationState.getTarget();
|
|
1105
|
+
if (!telegramBusFollowerRegistrationState.isRegistered() ||
|
|
1106
|
+
!binding ||
|
|
1107
|
+
binding.recoveryKey !==
|
|
1108
|
+
followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
|
|
1109
|
+
!target ||
|
|
1110
|
+
(threadStore.getBotState().threadMode === "enabled" &&
|
|
1111
|
+
target.threadId === undefined))
|
|
1112
|
+
throw new Error("Telegram command reply lost its registered recipient.");
|
|
1113
|
+
return { ...target };
|
|
1114
|
+
},
|
|
1096
1115
|
beginCommandEffectWork: publicationRuntime.beginWork,
|
|
1097
1116
|
getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
|
|
1098
1117
|
recordMessageOwnership: messageOwnershipRuntime.recordRouted,
|
|
@@ -1400,15 +1419,6 @@ export default function (pi) {
|
|
|
1400
1419
|
return binding.hasAuthority?.() === true;
|
|
1401
1420
|
});
|
|
1402
1421
|
},
|
|
1403
|
-
recoverRejectedRecipientTarget(error) {
|
|
1404
|
-
const rejected = error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
|
|
1405
|
-
? error.requestTarget
|
|
1406
|
-
: undefined;
|
|
1407
|
-
const current = telegramBusFollowerRegistrationState.getTarget();
|
|
1408
|
-
return rejected && current?.threadId !== undefined
|
|
1409
|
-
? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
|
|
1410
|
-
: undefined;
|
|
1411
|
-
},
|
|
1412
1422
|
prepareUpdateForExecution(update) {
|
|
1413
1423
|
return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(update, textGroupRuntime.prepareForwardedMessage);
|
|
1414
1424
|
},
|
|
@@ -52,7 +52,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
|
|
|
52
52
|
sectionRegistry?: TelegramSectionRegistry;
|
|
53
53
|
}
|
|
54
54
|
export interface TelegramSettingsMenuRuntime<TContext> {
|
|
55
|
-
openSettingsMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
55
|
+
openSettingsMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
56
56
|
handleCallbackQuery: (query: {
|
|
57
57
|
id: string;
|
|
58
58
|
data?: string;
|
|
@@ -87,7 +87,7 @@ export interface TelegramMenuActionRuntimeDeps<TContext, TModel extends MenuMode
|
|
|
87
87
|
storeModelMenuState: (state: TelegramModelMenuState<TModel>) => void;
|
|
88
88
|
isIdle: (ctx: TContext) => boolean;
|
|
89
89
|
canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
|
|
90
|
-
sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
|
|
90
|
+
sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
|
|
91
91
|
target?: {
|
|
92
92
|
chatId: number;
|
|
93
93
|
threadId?: number;
|
|
@@ -102,9 +102,9 @@ export interface TelegramMenuActionRuntime<TContext, TModel extends MenuModel =
|
|
|
102
102
|
updateModelMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
103
103
|
updateThinkingMenuMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
104
104
|
updateStatusMessage: (state: TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
105
|
-
sendStatusMessage: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
106
|
-
openModelMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
107
|
-
openThinkingMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
105
|
+
sendStatusMessage: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
106
|
+
openModelMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
107
|
+
openThinkingMenu: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
108
108
|
}
|
|
109
109
|
export declare function handleTelegramMenuCallbackEntry(callbackQueryId: string, data: string | undefined, state: TelegramModelMenuState | undefined, deps: TelegramMenuCallbackEntryDeps): Promise<void>;
|
|
110
110
|
export declare function handleStoredTelegramMenuCallback<TModel extends MenuModel = MenuModel>(query: MenuCallbackQuery, deps: StoredTelegramMenuCallbackDeps<TModel>): Promise<void>;
|
|
@@ -174,6 +174,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
174
174
|
getCurrentInstanceId?: () => string | undefined;
|
|
175
175
|
getAdmissionScope?: () => string | undefined;
|
|
176
176
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
177
|
+
getCommandReplyTarget?: Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
|
|
177
178
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
178
179
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
179
180
|
recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
|
|
@@ -256,7 +257,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
256
257
|
isVoiceReplyActive?: () => boolean;
|
|
257
258
|
updateSettingsMenuMessage?: (state: Menu.TelegramModelMenuState<TModel>, ctx: TContext) => Promise<void>;
|
|
258
259
|
openQueueMenu: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
259
|
-
openSettingsMenu?: (chatId: number, replyToMessageId: number, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
260
|
+
openSettingsMenu?: (chatId: number, replyToMessageId: number | undefined, ctx: TContext, threadId?: number, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
|
|
260
261
|
settingsMenuCallbackHandler?: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
|
|
261
262
|
queueMenuCallbackHandler: (query: TCallbackQuery, ctx: TContext) => Promise<boolean>;
|
|
262
263
|
buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
|
|
@@ -303,7 +304,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
|
|
|
303
304
|
}) => Promise<string | undefined>;
|
|
304
305
|
/** Starts the animated placeholder on an answered guest inline message. */
|
|
305
306
|
startGuestPlaceholder?: (inlineMessageId: string) => void;
|
|
306
|
-
sendTextReply: (chatId: number, replyToMessageId: number, text: string, options?: {
|
|
307
|
+
sendTextReply: (chatId: number, replyToMessageId: number | undefined, text: string, options?: {
|
|
307
308
|
parseMode?: "HTML";
|
|
308
309
|
target?: Queue.TelegramQueueTarget;
|
|
309
310
|
assertAuthority?: TelegramApiCallOptions["assertAuthority"];
|
|
@@ -7139,6 +7139,7 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7139
7139
|
appendControlItem: deps.queueMutationRuntime.append,
|
|
7140
7140
|
getAdmissionScope: deps.getAdmissionScope,
|
|
7141
7141
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
7142
|
+
getReplyTarget: deps.getCommandReplyTarget,
|
|
7142
7143
|
onControlQueued: (message, receipt) => reportQueueAdmission([message], [receipt]),
|
|
7143
7144
|
showStatus: deps.menuActions.sendStatusMessage,
|
|
7144
7145
|
openModelMenu: deps.menuActions.openModelMenu,
|
|
@@ -505,16 +505,18 @@ export declare class TelegramApiStaleTargetError extends Error {
|
|
|
505
505
|
threadId: number;
|
|
506
506
|
});
|
|
507
507
|
}
|
|
508
|
-
/**
|
|
508
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
509
509
|
export declare class TelegramApiFollowerNotAllowedError extends Error {
|
|
510
510
|
readonly requestTarget: {
|
|
511
511
|
chatId: number;
|
|
512
512
|
threadId: number;
|
|
513
513
|
} | undefined;
|
|
514
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
515
|
+
readonly rejectedRequestMethod: string | undefined;
|
|
514
516
|
constructor(message: string, requestTarget?: {
|
|
515
517
|
chatId: number;
|
|
516
518
|
threadId: number;
|
|
517
|
-
});
|
|
519
|
+
}, rejectedRequestMethod?: string);
|
|
518
520
|
}
|
|
519
521
|
export declare function getTelegramApiErrorRequestTarget(error: unknown): {
|
|
520
522
|
chatId: number;
|
|
@@ -338,13 +338,16 @@ export class TelegramApiStaleTargetError extends Error {
|
|
|
338
338
|
this.requestTarget = { ...requestTarget };
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
-
/**
|
|
341
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
342
342
|
export class TelegramApiFollowerNotAllowedError extends Error {
|
|
343
343
|
requestTarget;
|
|
344
|
-
|
|
344
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
345
|
+
rejectedRequestMethod;
|
|
346
|
+
constructor(message, requestTarget, rejectedRequestMethod) {
|
|
345
347
|
super(message);
|
|
346
348
|
this.name = "TelegramApiFollowerNotAllowedError";
|
|
347
349
|
this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
|
|
350
|
+
this.rejectedRequestMethod = rejectedRequestMethod;
|
|
348
351
|
}
|
|
349
352
|
}
|
|
350
353
|
export function getTelegramApiErrorRequestTarget(error) {
|
|
@@ -1487,7 +1487,7 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
|
|
|
1487
1487
|
runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
|
|
1488
1488
|
inputCustodyBus?: TelegramInputCustodyBusBindingRuntime<TContext>;
|
|
1489
1489
|
acquireSourceReference?: (role: "leader" | "follower", binding: TelegramUpdateAdmissionLifecycleJournalBinding) => () => void;
|
|
1490
|
-
worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution"
|
|
1490
|
+
worker: Omit<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>, "journal" | "getJournalBindingKey" | "getRecipientBindingKey" | "hasAuthority" | "prepareUpdateForExecution">;
|
|
1491
1491
|
leader: {
|
|
1492
1492
|
resolveBinding: () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
|
|
1493
1493
|
hasAuthority: (ctx: TContext) => boolean;
|
|
@@ -1497,7 +1497,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate extends Te
|
|
|
1497
1497
|
isRegistered: () => boolean;
|
|
1498
1498
|
getGeneration: () => string | undefined;
|
|
1499
1499
|
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
1500
|
-
recoverRejectedRecipientTarget?: NonNullable<TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]>;
|
|
1501
1500
|
/** Session succession before worker creation, under the exact generation-fenced binding. */
|
|
1502
1501
|
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
|
|
1503
1502
|
hasAuthority?: () => boolean;
|
|
@@ -1509,19 +1508,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate extends TelegramJou
|
|
|
1509
1508
|
inputCustody?: TelegramCustodyExecutionJournal;
|
|
1510
1509
|
defaultHandle: (update: TUpdate, ctx: TContext, execution?: TelegramUpdateExecutionFence) => Promise<void>;
|
|
1511
1510
|
prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
|
|
1512
|
-
/**
|
|
1513
|
-
* Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
|
|
1514
|
-
* classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
|
|
1515
|
-
*/
|
|
1516
|
-
recoverRejectedRecipientTarget?: (error: unknown) => {
|
|
1517
|
-
rejected: {
|
|
1518
|
-
chatId: number;
|
|
1519
|
-
threadId: number;
|
|
1520
|
-
};
|
|
1521
|
-
current: TelegramTarget & {
|
|
1522
|
-
threadId: number;
|
|
1523
|
-
};
|
|
1524
|
-
} | undefined;
|
|
1525
1511
|
registry?: TelegramUpdateHandlerRegistry;
|
|
1526
1512
|
};
|
|
1527
1513
|
/** Compose source-bound routing and late grouped settlement under one worker. */
|
|
@@ -6359,7 +6359,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
|
|
|
6359
6359
|
const typedUpdate = update;
|
|
6360
6360
|
const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
|
|
6361
6361
|
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
6362
|
-
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
6363
6362
|
const projectTo = (destination) => ({ ...prepared,
|
|
6364
6363
|
...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
|
|
6365
6364
|
: withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
|
|
@@ -6374,17 +6373,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
|
|
|
6374
6373
|
if (await deps.settleTerminalExecutionFailure?.(error)) {
|
|
6375
6374
|
return { kind: "complete" };
|
|
6376
6375
|
}
|
|
6377
|
-
//
|
|
6378
|
-
//
|
|
6379
|
-
const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
|
|
6380
|
-
const message = prepared.message ?? prepared.edited_message;
|
|
6381
|
-
if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
|
|
6382
|
-
message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
|
|
6383
|
-
recovery.current.threadId !== recovery.rejected.threadId) {
|
|
6384
|
-
deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
|
|
6385
|
-
chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
|
|
6386
|
-
return await executeUpdate(projectTo(recovery.current), ctx, signal);
|
|
6387
|
-
}
|
|
6376
|
+
// A refused reply proves only that send was not issued, not that the handler made no prior effects.
|
|
6377
|
+
// Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
|
|
6388
6378
|
throw error;
|
|
6389
6379
|
}
|
|
6390
6380
|
},
|
|
@@ -6457,9 +6447,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly(deps) {
|
|
|
6457
6447
|
spendHistoricalInput: undefined,
|
|
6458
6448
|
onHeldSourcesPrepared: undefined,
|
|
6459
6449
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
6460
|
-
...(deps.follower.recoverRejectedRecipientTarget
|
|
6461
|
-
? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
|
|
6462
|
-
: {}),
|
|
6463
6450
|
});
|
|
6464
6451
|
},
|
|
6465
6452
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -673,12 +673,26 @@ This inspection authorizes no reload/restart, live-state mutation, fault injecti
|
|
|
673
673
|
## Security Boundaries
|
|
674
674
|
|
|
675
675
|
- Messages, edits, callbacks, and reactions check user authorization, not only chat/thread membership.
|
|
676
|
-
- Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary.
|
|
676
|
+
- Followers authenticate to the local leader IPC with a leader-minted capability secret carried in the active lock entry; registration, heartbeat, forwarded updates, and follower API calls without the secret are rejected. Registration rejections are surfaced verbatim in the follower `/telegram-connect` result, registration waits through leader-side Telegram thread provisioning, and successful registrations send an immediate heartbeat before the interval ticker so the leader does not prune a live follower before its first scheduled heartbeat. The local bus socket is also created under a private `0700` directory with `0600` socket permissions as a first local-only boundary. Follower API authorization refusal surfaces as `TelegramApiFollowerNotAllowedError` with the requested target when available. Only the leader's structured `follower-api-not-allowed` negative acknowledgement, emitted before API delegation and matched to the requested method, populates `rejectedRequestMethod`. Legacy error text, identical text thrown after delegation, a mismatched method or a lost acknowledgement supplies no such proof; old peers continue to receive the same human-readable rejection. The structured refusal proves only that the rejected API request was not executed, not that the command had no earlier effects. Admission therefore never immediately re-enters the handler or retargets its source to recover a reply. The original input remains in durable `retry-wait`; ordinary retries retain their documented at-least-once semantics. Safe reply-request recovery for re-created recipient Threads remains open in the backlog.
|
|
677
677
|
- Follower Bot API proxying is allowlisted and target-scoped where applicable. Ordinary sends remain confined to the follower's assigned thread; trusted runtime-marked `telegram_message` cross-target sends may address only a different thread inside the same paired chat, and the leader strips the internal marker before calling Telegram. This preserves requested inter-thread delivery without granting arbitrary bot or cross-chat control.
|
|
678
678
|
- Button and section callbacks verify authorized `from.id` and owning target/instance.
|
|
679
679
|
- Generated artifacts stay scoped to the owning thread after leader failover.
|
|
680
680
|
- Diagnostics redact bot tokens, large prompts, attachment paths, and handler output.
|
|
681
681
|
|
|
682
|
+
### Reply Recovery Ownership Boundary
|
|
683
|
+
|
|
684
|
+
A destination belongs to the complete delivery operation, not just the outgoing Bot API body. `replies.sendTelegramChunkSequence` captures one target for all chunks and records message ownership against it. Menu state independently retains `chatId`, `threadId` and the returned message ID through `menu-model.createTelegramMenuDelivery`. Its numeric send result cannot report a relocated Thread to the menu owner. Rewriting only a refused send's `message_thread_id` would therefore separate actual delivery from ownership and callback state; moving only later chunks would also split one reply across tabs.
|
|
685
|
+
|
|
686
|
+
Follower API arguments use one private JSON-wire snapshot made before registration waits. Issued method/body, refusal target/method and slow-call diagnostics derive from that same snapshot; later caller mutations cannot relabel the attempt. Caller serialization runs once, with local authority checked before capture and again before issuance.
|
|
687
|
+
|
|
688
|
+
Admission and recipient identity are separate. The local update execution fence carries its admission generation, update ID and abort signal; the queue-owner snapshot carries instance, process/birth and session-generation identity. Neither contains a complete recipient/session/profile/registration/target grant. Receipt and Workspace-admission namespaces are not the follower's registered profile key: selected-menu composition supplies that registered key separately, while the leader independently captures its active Workspace scope. No ordinary recipient-scoped reply-delivery lease joins these views today.
|
|
689
|
+
|
|
690
|
+
Pre-publication destination selection is separate from refused-send recovery. For an executing private-DM follower journal, the status/model/settings and text-reply target adapters select the acknowledged recipient target while the active profile/session journal binding still matches the worker. The execution fence checks admission currency; it does not grant delivery. The selected target is passed to the menu owner before state construction and to the existing reply sender before chunking, with the old reply anchor removed on a Thread change. The original input and its journal are not rewritten. A different chat, a Thread-to-All fallback or missing recipient binding refuses selection; explicit selected-menu authority keeps its original fixed destination. The ordinary leader-side API authorization remains unchanged. This prevents a stale source address from choosing the initial reply destination; it neither relocates an issued request nor supplies the missing reply-recovery lease.
|
|
691
|
+
|
|
692
|
+
Structured refusal is evidence about one API attempt, not a delivery or command recovery grant. Current delivery propagates that refusal without a replacement send, without editing the target or reply anchor, and without replaying acknowledged chunks. Already acknowledged messages keep their original ownership; the refused message acquires none. This applies to interactive HTML menus, split native Markdown and single Rich Message sections.
|
|
693
|
+
|
|
694
|
+
Guarded follower delivery must use a recipient-scoped transport that enforces authority at the leader, not serialize a local callback through the ordinary proxy. The existing selected-menu channel is bound to an exact live-rebind operation, source cohort and recipient; a raw journaled command with no matching retained operation cannot borrow that grant. Any future reply-only recovery must establish its own exact authority, keep the destination consistent in delivery and menu ownership, clear stale reply metadata, and refuse relocation after partial publication or an unknown outcome. Handler-replay containment stays enabled until that boundary is proven; this is not an automatic recovery feature.
|
|
695
|
+
|
|
682
696
|
## Acceptance Criteria
|
|
683
697
|
|
|
684
698
|
- [x] The lock semantics are redesigned as Telegram bus leadership with liveness proof and stale takeover rules.
|
|
@@ -1919,11 +1919,16 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1919
1919
|
method: string,
|
|
1920
1920
|
args: unknown[],
|
|
1921
1921
|
localOptions: Pick<TelegramApiCallOptions, "assertAuthority"> | undefined,
|
|
1922
|
-
timing: { registeredAtMs?: number },
|
|
1922
|
+
timing: { registeredAtMs?: number; apiMethod?: string },
|
|
1923
1923
|
): Promise<unknown> => {
|
|
1924
1924
|
const assertion = localOptions?.assertAuthority;
|
|
1925
1925
|
const assertCurrent = () => assertTelegramApiCallAuthority(assertion, false);
|
|
1926
1926
|
assertCurrent();
|
|
1927
|
+
// Match IPC's JSON semantics once, before awaits; send and refusal metadata share this identity.
|
|
1928
|
+
const requestArgs = JSON.parse(JSON.stringify(args)) as unknown[];
|
|
1929
|
+
const apiMethod = (method === "call" || method === "callMultipart") && typeof requestArgs[0] === "string"
|
|
1930
|
+
? requestArgs[0] : method;
|
|
1931
|
+
timing.apiMethod = apiMethod;
|
|
1927
1932
|
const registration = await resolveTelegramBusFollowerRegistration(
|
|
1928
1933
|
deps,
|
|
1929
1934
|
timeoutMs,
|
|
@@ -1943,18 +1948,13 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1943
1948
|
instanceId: deps.instanceId,
|
|
1944
1949
|
registrationGeneration: registration.generation,
|
|
1945
1950
|
method,
|
|
1946
|
-
args,
|
|
1951
|
+
args: requestArgs,
|
|
1947
1952
|
sentAtMs: getNowMs(),
|
|
1948
1953
|
},
|
|
1949
1954
|
assertion ? assertCurrent : undefined,
|
|
1950
1955
|
);
|
|
1951
1956
|
} catch (error) {
|
|
1952
1957
|
if (error instanceof TelegramBusLocalAuthorityError) throw new TelegramApiAuthorityError(error.requestIssued);
|
|
1953
|
-
const apiMethod =
|
|
1954
|
-
(method === "call" || method === "callMultipart") &&
|
|
1955
|
-
typeof args[0] === "string"
|
|
1956
|
-
? args[0]
|
|
1957
|
-
: method;
|
|
1958
1958
|
if (!isTelegramApiMethodRetrySafe(apiMethod)) {
|
|
1959
1959
|
throw new TelegramApiCommitUnknownError(apiMethod, error);
|
|
1960
1960
|
}
|
|
@@ -1988,25 +1988,30 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
1988
1988
|
new Error(message ?? "Telegram bus API call result is ambiguous."),
|
|
1989
1989
|
);
|
|
1990
1990
|
}
|
|
1991
|
-
|
|
1991
|
+
const refusedMethod = response?.kind === "bus.ack" && !response.ok &&
|
|
1992
|
+
response.error?.code === "follower-api-not-allowed" &&
|
|
1993
|
+
response.error.method === apiMethod
|
|
1994
|
+
? apiMethod : undefined;
|
|
1995
|
+
if (refusedMethod || message === TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE) {
|
|
1992
1996
|
const body =
|
|
1993
|
-
(method === "call" || method === "callMultipart") && isRecord(
|
|
1994
|
-
?
|
|
1997
|
+
(method === "call" || method === "callMultipart") && isRecord(requestArgs[1])
|
|
1998
|
+
? requestArgs[1]
|
|
1995
1999
|
: undefined;
|
|
1996
2000
|
const chatId = Number(body?.chat_id);
|
|
1997
2001
|
const threadId = Number(body?.message_thread_id);
|
|
1998
2002
|
throw new TelegramApiFollowerNotAllowedError(
|
|
1999
|
-
message,
|
|
2003
|
+
message ?? TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
|
|
2000
2004
|
Number.isSafeInteger(chatId) && Number.isSafeInteger(threadId)
|
|
2001
2005
|
? { chatId, threadId }
|
|
2002
2006
|
: undefined,
|
|
2007
|
+
refusedMethod,
|
|
2003
2008
|
);
|
|
2004
2009
|
}
|
|
2005
2010
|
throw new Error(message ?? "Telegram bus API call failed.");
|
|
2006
2011
|
};
|
|
2007
2012
|
return async (method, args, localOptions) => {
|
|
2008
2013
|
const startedAtMs = getNowMs();
|
|
2009
|
-
const timing: { registeredAtMs?: number } = {};
|
|
2014
|
+
const timing: { registeredAtMs?: number; apiMethod?: string } = {};
|
|
2010
2015
|
let outcome = "ok";
|
|
2011
2016
|
try {
|
|
2012
2017
|
return await caller(method, args, localOptions, timing);
|
|
@@ -2016,11 +2021,7 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
2016
2021
|
} finally {
|
|
2017
2022
|
const elapsedMs = getNowMs() - startedAtMs;
|
|
2018
2023
|
if (elapsedMs >= slowCallMs) {
|
|
2019
|
-
const apiMethod =
|
|
2020
|
-
(method === "call" || method === "callMultipart") &&
|
|
2021
|
-
typeof args[0] === "string"
|
|
2022
|
-
? args[0]
|
|
2023
|
-
: method;
|
|
2024
|
+
const apiMethod = timing.apiMethod ?? method;
|
|
2024
2025
|
try {
|
|
2025
2026
|
deps.recordRuntimeEvent?.(
|
|
2026
2027
|
"bus",
|
|
@@ -4189,10 +4189,17 @@ async function handleFollowerApiCall(
|
|
|
4189
4189
|
args: envelope.args,
|
|
4190
4190
|
})
|
|
4191
4191
|
) {
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4192
|
+
const apiMethod = getFollowerApiMethodAndBody(envelope).apiMethod;
|
|
4193
|
+
return {
|
|
4194
|
+
...rejectTelegramBusRequest(
|
|
4195
|
+
envelope.requestId,
|
|
4196
|
+
TELEGRAM_BUS_FOLLOWER_API_NOT_ALLOWED_MESSAGE,
|
|
4197
|
+
),
|
|
4198
|
+
// Only this pre-dispatch authorizer refusal certifies that the API method was not called.
|
|
4199
|
+
...(/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(apiMethod)
|
|
4200
|
+
? { error: { code: "follower-api-not-allowed" as const, method: apiMethod } }
|
|
4201
|
+
: {}),
|
|
4202
|
+
};
|
|
4196
4203
|
}
|
|
4197
4204
|
if (!deps.callApi) {
|
|
4198
4205
|
return rejectTelegramBusRequest(
|
|
@@ -1260,6 +1260,7 @@ export type TelegramBusEnvelope = (
|
|
|
1260
1260
|
| "ledger-overloaded"
|
|
1261
1261
|
| "incompatible-protocol"
|
|
1262
1262
|
| "stale-target"
|
|
1263
|
+
| "follower-api-not-allowed"
|
|
1263
1264
|
| "workspace-binding-unavailable";
|
|
1264
1265
|
method?: string;
|
|
1265
1266
|
chatId?: number;
|
|
@@ -4148,10 +4149,17 @@ function parseAckEnvelope(
|
|
|
4148
4149
|
code === "ledger-overloaded" ||
|
|
4149
4150
|
code === "incompatible-protocol" ||
|
|
4150
4151
|
code === "stale-target" ||
|
|
4152
|
+
code === "follower-api-not-allowed" ||
|
|
4151
4153
|
code === "workspace-binding-unavailable"
|
|
4152
4154
|
) {
|
|
4153
4155
|
const chatId = value.error.chatId;
|
|
4154
4156
|
const threadId = value.error.threadId;
|
|
4157
|
+
if (
|
|
4158
|
+
code === "follower-api-not-allowed" &&
|
|
4159
|
+
(value.ok || Object.hasOwn(value, "result") ||
|
|
4160
|
+
typeof value.error.method !== "string" ||
|
|
4161
|
+
!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(value.error.method))
|
|
4162
|
+
) return undefined;
|
|
4155
4163
|
if (
|
|
4156
4164
|
code === "stale-target" &&
|
|
4157
4165
|
(!Number.isSafeInteger(chatId) || !Number.isSafeInteger(threadId))
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
TelegramApiAuthorityError,
|
|
37
37
|
type TelegramApiCallOptions,
|
|
38
38
|
} from "./telegram-api.ts";
|
|
39
|
+
import type { TelegramTarget } from "./target.ts";
|
|
39
40
|
import type { TelegramSessionReplacementIntent } from "./threads.ts";
|
|
40
41
|
import {
|
|
41
42
|
acquireTelegramUpdateRouting,
|
|
@@ -1194,34 +1195,38 @@ export interface TelegramCommandTargetRuntimeDeps<TContext> {
|
|
|
1194
1195
|
) => void;
|
|
1195
1196
|
getAdmissionScope?: () => string | undefined;
|
|
1196
1197
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
1198
|
+
/** Ordinary reply destination selected before publication, not resend authority. */
|
|
1199
|
+
getReplyTarget?: (
|
|
1200
|
+
message: TelegramCommandRuntimeMessage,
|
|
1201
|
+
) => TelegramTarget | undefined;
|
|
1197
1202
|
onControlQueued?: (
|
|
1198
1203
|
message: TelegramCommandRuntimeMessage,
|
|
1199
1204
|
receipt: TelegramQueueAdmissionReceipt,
|
|
1200
1205
|
) => void;
|
|
1201
1206
|
showStatus: (
|
|
1202
1207
|
chatId: number,
|
|
1203
|
-
replyToMessageId: number,
|
|
1208
|
+
replyToMessageId: number | undefined,
|
|
1204
1209
|
ctx: TContext,
|
|
1205
1210
|
threadId?: number,
|
|
1206
1211
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1207
1212
|
) => Promise<void>;
|
|
1208
1213
|
openModelMenu: (
|
|
1209
1214
|
chatId: number,
|
|
1210
|
-
replyToMessageId: number,
|
|
1215
|
+
replyToMessageId: number | undefined,
|
|
1211
1216
|
ctx: TContext,
|
|
1212
1217
|
threadId?: number,
|
|
1213
1218
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1214
1219
|
) => Promise<void>;
|
|
1215
1220
|
openSettingsMenu?: (
|
|
1216
1221
|
chatId: number,
|
|
1217
|
-
replyToMessageId: number,
|
|
1222
|
+
replyToMessageId: number | undefined,
|
|
1218
1223
|
ctx: TContext,
|
|
1219
1224
|
threadId?: number,
|
|
1220
1225
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1221
1226
|
) => Promise<void>;
|
|
1222
1227
|
sendTextReply: (
|
|
1223
1228
|
chatId: number,
|
|
1224
|
-
replyToMessageId: number,
|
|
1229
|
+
replyToMessageId: number | undefined,
|
|
1225
1230
|
text: string,
|
|
1226
1231
|
options?: {
|
|
1227
1232
|
parseMode?: "HTML";
|
|
@@ -1336,6 +1341,7 @@ export function createTelegramCommandTargetQueueRuntime<
|
|
|
1336
1341
|
createControlItem,
|
|
1337
1342
|
enqueueControlItem: controlQueueController.enqueue,
|
|
1338
1343
|
}),
|
|
1344
|
+
getReplyTarget: deps.getReplyTarget,
|
|
1339
1345
|
getAdmissionScope: deps.getAdmissionScope,
|
|
1340
1346
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
1341
1347
|
onControlQueued: deps.onControlQueued,
|
|
@@ -1352,6 +1358,25 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1352
1358
|
>(
|
|
1353
1359
|
deps: TelegramCommandTargetRuntimeDeps<TContext>,
|
|
1354
1360
|
): TelegramCommandTargetRuntime<TMessage, TContext> {
|
|
1361
|
+
const getReplyTarget = (
|
|
1362
|
+
message: TMessage,
|
|
1363
|
+
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
1364
|
+
) => {
|
|
1365
|
+
const source = getTelegramCommandMessageTarget(message);
|
|
1366
|
+
const recipient = options?.assertAuthority
|
|
1367
|
+
? undefined
|
|
1368
|
+
: deps.getReplyTarget?.(message);
|
|
1369
|
+
if (!recipient) return source;
|
|
1370
|
+
if (recipient.chatId !== source.chatId)
|
|
1371
|
+
throw new Error("Telegram command reply cannot change its recipient chat.");
|
|
1372
|
+
if (source.threadId !== undefined && recipient.threadId === undefined)
|
|
1373
|
+
throw new Error("Telegram command cannot move a Thread reply to All.");
|
|
1374
|
+
return {
|
|
1375
|
+
...recipient,
|
|
1376
|
+
replyToMessageId:
|
|
1377
|
+
recipient.threadId === source.threadId ? source.replyToMessageId : undefined,
|
|
1378
|
+
};
|
|
1379
|
+
};
|
|
1355
1380
|
return {
|
|
1356
1381
|
enqueueControlItem: (message, ctx, controlType, statusSummary, execute) => {
|
|
1357
1382
|
const sourceUpdateId = message.pi_telegram_source_update_id;
|
|
@@ -1381,7 +1406,7 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1381
1406
|
);
|
|
1382
1407
|
},
|
|
1383
1408
|
showStatus: (message, ctx, options) => {
|
|
1384
|
-
const target =
|
|
1409
|
+
const target = getReplyTarget(message, options);
|
|
1385
1410
|
return deps.showStatus(
|
|
1386
1411
|
target.chatId,
|
|
1387
1412
|
target.replyToMessageId,
|
|
@@ -1391,7 +1416,7 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1391
1416
|
);
|
|
1392
1417
|
},
|
|
1393
1418
|
openModelMenu: (message, ctx, options) => {
|
|
1394
|
-
const target =
|
|
1419
|
+
const target = getReplyTarget(message, options);
|
|
1395
1420
|
return deps.openModelMenu(
|
|
1396
1421
|
target.chatId,
|
|
1397
1422
|
target.replyToMessageId,
|
|
@@ -1401,7 +1426,7 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1401
1426
|
);
|
|
1402
1427
|
},
|
|
1403
1428
|
openSettingsMenu: async (message, ctx, options) => {
|
|
1404
|
-
const target =
|
|
1429
|
+
const target = getReplyTarget(message, options);
|
|
1405
1430
|
if (!deps.openSettingsMenu) {
|
|
1406
1431
|
await deps.sendTextReply(
|
|
1407
1432
|
target.chatId,
|
|
@@ -1423,7 +1448,7 @@ export function createTelegramCommandTargetRuntime<
|
|
|
1423
1448
|
);
|
|
1424
1449
|
},
|
|
1425
1450
|
sendTextReply: async (message, text, options) => {
|
|
1426
|
-
const target =
|
|
1451
|
+
const target = getReplyTarget(message, options);
|
|
1427
1452
|
const assertAuthority = options?.assertAuthority,
|
|
1428
1453
|
send = deps.sendTextReply;
|
|
1429
1454
|
const capturedOptions = { ...options, target };
|
|
@@ -2217,6 +2242,7 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
2217
2242
|
}),
|
|
2218
2243
|
appendControlItem: deps.appendControlItem,
|
|
2219
2244
|
dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
|
|
2245
|
+
getReplyTarget: deps.getReplyTarget,
|
|
2220
2246
|
getAdmissionScope: deps.getAdmissionScope,
|
|
2221
2247
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
2222
2248
|
onControlQueued: deps.onControlQueued,
|
|
@@ -1364,6 +1364,27 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1364
1364
|
},
|
|
1365
1365
|
getAdmissionScope: getTelegramUpdateAdmissionScope,
|
|
1366
1366
|
getAdmissionJournalBinding: getTelegramQueueJournalBinding,
|
|
1367
|
+
getCommandReplyTarget(message) {
|
|
1368
|
+
if (
|
|
1369
|
+
message.chat.type !== "private" ||
|
|
1370
|
+
"business_connection_id" in message ||
|
|
1371
|
+
!Updates.getTelegramUpdateExecutionFence(message) ||
|
|
1372
|
+
lockRuntime.owns()
|
|
1373
|
+
) return undefined;
|
|
1374
|
+
Updates.assertTelegramUpdateExecutionCurrent(message);
|
|
1375
|
+
const binding = resolveTelegramFollowerJournalBinding();
|
|
1376
|
+
const target = telegramBusFollowerRegistrationState.getTarget();
|
|
1377
|
+
if (
|
|
1378
|
+
!telegramBusFollowerRegistrationState.isRegistered() ||
|
|
1379
|
+
!binding ||
|
|
1380
|
+
binding.recoveryKey !==
|
|
1381
|
+
followerAdmissionLifecycleRuntime.getJournalBindingKey() ||
|
|
1382
|
+
!target ||
|
|
1383
|
+
(threadStore.getBotState().threadMode === "enabled" &&
|
|
1384
|
+
target.threadId === undefined)
|
|
1385
|
+
) throw new Error("Telegram command reply lost its registered recipient.");
|
|
1386
|
+
return { ...target };
|
|
1387
|
+
},
|
|
1367
1388
|
beginCommandEffectWork: publicationRuntime.beginWork,
|
|
1368
1389
|
getMessageOwnership: messageOwnershipRuntime.getForwardOwnership,
|
|
1369
1390
|
recordMessageOwnership: messageOwnershipRuntime.recordRouted,
|
|
@@ -1763,16 +1784,6 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1763
1784
|
},
|
|
1764
1785
|
);
|
|
1765
1786
|
},
|
|
1766
|
-
recoverRejectedRecipientTarget(error) {
|
|
1767
|
-
const rejected =
|
|
1768
|
-
error instanceof TelegramApi.TelegramApiFollowerNotAllowedError
|
|
1769
|
-
? error.requestTarget
|
|
1770
|
-
: undefined;
|
|
1771
|
-
const current = telegramBusFollowerRegistrationState.getTarget();
|
|
1772
|
-
return rejected && current?.threadId !== undefined
|
|
1773
|
-
? { rejected, current: { chatId: current.chatId, threadId: current.threadId } }
|
|
1774
|
-
: undefined;
|
|
1775
|
-
},
|
|
1776
1787
|
prepareUpdateForExecution(update) {
|
|
1777
1788
|
return BusFollower.prepareTelegramBusFollowerJournaledUpdateForExecution(
|
|
1778
1789
|
update,
|
|
@@ -90,7 +90,7 @@ export interface TelegramSettingsMenuCallbackDeps extends TelegramSettingsMutati
|
|
|
90
90
|
export interface TelegramSettingsMenuRuntime<TContext> {
|
|
91
91
|
openSettingsMenu: (
|
|
92
92
|
chatId: number,
|
|
93
|
-
replyToMessageId: number,
|
|
93
|
+
replyToMessageId: number | undefined,
|
|
94
94
|
ctx: TContext,
|
|
95
95
|
threadId?: number,
|
|
96
96
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
@@ -264,7 +264,7 @@ export interface TelegramMenuActionRuntimeDeps<
|
|
|
264
264
|
canOfferInFlightModelSwitch: (ctx: TContext) => boolean;
|
|
265
265
|
sendTextReply: (
|
|
266
266
|
chatId: number,
|
|
267
|
-
replyToMessageId: number,
|
|
267
|
+
replyToMessageId: number | undefined,
|
|
268
268
|
text: string,
|
|
269
269
|
options?: {
|
|
270
270
|
target?: { chatId: number; threadId?: number };
|
|
@@ -294,21 +294,21 @@ export interface TelegramMenuActionRuntime<
|
|
|
294
294
|
) => Promise<void>;
|
|
295
295
|
sendStatusMessage: (
|
|
296
296
|
chatId: number,
|
|
297
|
-
replyToMessageId: number,
|
|
297
|
+
replyToMessageId: number | undefined,
|
|
298
298
|
ctx: TContext,
|
|
299
299
|
threadId?: number,
|
|
300
300
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
301
301
|
) => Promise<void>;
|
|
302
302
|
openModelMenu: (
|
|
303
303
|
chatId: number,
|
|
304
|
-
replyToMessageId: number,
|
|
304
|
+
replyToMessageId: number | undefined,
|
|
305
305
|
ctx: TContext,
|
|
306
306
|
threadId?: number,
|
|
307
307
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
308
308
|
) => Promise<void>;
|
|
309
309
|
openThinkingMenu: (
|
|
310
310
|
chatId: number,
|
|
311
|
-
replyToMessageId: number,
|
|
311
|
+
replyToMessageId: number | undefined,
|
|
312
312
|
ctx: TContext,
|
|
313
313
|
threadId?: number,
|
|
314
314
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
@@ -1499,6 +1499,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1499
1499
|
getCurrentInstanceId?: () => string | undefined;
|
|
1500
1500
|
getAdmissionScope?: () => string | undefined;
|
|
1501
1501
|
getAdmissionJournalBinding?: () => string | undefined;
|
|
1502
|
+
getCommandReplyTarget?:
|
|
1503
|
+
Commands.TelegramCommandTargetRuntimeDeps<TContext>["getReplyTarget"];
|
|
1502
1504
|
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
|
1503
1505
|
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
|
1504
1506
|
recordMessageOwnership?: Updates.TelegramMessageOwnershipRecorder;
|
|
@@ -1640,7 +1642,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1640
1642
|
) => Promise<void>;
|
|
1641
1643
|
openSettingsMenu?: (
|
|
1642
1644
|
chatId: number,
|
|
1643
|
-
replyToMessageId: number,
|
|
1645
|
+
replyToMessageId: number | undefined,
|
|
1644
1646
|
ctx: TContext,
|
|
1645
1647
|
threadId?: number,
|
|
1646
1648
|
options?: Pick<TelegramApiCallOptions, "assertAuthority">,
|
|
@@ -1728,7 +1730,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
1728
1730
|
startGuestPlaceholder?: (inlineMessageId: string) => void;
|
|
1729
1731
|
sendTextReply: (
|
|
1730
1732
|
chatId: number,
|
|
1731
|
-
replyToMessageId: number,
|
|
1733
|
+
replyToMessageId: number | undefined,
|
|
1732
1734
|
text: string,
|
|
1733
1735
|
options?: {
|
|
1734
1736
|
parseMode?: "HTML";
|
|
@@ -10181,6 +10183,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
10181
10183
|
appendControlItem: deps.queueMutationRuntime.append,
|
|
10182
10184
|
getAdmissionScope: deps.getAdmissionScope,
|
|
10183
10185
|
getAdmissionJournalBinding: deps.getAdmissionJournalBinding,
|
|
10186
|
+
getReplyTarget: deps.getCommandReplyTarget,
|
|
10184
10187
|
onControlQueued: (message, receipt) =>
|
|
10185
10188
|
reportQueueAdmission([message], [receipt]),
|
|
10186
10189
|
showStatus: deps.menuActions.sendStatusMessage,
|
|
@@ -1049,17 +1049,21 @@ export class TelegramApiStaleTargetError extends Error {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
|
-
/**
|
|
1052
|
+
/** A follower API refusal; legacy text alone does not prove the request was rejected before execution. */
|
|
1053
1053
|
export class TelegramApiFollowerNotAllowedError extends Error {
|
|
1054
1054
|
readonly requestTarget: { chatId: number; threadId: number } | undefined;
|
|
1055
|
+
/** Present only after a structured pre-execution refusal matches the requested API method. */
|
|
1056
|
+
readonly rejectedRequestMethod: string | undefined;
|
|
1055
1057
|
|
|
1056
1058
|
constructor(
|
|
1057
1059
|
message: string,
|
|
1058
1060
|
requestTarget?: { chatId: number; threadId: number },
|
|
1061
|
+
rejectedRequestMethod?: string,
|
|
1059
1062
|
) {
|
|
1060
1063
|
super(message);
|
|
1061
1064
|
this.name = "TelegramApiFollowerNotAllowedError";
|
|
1062
1065
|
this.requestTarget = requestTarget ? { ...requestTarget } : undefined;
|
|
1066
|
+
this.rejectedRequestMethod = rejectedRequestMethod;
|
|
1063
1067
|
}
|
|
1064
1068
|
}
|
|
1065
1069
|
|
|
@@ -10495,7 +10495,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
|
10495
10495
|
| "getRecipientBindingKey"
|
|
10496
10496
|
| "hasAuthority"
|
|
10497
10497
|
| "prepareUpdateForExecution"
|
|
10498
|
-
| "recoverRejectedRecipientTarget"
|
|
10499
10498
|
>;
|
|
10500
10499
|
leader: {
|
|
10501
10500
|
resolveBinding: () =>
|
|
@@ -10508,9 +10507,6 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
|
10508
10507
|
isRegistered: () => boolean;
|
|
10509
10508
|
getGeneration: () => string | undefined;
|
|
10510
10509
|
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
10511
|
-
recoverRejectedRecipientTarget?: NonNullable<
|
|
10512
|
-
TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>["recoverRejectedRecipientTarget"]
|
|
10513
|
-
>;
|
|
10514
10510
|
/** Session succession before worker creation, under the exact generation-fenced binding. */
|
|
10515
10511
|
prepareBinding?: (
|
|
10516
10512
|
binding: TelegramUpdateAdmissionLifecycleJournalBinding & {
|
|
@@ -10535,13 +10531,6 @@ export type TelegramUpdateAdmissionWorkerRuntimeDeps<
|
|
|
10535
10531
|
execution?: TelegramUpdateExecutionFence,
|
|
10536
10532
|
) => Promise<void>;
|
|
10537
10533
|
prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
|
|
10538
|
-
/**
|
|
10539
|
-
* Recipient-only recovery for a journaled message whose own Thread changed (for example after its Thread was re-created):
|
|
10540
|
-
* classifies a leader refusal of a reply to the original Thread and returns that Thread plus the owner's current one.
|
|
10541
|
-
*/
|
|
10542
|
-
recoverRejectedRecipientTarget?: (error: unknown) =>
|
|
10543
|
-
| { rejected: { chatId: number; threadId: number }; current: TelegramTarget & { threadId: number } }
|
|
10544
|
-
| undefined;
|
|
10545
10534
|
registry?: TelegramUpdateHandlerRegistry;
|
|
10546
10535
|
};
|
|
10547
10536
|
|
|
@@ -10668,7 +10657,6 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
|
10668
10657
|
const typedUpdate = update as TUpdate;
|
|
10669
10658
|
const prepared = deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate;
|
|
10670
10659
|
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
10671
|
-
// Journal/source evidence remains the original; only the warm released handler projection changes destination.
|
|
10672
10660
|
const projectTo = (destination: TelegramTarget & { threadId: number }): TUpdate => ({ ...prepared,
|
|
10673
10661
|
...(prepared.message ? { message: prepared.message.message_thread_id === destination.threadId ? { ...prepared.message }
|
|
10674
10662
|
: withoutTelegramTurnReplyAnchor({ ...prepared.message, message_thread_id: destination.threadId, reply_to_message: undefined }) } : {}),
|
|
@@ -10686,17 +10674,8 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
|
10686
10674
|
if (await deps.settleTerminalExecutionFailure?.(error)) {
|
|
10687
10675
|
return { kind: "complete" };
|
|
10688
10676
|
}
|
|
10689
|
-
//
|
|
10690
|
-
//
|
|
10691
|
-
const recovery = !target ? deps.recoverRejectedRecipientTarget?.(error) : undefined;
|
|
10692
|
-
const message = prepared.message ?? prepared.edited_message;
|
|
10693
|
-
if (recovery && message && message.message_thread_id === recovery.rejected.threadId &&
|
|
10694
|
-
message.chat?.id === recovery.rejected.chatId && recovery.current.chatId === recovery.rejected.chatId &&
|
|
10695
|
-
recovery.current.threadId !== recovery.rejected.threadId) {
|
|
10696
|
-
deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "recipient-target-recovery", updateId: update.update_id,
|
|
10697
|
-
chatId: recovery.current.chatId, staleThreadId: recovery.rejected.threadId, threadId: recovery.current.threadId });
|
|
10698
|
-
return await executeUpdate(projectTo(recovery.current), ctx, signal);
|
|
10699
|
-
}
|
|
10677
|
+
// A refused reply proves only that send was not issued, not that the handler made no prior effects.
|
|
10678
|
+
// Leave failure settlement to the worker's retry policy; never re-enter the handler to recover delivery here.
|
|
10700
10679
|
throw error;
|
|
10701
10680
|
}
|
|
10702
10681
|
},
|
|
@@ -10777,9 +10756,6 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
|
10777
10756
|
spendHistoricalInput: undefined,
|
|
10778
10757
|
onHeldSourcesPrepared: undefined,
|
|
10779
10758
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
10780
|
-
...(deps.follower.recoverRejectedRecipientTarget
|
|
10781
|
-
? { recoverRejectedRecipientTarget: deps.follower.recoverRejectedRecipientTarget }
|
|
10782
|
-
: {}),
|
|
10783
10759
|
});
|
|
10784
10760
|
},
|
|
10785
10761
|
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic macOS CI test shards
|
|
3
|
+
* Zones: CI, test execution
|
|
4
|
+
* Keeps real-process tests isolated and partitions the remaining files without changing test deadlines.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readdirSync } from "node:fs";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const [shard, phase, mode, ...extra] = process.argv.slice(2);
|
|
12
|
+
if (!["0", "1"].includes(shard) || !["process", "remaining"].includes(phase) ||
|
|
13
|
+
(mode !== undefined && mode !== "--list") || extra.length) {
|
|
14
|
+
throw new Error("Usage: run-macos-tests.mjs <0|1> <process|remaining> [--list]");
|
|
15
|
+
}
|
|
16
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
17
|
+
const entries = readdirSync(join(root, "tests"), { withFileTypes: true })
|
|
18
|
+
.filter((entry) => entry.name.endsWith(".test.ts"));
|
|
19
|
+
if (entries.some((entry) => !entry.isFile()))
|
|
20
|
+
throw new Error("The CI test inventory must contain only regular test files.");
|
|
21
|
+
const names = entries.map((entry) => entry.name).sort();
|
|
22
|
+
const processTest = "process-shutdown.test.ts";
|
|
23
|
+
if (!names.includes(processTest)) throw new Error("The real-process test suite is missing.");
|
|
24
|
+
const files = (phase === "process"
|
|
25
|
+
? (shard === "0" ? [processTest] : [])
|
|
26
|
+
: names.filter((name) => name !== processTest).filter((_, index) => index % 2 === Number(shard)))
|
|
27
|
+
.map((name) => `tests/${name}`);
|
|
28
|
+
if (mode === "--list") {
|
|
29
|
+
console.log(JSON.stringify(files));
|
|
30
|
+
} else if (files.length) {
|
|
31
|
+
console.log(`macOS shard ${Number(shard) + 1}/2 ${phase}: ${files.length} test file(s)`);
|
|
32
|
+
const result = spawnSync(process.execPath, [
|
|
33
|
+
"--experimental-strip-types", "--import", "./tests/fixtures/canonical-tmpdir.ts",
|
|
34
|
+
"--test", "--test-concurrency=1", `--test-reporter=${phase === "process" ? "spec" : "dot"}`,
|
|
35
|
+
...files,
|
|
36
|
+
], {
|
|
37
|
+
cwd: root,
|
|
38
|
+
stdio: "inherit",
|
|
39
|
+
env: { ...process.env, PATH: [join(root, "node_modules", ".bin"), process.env.PATH].filter(Boolean).join(delimiter) },
|
|
40
|
+
});
|
|
41
|
+
if (result.error) throw result.error;
|
|
42
|
+
process.exitCode = result.status ?? 1;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.36.
|
|
3
|
+
"version": "0.36.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@llblab/pi-codex-usage": "0.13.2",
|
|
51
51
|
"@llblab/pi-grow-loop": "0.9.0",
|
|
52
52
|
"@llblab/pi-state-flow": "0.26.5",
|
|
53
|
-
"@llblab/pi-telegram": "0.57.
|
|
53
|
+
"@llblab/pi-telegram": "0.57.11",
|
|
54
54
|
"@llblab/skills": "1.15.0"
|
|
55
55
|
},
|
|
56
56
|
"bundledDependencies": [
|