@llblab/pi-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +35 -0
- package/BACKLOG.md +3 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/node_modules/@llblab/pi-actors/AGENTS.md +150 -0
- package/node_modules/@llblab/pi-actors/BACKLOG.md +6 -0
- package/node_modules/@llblab/pi-actors/CHANGELOG.md +865 -0
- package/node_modules/@llblab/pi-actors/README.md +231 -0
- package/node_modules/@llblab/pi-actors/banner.jpg +0 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/artifact-manifest.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/control-endpoint.json +6 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/control-record.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/recipe-summary.json +8 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/run-state.json +10 -0
- package/node_modules/@llblab/pi-actors/dist/fixtures/protocol/trace-event.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/index.d.ts +7 -0
- package/node_modules/@llblab/pi-actors/dist/index.js +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +146 -0
- package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +800 -0
- package/node_modules/@llblab/pi-actors/dist/lib/automatic-review-runtime.d.ts +20 -0
- package/node_modules/@llblab/pi-actors/dist/lib/automatic-review-runtime.js +103 -0
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.d.ts +85 -0
- package/node_modules/@llblab/pi-actors/dist/lib/command-templates.js +804 -0
- package/node_modules/@llblab/pi-actors/dist/lib/config.d.ts +34 -0
- package/node_modules/@llblab/pi-actors/dist/lib/config.js +216 -0
- package/node_modules/@llblab/pi-actors/dist/lib/control-projection.d.ts +20 -0
- package/node_modules/@llblab/pi-actors/dist/lib/control-projection.js +66 -0
- package/node_modules/@llblab/pi-actors/dist/lib/control.d.ts +15 -0
- package/node_modules/@llblab/pi-actors/dist/lib/control.js +97 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-consolidation-transaction.d.ts +65 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-consolidation-transaction.js +610 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-consolidation.d.ts +35 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-consolidation.js +126 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-review.d.ts +56 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-review.js +254 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-sleep.d.ts +65 -0
- package/node_modules/@llblab/pi-actors/dist/lib/draft-sleep.js +467 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution-output.d.ts +25 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution-output.js +88 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution-sessions.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution-sessions.js +85 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution.d.ts +99 -0
- package/node_modules/@llblab/pi-actors/dist/lib/execution.js +725 -0
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +141 -0
- package/node_modules/@llblab/pi-actors/dist/lib/file-state.d.ts +18 -0
- package/node_modules/@llblab/pi-actors/dist/lib/file-state.js +310 -0
- package/node_modules/@llblab/pi-actors/dist/lib/identity.d.ts +9 -0
- package/node_modules/@llblab/pi-actors/dist/lib/identity.js +27 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-actions.d.ts +16 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-actions.js +57 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-command.d.ts +7 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-command.js +37 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.d.ts +100 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +824 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector.d.ts +20 -0
- package/node_modules/@llblab/pi-actors/dist/lib/inspector.js +136 -0
- package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +28 -0
- package/node_modules/@llblab/pi-actors/dist/lib/limits.js +28 -0
- package/node_modules/@llblab/pi-actors/dist/lib/model-context.d.ts +56 -0
- package/node_modules/@llblab/pi-actors/dist/lib/model-context.js +220 -0
- package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +186 -0
- package/node_modules/@llblab/pi-actors/dist/lib/observability.js +951 -0
- package/node_modules/@llblab/pi-actors/dist/lib/paths.d.ts +27 -0
- package/node_modules/@llblab/pi-actors/dist/lib/paths.js +72 -0
- package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +19 -0
- package/node_modules/@llblab/pi-actors/dist/lib/pi.js +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/preflight-diagnostics.d.ts +27 -0
- package/node_modules/@llblab/pi-actors/dist/lib/preflight-diagnostics.js +86 -0
- package/node_modules/@llblab/pi-actors/dist/lib/prompts.d.ts +24 -0
- package/node_modules/@llblab/pi-actors/dist/lib/prompts.js +49 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipe-control.d.ts +7 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipe-control.js +43 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-context.d.ts +36 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-context.js +197 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-discovery.d.ts +77 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-discovery.js +783 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-references.d.ts +113 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-references.js +1108 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-usage.d.ts +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/recipes-usage.js +494 -0
- package/node_modules/@llblab/pi-actors/dist/lib/registry.d.ts +67 -0
- package/node_modules/@llblab/pi-actors/dist/lib/registry.js +504 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-control.d.ts +14 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-control.js +110 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-diagnostics.d.ts +11 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-diagnostics.js +148 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-projection.d.ts +14 -0
- package/node_modules/@llblab/pi-actors/dist/lib/review-projection.js +166 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-evidence-policy.d.ts +95 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-evidence-policy.js +177 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +18 -0
- package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +125 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.d.ts +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.js +68 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-control-delivery.d.ts +28 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-control-delivery.js +150 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-control.d.ts +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-control.js +89 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-controls.d.ts +39 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-controls.js +283 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-identity.d.ts +5 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-identity.js +11 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-index.d.ts +27 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-index.js +142 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-ownership.d.ts +7 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-ownership.js +96 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-parent-teardown.d.ts +51 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-parent-teardown.js +172 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-process.d.ts +22 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-process.js +125 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-retention.d.ts +18 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-retention.js +108 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-start.d.ts +12 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-start.js +68 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-status.d.ts +10 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-status.js +68 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.d.ts +48 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.js +491 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime-identity.d.ts +7 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime-identity.js +35 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime-triage.d.ts +29 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime-triage.js +60 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.d.ts +69 -0
- package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +339 -0
- package/node_modules/@llblab/pi-actors/dist/lib/schema.d.ts +60 -0
- package/node_modules/@llblab/pi-actors/dist/lib/schema.js +428 -0
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +45 -0
- package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +204 -0
- package/node_modules/@llblab/pi-actors/dist/lib/state-readers.d.ts +21 -0
- package/node_modules/@llblab/pi-actors/dist/lib/state-readers.js +74 -0
- package/node_modules/@llblab/pi-actors/dist/lib/temp.d.ts +10 -0
- package/node_modules/@llblab/pi-actors/dist/lib/temp.js +90 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-lineage-transaction.js +597 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-lineage.d.ts +24 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-lineage.js +98 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-scheduler.d.ts +80 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-scheduler.js +494 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-transaction.d.ts +50 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review-transaction.js +362 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review.d.ts +56 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tool-review.js +197 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-access.d.ts +19 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-access.js +44 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.d.ts +15 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +532 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-local.d.ts +17 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-local.js +191 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-message.d.ts +9 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-message.js +139 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-register.d.ts +18 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-register.js +41 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-response.d.ts +20 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-response.js +243 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-spawn.d.ts +15 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools-spawn.js +133 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools.d.ts +28 -0
- package/node_modules/@llblab/pi-actors/dist/lib/tools.js +49 -0
- package/node_modules/@llblab/pi-actors/dist/lib/trace-projection.d.ts +22 -0
- package/node_modules/@llblab/pi-actors/dist/lib/trace-projection.js +231 -0
- package/node_modules/@llblab/pi-actors/dist/pi-actors/index.js +1 -0
- package/node_modules/@llblab/pi-actors/dist/scripts/async-runner.mjs +555 -0
- package/node_modules/@llblab/pi-actors/dist/scripts/build-dist.mjs +60 -0
- package/node_modules/@llblab/pi-actors/dist/scripts/conformance.mjs +63 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +126 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/command-validate.json +13 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/jsonl-tail.json +10 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/recipe-validate.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/resource-locker-snapshot.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/resource-locker.json +25 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/run-ops-snapshot.json +15 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/run-state-files.json +13 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/recipes/run-summary.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/references/diagnostics.md +44 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/references/persistent-tools.md +76 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/references/recipes.md +51 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +41 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/scripts/resource-locker.mjs +425 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/scripts/run-utils.mjs +164 -0
- package/node_modules/@llblab/pi-actors/dist/skills/actors/scripts/validate-recipe.mjs +370 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/SKILL.md +33 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/recipes/bundle.json +77 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/recipes/file-write.json +14 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/recipes/manifest.json +14 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/recipes/report.json +39 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/recipes/write.json +53 -0
- package/node_modules/@llblab/pi-actors/dist/skills/artifacts/scripts/artifact-utils.mjs +63 -0
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/SKILL.md +57 -0
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/genapps/music-player.mjs +360 -0
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/recipes/playback.json +29 -0
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback-client.mjs +143 -0
- package/node_modules/@llblab/pi-actors/dist/skills/music-player/scripts/playback.mjs +1446 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/SKILL.md +37 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/changelog-head.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/changelog-section.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/docs-maintenance.json +66 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/git-log.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/git-status.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/markdown-index.json +14 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/package-summary.json +9 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/release-readiness.json +115 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/release-summary.json +74 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/repo-health.json +75 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/run-ops.json +56 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/recipes/skill-summary.json +11 -0
- package/node_modules/@llblab/pi-actors/dist/skills/project-work/scripts/project-utils.mjs +89 -0
- package/node_modules/@llblab/pi-actors/dist/skills/recipe-memory/SKILL.md +36 -0
- package/node_modules/@llblab/pi-actors/dist/skills/recipe-memory/recipes/draft-review.json +14 -0
- package/node_modules/@llblab/pi-actors/dist/skills/recipe-memory/recipes/tool-review.json +14 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +84 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/architect.json +82 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/checkpoint-continuation.json +51 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/development-tasking.json +67 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/lens-review.json +63 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/quorum-review.json +65 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/research-synthesis.json +81 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/review-readiness.json +59 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-artifact.json +21 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-checkpoint.json +21 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-conflict-report.json +19 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-contradiction-map.json +20 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-critic.json +22 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-evidence-map.json +20 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-followup.json +21 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-judge.json +22 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-merge.json +24 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-normalize.json +20 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-plan.json +20 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-preflight.json +18 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-prompt.json +16 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-prompts.json +39 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-quorum.json +31 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-review-coordinator.json +170 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-review.json +26 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-task-card.json +22 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-tools.json +15 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/recipes/subagent-verify.json +23 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/references/development-swarm.md +189 -0
- package/node_modules/@llblab/pi-actors/dist/skills/swarm/references/review-swarms.md +115 -0
- package/node_modules/@llblab/pi-actors/docs/README.md +20 -0
- package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +50 -0
- package/node_modules/@llblab/pi-actors/docs/async-runs.md +141 -0
- package/node_modules/@llblab/pi-actors/docs/command-templates.md +321 -0
- package/node_modules/@llblab/pi-actors/docs/recipe-library.md +89 -0
- package/node_modules/@llblab/pi-actors/docs/releasing.md +28 -0
- package/node_modules/@llblab/pi-actors/docs/template-recipes.md +175 -0
- package/node_modules/@llblab/pi-actors/docs/tool-registry.md +90 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/artifact-manifest.json +9 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/control-endpoint.json +6 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/control-record.json +9 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/recipe-summary.json +8 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/run-state.json +10 -0
- package/node_modules/@llblab/pi-actors/fixtures/protocol/trace-event.json +9 -0
- package/node_modules/@llblab/pi-actors/index.ts +30 -0
- package/node_modules/@llblab/pi-actors/lib/async-runs.ts +1245 -0
- package/node_modules/@llblab/pi-actors/lib/automatic-review-runtime.ts +148 -0
- package/node_modules/@llblab/pi-actors/lib/command-templates.ts +1116 -0
- package/node_modules/@llblab/pi-actors/lib/config.ts +261 -0
- package/node_modules/@llblab/pi-actors/lib/control-projection.ts +105 -0
- package/node_modules/@llblab/pi-actors/lib/control.ts +117 -0
- package/node_modules/@llblab/pi-actors/lib/draft-consolidation-transaction.ts +821 -0
- package/node_modules/@llblab/pi-actors/lib/draft-consolidation.ts +181 -0
- package/node_modules/@llblab/pi-actors/lib/draft-review.ts +325 -0
- package/node_modules/@llblab/pi-actors/lib/draft-sleep.ts +575 -0
- package/node_modules/@llblab/pi-actors/lib/execution-output.ts +123 -0
- package/node_modules/@llblab/pi-actors/lib/execution-sessions.ts +111 -0
- package/node_modules/@llblab/pi-actors/lib/execution.ts +1153 -0
- package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +181 -0
- package/node_modules/@llblab/pi-actors/lib/file-state.ts +306 -0
- package/node_modules/@llblab/pi-actors/lib/identity.ts +29 -0
- package/node_modules/@llblab/pi-actors/lib/inspector-actions.ts +79 -0
- package/node_modules/@llblab/pi-actors/lib/inspector-command.ts +54 -0
- package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +901 -0
- package/node_modules/@llblab/pi-actors/lib/inspector.ts +204 -0
- package/node_modules/@llblab/pi-actors/lib/limits.ts +29 -0
- package/node_modules/@llblab/pi-actors/lib/model-context.ts +359 -0
- package/node_modules/@llblab/pi-actors/lib/observability.ts +1337 -0
- package/node_modules/@llblab/pi-actors/lib/paths.ts +111 -0
- package/node_modules/@llblab/pi-actors/lib/pi.ts +47 -0
- package/node_modules/@llblab/pi-actors/lib/preflight-diagnostics.ts +132 -0
- package/node_modules/@llblab/pi-actors/lib/prompts.ts +69 -0
- package/node_modules/@llblab/pi-actors/lib/recipe-control.ts +52 -0
- package/node_modules/@llblab/pi-actors/lib/recipes-context.ts +268 -0
- package/node_modules/@llblab/pi-actors/lib/recipes-discovery.ts +1060 -0
- package/node_modules/@llblab/pi-actors/lib/recipes-references.ts +1598 -0
- package/node_modules/@llblab/pi-actors/lib/recipes-usage.ts +638 -0
- package/node_modules/@llblab/pi-actors/lib/registry.ts +806 -0
- package/node_modules/@llblab/pi-actors/lib/review-control.ts +136 -0
- package/node_modules/@llblab/pi-actors/lib/review-diagnostics.ts +164 -0
- package/node_modules/@llblab/pi-actors/lib/review-projection.ts +196 -0
- package/node_modules/@llblab/pi-actors/lib/run-evidence-policy.ts +242 -0
- package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +155 -0
- package/node_modules/@llblab/pi-actors/lib/runs-artifacts.ts +96 -0
- package/node_modules/@llblab/pi-actors/lib/runs-control-delivery.ts +209 -0
- package/node_modules/@llblab/pi-actors/lib/runs-control.ts +148 -0
- package/node_modules/@llblab/pi-actors/lib/runs-controls.ts +282 -0
- package/node_modules/@llblab/pi-actors/lib/runs-identity.ts +14 -0
- package/node_modules/@llblab/pi-actors/lib/runs-index.ts +192 -0
- package/node_modules/@llblab/pi-actors/lib/runs-ownership.ts +136 -0
- package/node_modules/@llblab/pi-actors/lib/runs-parent-teardown.ts +257 -0
- package/node_modules/@llblab/pi-actors/lib/runs-process.ts +174 -0
- package/node_modules/@llblab/pi-actors/lib/runs-retention.ts +140 -0
- package/node_modules/@llblab/pi-actors/lib/runs-start.ts +113 -0
- package/node_modules/@llblab/pi-actors/lib/runs-status.ts +101 -0
- package/node_modules/@llblab/pi-actors/lib/runs-trace.ts +611 -0
- package/node_modules/@llblab/pi-actors/lib/runtime-identity.ts +39 -0
- package/node_modules/@llblab/pi-actors/lib/runtime-triage.ts +106 -0
- package/node_modules/@llblab/pi-actors/lib/runtime.ts +430 -0
- package/node_modules/@llblab/pi-actors/lib/schema.ts +525 -0
- package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +302 -0
- package/node_modules/@llblab/pi-actors/lib/state-readers.ts +93 -0
- package/node_modules/@llblab/pi-actors/lib/temp.ts +99 -0
- package/node_modules/@llblab/pi-actors/lib/tool-review-lineage-transaction.ts +881 -0
- package/node_modules/@llblab/pi-actors/lib/tool-review-lineage.ts +145 -0
- package/node_modules/@llblab/pi-actors/lib/tool-review-scheduler.ts +635 -0
- package/node_modules/@llblab/pi-actors/lib/tool-review-transaction.ts +563 -0
- package/node_modules/@llblab/pi-actors/lib/tool-review.ts +270 -0
- package/node_modules/@llblab/pi-actors/lib/tools-access.ts +68 -0
- package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +673 -0
- package/node_modules/@llblab/pi-actors/lib/tools-local.ts +303 -0
- package/node_modules/@llblab/pi-actors/lib/tools-message.ts +176 -0
- package/node_modules/@llblab/pi-actors/lib/tools-register.ts +63 -0
- package/node_modules/@llblab/pi-actors/lib/tools-response.ts +285 -0
- package/node_modules/@llblab/pi-actors/lib/tools-spawn.ts +212 -0
- package/node_modules/@llblab/pi-actors/lib/tools.ts +88 -0
- package/node_modules/@llblab/pi-actors/lib/trace-projection.ts +306 -0
- package/node_modules/@llblab/pi-actors/package.json +76 -0
- package/node_modules/@llblab/pi-actors/scripts/async-runner.mjs +555 -0
- package/node_modules/@llblab/pi-actors/scripts/build-dist.mjs +60 -0
- package/node_modules/@llblab/pi-actors/scripts/conformance.mjs +63 -0
- package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +126 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/command-validate.json +13 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/jsonl-tail.json +10 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/recipe-validate.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/resource-locker-snapshot.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/resource-locker.json +25 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/run-ops-snapshot.json +15 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/run-state-files.json +13 -0
- package/node_modules/@llblab/pi-actors/skills/actors/recipes/run-summary.json +9 -0
- package/node_modules/@llblab/pi-actors/skills/actors/references/diagnostics.md +44 -0
- package/node_modules/@llblab/pi-actors/skills/actors/references/persistent-tools.md +76 -0
- package/node_modules/@llblab/pi-actors/skills/actors/references/recipes.md +51 -0
- package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +41 -0
- package/node_modules/@llblab/pi-actors/skills/actors/scripts/resource-locker.mjs +425 -0
- package/node_modules/@llblab/pi-actors/skills/actors/scripts/run-utils.mjs +164 -0
- package/node_modules/@llblab/pi-actors/skills/actors/scripts/validate-recipe.mjs +370 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/SKILL.md +33 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/recipes/bundle.json +77 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/recipes/file-write.json +14 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/recipes/manifest.json +14 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/recipes/report.json +39 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/recipes/write.json +53 -0
- package/node_modules/@llblab/pi-actors/skills/artifacts/scripts/artifact-utils.mjs +63 -0
- package/node_modules/@llblab/pi-actors/skills/music-player/SKILL.md +57 -0
- package/node_modules/@llblab/pi-actors/skills/music-player/genapps/music-player.mjs +360 -0
- package/node_modules/@llblab/pi-actors/skills/music-player/recipes/playback.json +29 -0
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback-client.mjs +143 -0
- package/node_modules/@llblab/pi-actors/skills/music-player/scripts/playback.mjs +1446 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/SKILL.md +37 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/changelog-head.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/changelog-section.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/docs-maintenance.json +66 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/git-log.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/git-status.json +9 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/markdown-index.json +14 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/package-summary.json +9 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/release-readiness.json +115 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/release-summary.json +74 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/repo-health.json +75 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/run-ops.json +56 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/recipes/skill-summary.json +11 -0
- package/node_modules/@llblab/pi-actors/skills/project-work/scripts/project-utils.mjs +89 -0
- package/node_modules/@llblab/pi-actors/skills/recipe-memory/SKILL.md +36 -0
- package/node_modules/@llblab/pi-actors/skills/recipe-memory/recipes/draft-review.json +14 -0
- package/node_modules/@llblab/pi-actors/skills/recipe-memory/recipes/tool-review.json +14 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +84 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/architect.json +82 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/checkpoint-continuation.json +51 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/development-tasking.json +67 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/lens-review.json +63 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/quorum-review.json +65 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/research-synthesis.json +81 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/review-readiness.json +59 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-artifact.json +21 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-checkpoint.json +21 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-conflict-report.json +19 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-contradiction-map.json +20 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-critic.json +22 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-evidence-map.json +20 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-followup.json +21 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-judge.json +22 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-merge.json +24 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-normalize.json +20 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-plan.json +20 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-preflight.json +18 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-prompt.json +16 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-prompts.json +39 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-quorum.json +31 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-review-coordinator.json +170 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-review.json +26 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-task-card.json +22 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-tools.json +15 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/recipes/subagent-verify.json +23 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/references/development-swarm.md +189 -0
- package/node_modules/@llblab/pi-actors/skills/swarm/references/review-swarms.md +115 -0
- package/node_modules/@llblab/pi-codex-usage/AGENTS.md +17 -0
- package/node_modules/@llblab/pi-codex-usage/BACKLOG.md +8 -0
- package/node_modules/@llblab/pi-codex-usage/CHANGELOG.md +80 -0
- package/node_modules/@llblab/pi-codex-usage/LICENSE +22 -0
- package/node_modules/@llblab/pi-codex-usage/README.md +113 -0
- package/node_modules/@llblab/pi-codex-usage/banner.jpg +0 -0
- package/node_modules/@llblab/pi-codex-usage/index.ts +1525 -0
- package/node_modules/@llblab/pi-codex-usage/package.json +59 -0
- package/node_modules/@llblab/pi-grow-loop/AGENTS.md +56 -0
- package/node_modules/@llblab/pi-grow-loop/BACKLOG.md +3 -0
- package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +126 -0
- package/node_modules/@llblab/pi-grow-loop/README.md +216 -0
- package/node_modules/@llblab/pi-grow-loop/banner.jpg +0 -0
- package/node_modules/@llblab/pi-grow-loop/index.ts +219 -0
- package/node_modules/@llblab/pi-grow-loop/package.json +64 -0
- package/node_modules/@llblab/pi-grow-loop/skills/grow-loop/SKILL.md +131 -0
- package/node_modules/@llblab/pi-grow-loop/skills/while-true/SKILL.md +233 -0
- package/node_modules/@llblab/pi-telegram/AGENTS.md +157 -0
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +15 -0
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +886 -0
- package/node_modules/@llblab/pi-telegram/README.md +330 -0
- package/node_modules/@llblab/pi-telegram/api/activity.ts +15 -0
- package/node_modules/@llblab/pi-telegram/api/commands.ts +11 -0
- package/node_modules/@llblab/pi-telegram/api/delivery.ts +21 -0
- package/node_modules/@llblab/pi-telegram/api/inbound.ts +14 -0
- package/node_modules/@llblab/pi-telegram/api/keyboard.ts +11 -0
- package/node_modules/@llblab/pi-telegram/api/outbound.ts +11 -0
- package/node_modules/@llblab/pi-telegram/api/sections.ts +17 -0
- package/node_modules/@llblab/pi-telegram/api/status.ts +12 -0
- package/node_modules/@llblab/pi-telegram/api/updates.ts +16 -0
- package/node_modules/@llblab/pi-telegram/api/voice.ts +24 -0
- package/node_modules/@llblab/pi-telegram/docs/README.md +25 -0
- package/node_modules/@llblab/pi-telegram/docs/activity.md +304 -0
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +418 -0
- package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +48 -0
- package/node_modules/@llblab/pi-telegram/docs/command-templates.md +365 -0
- package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +176 -0
- package/node_modules/@llblab/pi-telegram/docs/delivery.md +225 -0
- package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +310 -0
- package/node_modules/@llblab/pi-telegram/docs/inbound.md +136 -0
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +467 -0
- package/node_modules/@llblab/pi-telegram/docs/outbound.md +193 -0
- package/node_modules/@llblab/pi-telegram/docs/public-api.md +599 -0
- package/node_modules/@llblab/pi-telegram/docs/sections.md +452 -0
- package/node_modules/@llblab/pi-telegram/docs/ui-style.md +256 -0
- package/node_modules/@llblab/pi-telegram/docs/updates.md +171 -0
- package/node_modules/@llblab/pi-telegram/docs/voice.md +217 -0
- package/node_modules/@llblab/pi-telegram/index.ts +1336 -0
- package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +714 -0
- package/node_modules/@llblab/pi-telegram/lib/activity.ts +782 -0
- package/node_modules/@llblab/pi-telegram/lib/agent-messages.ts +121 -0
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +1129 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +379 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +1941 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +2119 -0
- package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +244 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +2645 -0
- package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +954 -0
- package/node_modules/@llblab/pi-telegram/lib/commands.ts +1854 -0
- package/node_modules/@llblab/pi-telegram/lib/config.ts +1143 -0
- package/node_modules/@llblab/pi-telegram/lib/delivery.ts +891 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +104 -0
- package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +958 -0
- package/node_modules/@llblab/pi-telegram/lib/inbound.ts +824 -0
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +3140 -0
- package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +59 -0
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +623 -0
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +1422 -0
- package/node_modules/@llblab/pi-telegram/lib/logs.ts +393 -0
- package/node_modules/@llblab/pi-telegram/lib/media.ts +921 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +1182 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +926 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +876 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +274 -0
- package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +185 -0
- package/node_modules/@llblab/pi-telegram/lib/menu.ts +867 -0
- package/node_modules/@llblab/pi-telegram/lib/model.ts +672 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +981 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +331 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +515 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +285 -0
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +1007 -0
- package/node_modules/@llblab/pi-telegram/lib/ownership.ts +298 -0
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +127 -0
- package/node_modules/@llblab/pi-telegram/lib/pi.ts +197 -0
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +1588 -0
- package/node_modules/@llblab/pi-telegram/lib/preview.ts +846 -0
- package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +158 -0
- package/node_modules/@llblab/pi-telegram/lib/prompts.ts +218 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +3075 -0
- package/node_modules/@llblab/pi-telegram/lib/recovery.ts +464 -0
- package/node_modules/@llblab/pi-telegram/lib/rendering.ts +1213 -0
- package/node_modules/@llblab/pi-telegram/lib/replies.ts +892 -0
- package/node_modules/@llblab/pi-telegram/lib/routing.ts +2889 -0
- package/node_modules/@llblab/pi-telegram/lib/runtime.ts +628 -0
- package/node_modules/@llblab/pi-telegram/lib/sections.ts +710 -0
- package/node_modules/@llblab/pi-telegram/lib/setup.ts +220 -0
- package/node_modules/@llblab/pi-telegram/lib/skills.ts +21 -0
- package/node_modules/@llblab/pi-telegram/lib/status.ts +1550 -0
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +883 -0
- package/node_modules/@llblab/pi-telegram/lib/target.ts +49 -0
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +1747 -0
- package/node_modules/@llblab/pi-telegram/lib/text-groups.ts +487 -0
- package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +1002 -0
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +3183 -0
- package/node_modules/@llblab/pi-telegram/lib/time-injection.ts +78 -0
- package/node_modules/@llblab/pi-telegram/lib/turns.ts +753 -0
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +4950 -0
- package/node_modules/@llblab/pi-telegram/lib/voice.ts +340 -0
- package/node_modules/@llblab/pi-telegram/package.json +83 -0
- package/node_modules/@llblab/pi-telegram/screenshot.png +0 -0
- package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +435 -0
- package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +241 -0
- package/node_modules/@llblab/pi-telegram/skills/generative-apps/SKILL.md +114 -0
- package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +149 -0
- package/package.json +67 -0
|
@@ -0,0 +1,4950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram updates domain helpers
|
|
3
|
+
* Zones: telegram inbound, authorization, routing plans
|
|
4
|
+
* Owns update extraction, authorization, execution planning, generation-fenced journal draining, and the public update-handler registry
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createTelegramPrivateTarget,
|
|
11
|
+
createTelegramThreadTarget,
|
|
12
|
+
type TelegramTarget,
|
|
13
|
+
} from "./target.ts";
|
|
14
|
+
import type {
|
|
15
|
+
TelegramBusEnvelope,
|
|
16
|
+
TelegramBusFollowerView,
|
|
17
|
+
TelegramBusForeignUpdateSettlement,
|
|
18
|
+
TelegramProcessLiveness,
|
|
19
|
+
} from "./bus.ts";
|
|
20
|
+
import type { TelegramMessageOwnershipStore } from "./ownership.ts";
|
|
21
|
+
import {
|
|
22
|
+
TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH,
|
|
23
|
+
TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH,
|
|
24
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH,
|
|
25
|
+
areTelegramUpdateJournalQueueOwnersEqual,
|
|
26
|
+
getTelegramUpdateJournalBindingPath,
|
|
27
|
+
isTelegramUpdateJournalQueueOwnerProcess,
|
|
28
|
+
parseTelegramUpdateJournalQueueOwner,
|
|
29
|
+
type TelegramJournaledUpdate,
|
|
30
|
+
type TelegramUpdateJournalDeadQueueOwnerRecoveryResult,
|
|
31
|
+
type TelegramUpdateJournalOperatorDispositionInput,
|
|
32
|
+
type TelegramUpdateJournalOperatorDispositionResult,
|
|
33
|
+
type TelegramUpdateJournalQueueDiscardResult,
|
|
34
|
+
type TelegramUpdateJournalQueueHandoffAcceptResult,
|
|
35
|
+
type TelegramUpdateJournalQueueHandoffCancelResult,
|
|
36
|
+
type TelegramUpdateJournalQueueHandoffInput,
|
|
37
|
+
type TelegramUpdateJournalQueueHandoffOfferResult,
|
|
38
|
+
type TelegramUpdateJournalQueueOwner,
|
|
39
|
+
type TelegramUpdateJournalQueueOwnerIdentity,
|
|
40
|
+
} from "./journal.ts";
|
|
41
|
+
import {
|
|
42
|
+
areTelegramQueueAdmissionReceiptsEqual,
|
|
43
|
+
createTelegramQueueHandoff,
|
|
44
|
+
removeTelegramQueueItemByReceipt,
|
|
45
|
+
type PendingTelegramControlItem,
|
|
46
|
+
type TelegramControlQueueHandoffPayload,
|
|
47
|
+
type TelegramQueueAdmissionReceipt,
|
|
48
|
+
type TelegramQueueHandoffPayload,
|
|
49
|
+
type TelegramQueueHandoffStageResult,
|
|
50
|
+
type TelegramQueueReactionDisposition,
|
|
51
|
+
type TelegramQueueHandoffStagingRuntime,
|
|
52
|
+
type TelegramQueueItem,
|
|
53
|
+
} from "./queue.ts";
|
|
54
|
+
import {
|
|
55
|
+
createTelegramUserPairingRuntime,
|
|
56
|
+
getTelegramAuthorizationState,
|
|
57
|
+
type TelegramAuthorizationState,
|
|
58
|
+
type TelegramUserPairingRuntimeDeps,
|
|
59
|
+
} from "./config.ts";
|
|
60
|
+
|
|
61
|
+
// --- Extraction ---
|
|
62
|
+
|
|
63
|
+
export interface TelegramReactionTypeEmoji {
|
|
64
|
+
type: "emoji";
|
|
65
|
+
emoji: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TelegramReactionTypeNonEmoji {
|
|
69
|
+
type: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type TelegramReactionType =
|
|
73
|
+
| TelegramReactionTypeEmoji
|
|
74
|
+
| TelegramReactionTypeNonEmoji;
|
|
75
|
+
|
|
76
|
+
export const TELEGRAM_PRIORITY_REACTIONS = [
|
|
77
|
+
{ id: 10, name: "like", emoji: "👍" },
|
|
78
|
+
{ id: 11, name: "lightning", emoji: "⚡" },
|
|
79
|
+
{ id: 12, name: "heart", emoji: "❤" },
|
|
80
|
+
{ id: 13, name: "dove", emoji: "🕊" },
|
|
81
|
+
{ id: 14, name: "fire", emoji: "🔥" },
|
|
82
|
+
] as const;
|
|
83
|
+
export const TELEGRAM_REMOVAL_REACTIONS = [
|
|
84
|
+
{ id: 20, name: "dislike", emoji: "👎" },
|
|
85
|
+
{ id: 21, name: "ghost", emoji: "👻" },
|
|
86
|
+
{ id: 22, name: "broken-heart", emoji: "💔" },
|
|
87
|
+
{ id: 23, name: "poop", emoji: "💩" },
|
|
88
|
+
{ id: 24, name: "wastebasket", emoji: "🗑" },
|
|
89
|
+
] as const;
|
|
90
|
+
export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
|
|
91
|
+
TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
|
|
92
|
+
export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map(
|
|
93
|
+
(reaction) => reaction.emoji,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export interface TelegramUpdateDeletion {
|
|
97
|
+
deleted_business_messages?: { message_ids?: unknown };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isTelegramMessageIdList(value: unknown): value is number[] {
|
|
101
|
+
return Array.isArray(value) && value.every((item) => Number.isInteger(item));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizeTelegramReactionEmoji(emoji: string): string {
|
|
105
|
+
return emoji.replace(/\uFE0F/g, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function collectTelegramReactionEmojis(
|
|
109
|
+
reactions: TelegramReactionType[],
|
|
110
|
+
): Set<string> {
|
|
111
|
+
const emojis = new Set<string>();
|
|
112
|
+
for (const reaction of reactions) {
|
|
113
|
+
if (reaction.type === "emoji") {
|
|
114
|
+
const emojiReaction = reaction as TelegramReactionTypeEmoji;
|
|
115
|
+
emojis.add(normalizeTelegramReactionEmoji(emojiReaction.emoji));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return emojis;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getTelegramReactionEmoji(
|
|
122
|
+
emojis: Set<string>,
|
|
123
|
+
candidates: readonly string[],
|
|
124
|
+
): string | undefined {
|
|
125
|
+
return candidates.find((emoji) => emojis.has(emoji));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getTelegramQueueReactionDisposition(
|
|
129
|
+
reactions: TelegramReactionType[],
|
|
130
|
+
): TelegramQueueReactionDisposition {
|
|
131
|
+
const emojis = collectTelegramReactionEmojis(reactions);
|
|
132
|
+
const suppressionEmoji = getTelegramReactionEmoji(
|
|
133
|
+
emojis,
|
|
134
|
+
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
135
|
+
);
|
|
136
|
+
const priorityEmoji = getTelegramReactionEmoji(
|
|
137
|
+
emojis,
|
|
138
|
+
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
139
|
+
);
|
|
140
|
+
if (suppressionEmoji && priorityEmoji) {
|
|
141
|
+
return {
|
|
142
|
+
kind: "priority-suppressed",
|
|
143
|
+
priorityEmoji,
|
|
144
|
+
suppressionEmoji,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (suppressionEmoji) return { kind: "suppressed", emoji: suppressionEmoji };
|
|
148
|
+
if (priorityEmoji) return { kind: "priority", emoji: priorityEmoji };
|
|
149
|
+
return { kind: "default" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getTelegramQueueReactionTransition(
|
|
153
|
+
oldReactions: TelegramReactionType[],
|
|
154
|
+
newReactions: TelegramReactionType[],
|
|
155
|
+
): TelegramQueueReactionDisposition | undefined {
|
|
156
|
+
const oldEmojis = collectTelegramReactionEmojis(oldReactions);
|
|
157
|
+
const newEmojis = collectTelegramReactionEmojis(newReactions);
|
|
158
|
+
const oldPriorityEmoji = getTelegramReactionEmoji(
|
|
159
|
+
oldEmojis,
|
|
160
|
+
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
161
|
+
);
|
|
162
|
+
const newPriorityEmoji = getTelegramReactionEmoji(
|
|
163
|
+
newEmojis,
|
|
164
|
+
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
165
|
+
);
|
|
166
|
+
const oldSuppressionEmoji = getTelegramReactionEmoji(
|
|
167
|
+
oldEmojis,
|
|
168
|
+
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
169
|
+
);
|
|
170
|
+
const newSuppressionEmoji = getTelegramReactionEmoji(
|
|
171
|
+
newEmojis,
|
|
172
|
+
TELEGRAM_REMOVAL_REACTION_EMOJIS,
|
|
173
|
+
);
|
|
174
|
+
if (
|
|
175
|
+
oldPriorityEmoji === newPriorityEmoji &&
|
|
176
|
+
oldSuppressionEmoji === newSuppressionEmoji
|
|
177
|
+
) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const transition: Extract<
|
|
181
|
+
TelegramQueueReactionDisposition,
|
|
182
|
+
{ kind: "reaction-transition" }
|
|
183
|
+
> = { kind: "reaction-transition" };
|
|
184
|
+
if (oldPriorityEmoji !== newPriorityEmoji) {
|
|
185
|
+
transition.priorityEmoji = newPriorityEmoji ?? null;
|
|
186
|
+
}
|
|
187
|
+
if (oldSuppressionEmoji !== newSuppressionEmoji) {
|
|
188
|
+
transition.suppressionEmoji = newSuppressionEmoji ?? null;
|
|
189
|
+
}
|
|
190
|
+
return transition;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function extractDeletedTelegramMessageIds(
|
|
194
|
+
update: TelegramUpdateDeletion,
|
|
195
|
+
): number[] {
|
|
196
|
+
const deletedBusinessMessageIds =
|
|
197
|
+
update.deleted_business_messages?.message_ids;
|
|
198
|
+
if (isTelegramMessageIdList(deletedBusinessMessageIds)) {
|
|
199
|
+
return deletedBusinessMessageIds;
|
|
200
|
+
}
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Routing ---
|
|
205
|
+
|
|
206
|
+
export interface TelegramUser {
|
|
207
|
+
id: number;
|
|
208
|
+
is_bot: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface TelegramChat {
|
|
212
|
+
id?: number;
|
|
213
|
+
type: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface TelegramUpdateMessage {
|
|
217
|
+
chat: TelegramChat;
|
|
218
|
+
from?: TelegramUser;
|
|
219
|
+
message_id?: number;
|
|
220
|
+
message_thread_id?: number;
|
|
221
|
+
pi_telegram_agent_source_thread?: string;
|
|
222
|
+
forum_topic_created?: unknown;
|
|
223
|
+
forum_topic_closed?: unknown;
|
|
224
|
+
forum_topic_reopened?: unknown;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export type TelegramTopicLifecycleKind = "created" | "closed" | "reopened";
|
|
228
|
+
|
|
229
|
+
export interface TelegramTopicLifecycleUpdate<
|
|
230
|
+
TMessage = TelegramUpdateMessage,
|
|
231
|
+
> {
|
|
232
|
+
kind: TelegramTopicLifecycleKind;
|
|
233
|
+
message: TMessage;
|
|
234
|
+
target: TelegramTarget & { threadId: number };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getTelegramTopicLifecycleUpdate<
|
|
238
|
+
TMessage extends TelegramUpdateMessage,
|
|
239
|
+
>(
|
|
240
|
+
message: TMessage | undefined,
|
|
241
|
+
): TelegramTopicLifecycleUpdate<TMessage> | undefined {
|
|
242
|
+
if (
|
|
243
|
+
!message ||
|
|
244
|
+
typeof message.chat.id !== "number" ||
|
|
245
|
+
typeof message.message_thread_id !== "number"
|
|
246
|
+
) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const target: TelegramTarget & { threadId: number } = {
|
|
250
|
+
...createTelegramThreadTarget(message.chat.id, message.message_thread_id),
|
|
251
|
+
threadId: message.message_thread_id,
|
|
252
|
+
};
|
|
253
|
+
if (message.forum_topic_created !== undefined) {
|
|
254
|
+
return { kind: "created", message, target };
|
|
255
|
+
}
|
|
256
|
+
if (message.forum_topic_closed !== undefined) {
|
|
257
|
+
return { kind: "closed", message, target };
|
|
258
|
+
}
|
|
259
|
+
if (message.forum_topic_reopened !== undefined) {
|
|
260
|
+
return { kind: "reopened", message, target };
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface TelegramCallbackQuery {
|
|
266
|
+
id?: string;
|
|
267
|
+
from: TelegramUser;
|
|
268
|
+
message?: TelegramUpdateMessage;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface TelegramGuestMessage {
|
|
272
|
+
guest_query_id: string;
|
|
273
|
+
chat: TelegramChat;
|
|
274
|
+
from?: TelegramUser;
|
|
275
|
+
message_id?: number;
|
|
276
|
+
text?: string;
|
|
277
|
+
reply_to_message?: TelegramUpdateMessage;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function getTelegramMessageTarget(
|
|
281
|
+
message: TelegramUpdateMessage,
|
|
282
|
+
): TelegramTarget | undefined {
|
|
283
|
+
if (typeof message.chat.id !== "number") return undefined;
|
|
284
|
+
return typeof message.message_thread_id === "number"
|
|
285
|
+
? createTelegramThreadTarget(message.chat.id, message.message_thread_id)
|
|
286
|
+
: createTelegramPrivateTarget(message.chat.id);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface TelegramUpdateRouting {
|
|
290
|
+
message?: TelegramUpdateMessage;
|
|
291
|
+
edited_message?: TelegramUpdateMessage;
|
|
292
|
+
callback_query?: TelegramCallbackQuery;
|
|
293
|
+
guest_message?: TelegramGuestMessage;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function getAuthorizedTelegramCallbackQuery(
|
|
297
|
+
update: TelegramUpdateRouting,
|
|
298
|
+
allowedUserId?: number,
|
|
299
|
+
): TelegramCallbackQuery | undefined {
|
|
300
|
+
const query = update.callback_query;
|
|
301
|
+
if (!query || query.from.is_bot) return undefined;
|
|
302
|
+
const message = query.message;
|
|
303
|
+
if (!message) return undefined;
|
|
304
|
+
if (message.chat.type === "private") return query;
|
|
305
|
+
return query.from.id === allowedUserId ? query : undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function getAuthorizedTelegramMessage(
|
|
309
|
+
update: TelegramUpdateRouting,
|
|
310
|
+
allowedUserId?: number,
|
|
311
|
+
): TelegramUpdateMessage | undefined {
|
|
312
|
+
const message = update.message;
|
|
313
|
+
if (!message || !message.from || message.from.is_bot) return undefined;
|
|
314
|
+
if (message.chat.type === "private") return message;
|
|
315
|
+
return message.from.id === allowedUserId ? message : undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function getAuthorizedTelegramEditedMessage(
|
|
319
|
+
update: TelegramUpdateRouting,
|
|
320
|
+
allowedUserId?: number,
|
|
321
|
+
): TelegramUpdateMessage | undefined {
|
|
322
|
+
const message = update.edited_message;
|
|
323
|
+
if (!message || !message.from || message.from.is_bot) return undefined;
|
|
324
|
+
if (message.chat.type === "private") return message;
|
|
325
|
+
return message.from.id === allowedUserId ? message : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function getAuthorizedTelegramGuestMessage(
|
|
329
|
+
update: TelegramUpdateRouting,
|
|
330
|
+
): TelegramGuestMessage | undefined {
|
|
331
|
+
const guestMessage = update.guest_message;
|
|
332
|
+
if (!guestMessage || !guestMessage.from || guestMessage.from.is_bot) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
return guestMessage;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// --- Flow ---
|
|
339
|
+
|
|
340
|
+
export interface TelegramMessageOwnershipView {
|
|
341
|
+
instanceId: string;
|
|
342
|
+
ownerGeneration?: string;
|
|
343
|
+
recipientBindingKey?: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export type TelegramMessageOwnershipLookup = (
|
|
347
|
+
chatId: number,
|
|
348
|
+
messageId: number,
|
|
349
|
+
) => TelegramMessageOwnershipView | undefined;
|
|
350
|
+
|
|
351
|
+
export interface TelegramTargetOwnershipView {
|
|
352
|
+
instanceId: string;
|
|
353
|
+
ownerGeneration?: string;
|
|
354
|
+
recipientBindingKey?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type TelegramTargetOwnershipLookup = (
|
|
358
|
+
target: TelegramTarget,
|
|
359
|
+
) => TelegramTargetOwnershipView | undefined;
|
|
360
|
+
|
|
361
|
+
export interface TelegramForeignOwnedUpdateForwarder<
|
|
362
|
+
TContext,
|
|
363
|
+
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
364
|
+
TelegramMessageReactionUpdated,
|
|
365
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
366
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
367
|
+
> {
|
|
368
|
+
forwardCallback?: (input: {
|
|
369
|
+
query: TCallbackQuery;
|
|
370
|
+
ownership: TelegramMessageOwnershipView;
|
|
371
|
+
ctx: TContext;
|
|
372
|
+
}) =>
|
|
373
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
374
|
+
| TelegramBusForeignUpdateSettlement;
|
|
375
|
+
forwardReaction?: (input: {
|
|
376
|
+
reactionUpdate: TReactionUpdate;
|
|
377
|
+
ownership: TelegramMessageOwnershipView;
|
|
378
|
+
ctx: TContext;
|
|
379
|
+
}) =>
|
|
380
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
381
|
+
| TelegramBusForeignUpdateSettlement;
|
|
382
|
+
forwardMessage?: (input: {
|
|
383
|
+
message: TMessage;
|
|
384
|
+
ownership: TelegramTargetOwnershipView;
|
|
385
|
+
ctx: TContext;
|
|
386
|
+
}) =>
|
|
387
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
388
|
+
| TelegramBusForeignUpdateSettlement;
|
|
389
|
+
forwardEditedMessage?: (input: {
|
|
390
|
+
message: TMessage;
|
|
391
|
+
ownership: TelegramTargetOwnershipView;
|
|
392
|
+
ctx: TContext;
|
|
393
|
+
}) =>
|
|
394
|
+
| Promise<TelegramBusForeignUpdateSettlement>
|
|
395
|
+
| TelegramBusForeignUpdateSettlement;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
type TelegramForeignUpdateSettlementFailure =
|
|
399
|
+
| Exclude<TelegramBusForeignUpdateSettlement, { status: "accepted" }>
|
|
400
|
+
| {
|
|
401
|
+
status: "terminal-rejected";
|
|
402
|
+
failureClass: "forwarder-unavailable";
|
|
403
|
+
message: string;
|
|
404
|
+
sourceUpdateId?: number;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
export class TelegramForeignUpdateSettlementError extends Error {
|
|
408
|
+
readonly settlement: TelegramForeignUpdateSettlementFailure;
|
|
409
|
+
|
|
410
|
+
constructor(
|
|
411
|
+
operation: string,
|
|
412
|
+
settlement: TelegramForeignUpdateSettlementFailure,
|
|
413
|
+
) {
|
|
414
|
+
super(
|
|
415
|
+
`Telegram ${operation} forwarding did not settle: ${settlement.failureClass}.`,
|
|
416
|
+
);
|
|
417
|
+
this.name = "TelegramForeignUpdateSettlementError";
|
|
418
|
+
this.settlement = settlement;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function rejectTelegramForeignUpdateSettlement(
|
|
423
|
+
settlement: TelegramBusForeignUpdateSettlement | undefined,
|
|
424
|
+
operation: string,
|
|
425
|
+
source: unknown,
|
|
426
|
+
): never {
|
|
427
|
+
const sourceUpdateId =
|
|
428
|
+
source && typeof source === "object"
|
|
429
|
+
? Reflect.get(source, "pi_telegram_source_update_id")
|
|
430
|
+
: undefined;
|
|
431
|
+
const failure: TelegramForeignUpdateSettlementFailure =
|
|
432
|
+
settlement && settlement.status !== "accepted"
|
|
433
|
+
? settlement
|
|
434
|
+
: {
|
|
435
|
+
status: "terminal-rejected",
|
|
436
|
+
failureClass: "forwarder-unavailable",
|
|
437
|
+
message: `Telegram ${operation} forwarding is unavailable.`,
|
|
438
|
+
...(Number.isSafeInteger(sourceUpdateId) && sourceUpdateId >= 0
|
|
439
|
+
? { sourceUpdateId }
|
|
440
|
+
: {}),
|
|
441
|
+
};
|
|
442
|
+
throw new TelegramForeignUpdateSettlementError(operation, failure);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export interface TelegramMessageReactionUpdated {
|
|
446
|
+
chat: { id?: number; type: string };
|
|
447
|
+
user?: TelegramUser;
|
|
448
|
+
message_id: number;
|
|
449
|
+
old_reaction: TelegramReactionType[];
|
|
450
|
+
new_reaction: TelegramReactionType[];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export const TELEGRAM_INTERNAL_AGENT_MESSAGE = Symbol(
|
|
454
|
+
"telegram.internalAgentMessage",
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
export interface TelegramUpdateFlow
|
|
458
|
+
extends TelegramUpdateRouting, TelegramUpdateDeletion {
|
|
459
|
+
message_reaction?: TelegramMessageReactionUpdated;
|
|
460
|
+
[TELEGRAM_INTERNAL_AGENT_MESSAGE]?: true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type TelegramUpdateAdmissionOutcome =
|
|
464
|
+
| { kind: "complete" }
|
|
465
|
+
| { kind: "deferred" }
|
|
466
|
+
| {
|
|
467
|
+
kind: "queued";
|
|
468
|
+
queueKind: "prompt" | "control";
|
|
469
|
+
receiptId: string;
|
|
470
|
+
sourceUpdateIds: readonly number[];
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
type TelegramQueuedUpdateAdmissionOutcome = Extract<
|
|
474
|
+
TelegramUpdateAdmissionOutcome,
|
|
475
|
+
{ kind: "queued" }
|
|
476
|
+
>;
|
|
477
|
+
|
|
478
|
+
const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
|
|
479
|
+
"telegram.update-admission.binding",
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
interface TelegramUpdateAdmissionBinding {
|
|
483
|
+
sourceUpdateId: number;
|
|
484
|
+
report: (
|
|
485
|
+
outcome: Extract<
|
|
486
|
+
TelegramUpdateAdmissionOutcome,
|
|
487
|
+
{ kind: "deferred" | "queued" }
|
|
488
|
+
>,
|
|
489
|
+
) => void;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
|
|
493
|
+
|
|
494
|
+
function bindTelegramUpdateAdmissionCarrier<TValue>(
|
|
495
|
+
value: TValue | undefined,
|
|
496
|
+
binding: TelegramUpdateAdmissionBinding,
|
|
497
|
+
): TValue | undefined {
|
|
498
|
+
if (!value || typeof value !== "object") return value;
|
|
499
|
+
return {
|
|
500
|
+
...(value as Record<PropertyKey, unknown>),
|
|
501
|
+
pi_telegram_source_update_id: binding.sourceUpdateId,
|
|
502
|
+
[TELEGRAM_UPDATE_ADMISSION_BINDING]: binding,
|
|
503
|
+
} as TValue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function getTelegramUpdateAdmissionBinding(
|
|
507
|
+
value: unknown,
|
|
508
|
+
): TelegramUpdateAdmissionBinding | undefined {
|
|
509
|
+
if (!value || typeof value !== "object") return undefined;
|
|
510
|
+
const binding = Reflect.get(value, TELEGRAM_UPDATE_ADMISSION_BINDING) as
|
|
511
|
+
| TelegramUpdateAdmissionBinding
|
|
512
|
+
| undefined;
|
|
513
|
+
return binding &&
|
|
514
|
+
Number.isSafeInteger(binding.sourceUpdateId) &&
|
|
515
|
+
binding.sourceUpdateId >= 0 &&
|
|
516
|
+
typeof binding.report === "function"
|
|
517
|
+
? binding
|
|
518
|
+
: undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function bindTelegramUpdateAdmissionSource<
|
|
522
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
523
|
+
>(
|
|
524
|
+
update: TUpdate,
|
|
525
|
+
report: TelegramUpdateAdmissionBinding["report"],
|
|
526
|
+
): TUpdate {
|
|
527
|
+
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
|
|
528
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
529
|
+
"Telegram update admission requires a safe update_id.",
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const binding: TelegramUpdateAdmissionBinding = {
|
|
533
|
+
sourceUpdateId: update.update_id,
|
|
534
|
+
report,
|
|
535
|
+
};
|
|
536
|
+
const callbackQuery = update.callback_query
|
|
537
|
+
? bindTelegramUpdateAdmissionCarrier(
|
|
538
|
+
{
|
|
539
|
+
...update.callback_query,
|
|
540
|
+
message: bindTelegramUpdateAdmissionCarrier(
|
|
541
|
+
update.callback_query.message,
|
|
542
|
+
binding,
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
binding,
|
|
546
|
+
)
|
|
547
|
+
: undefined;
|
|
548
|
+
return {
|
|
549
|
+
...update,
|
|
550
|
+
...(update.message
|
|
551
|
+
? { message: bindTelegramUpdateAdmissionCarrier(update.message, binding) }
|
|
552
|
+
: {}),
|
|
553
|
+
...(update.edited_message
|
|
554
|
+
? {
|
|
555
|
+
edited_message: bindTelegramUpdateAdmissionCarrier(
|
|
556
|
+
update.edited_message,
|
|
557
|
+
binding,
|
|
558
|
+
),
|
|
559
|
+
}
|
|
560
|
+
: {}),
|
|
561
|
+
...(callbackQuery ? { callback_query: callbackQuery } : {}),
|
|
562
|
+
...(update.guest_message
|
|
563
|
+
? {
|
|
564
|
+
guest_message: bindTelegramUpdateAdmissionCarrier(
|
|
565
|
+
update.guest_message,
|
|
566
|
+
binding,
|
|
567
|
+
),
|
|
568
|
+
}
|
|
569
|
+
: {}),
|
|
570
|
+
...(update.message_reaction
|
|
571
|
+
? {
|
|
572
|
+
message_reaction: bindTelegramUpdateAdmissionCarrier(
|
|
573
|
+
update.message_reaction,
|
|
574
|
+
binding,
|
|
575
|
+
),
|
|
576
|
+
}
|
|
577
|
+
: {}),
|
|
578
|
+
} as TUpdate;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function collectTelegramAdmissionSourceUpdateIds(
|
|
582
|
+
values: readonly unknown[],
|
|
583
|
+
): number[] {
|
|
584
|
+
const sourceUpdateIds = new Set<number>();
|
|
585
|
+
for (const value of values) {
|
|
586
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
587
|
+
if (binding) sourceUpdateIds.add(binding.sourceUpdateId);
|
|
588
|
+
}
|
|
589
|
+
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function reportTelegramUpdateDeferred(value: unknown): boolean {
|
|
593
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
594
|
+
if (!binding) return false;
|
|
595
|
+
binding.report({ kind: "deferred" });
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function reportTelegramQueueAdmission(
|
|
600
|
+
values: readonly unknown[],
|
|
601
|
+
receipts: readonly TelegramQueueAdmissionReceiptLike[],
|
|
602
|
+
): boolean {
|
|
603
|
+
const bindings = new Map<number, TelegramUpdateAdmissionBinding>();
|
|
604
|
+
for (const value of values) {
|
|
605
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
606
|
+
if (!binding) continue;
|
|
607
|
+
const existing = bindings.get(binding.sourceUpdateId);
|
|
608
|
+
if (existing && existing !== binding) {
|
|
609
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
610
|
+
`Telegram update ${binding.sourceUpdateId} has conflicting admission bindings.`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
bindings.set(binding.sourceUpdateId, binding);
|
|
614
|
+
}
|
|
615
|
+
if (bindings.size === 0) return false;
|
|
616
|
+
const receiptsByUpdateId = new Map<
|
|
617
|
+
number,
|
|
618
|
+
{ receipt: TelegramQueueAdmissionReceiptLike; count: number }
|
|
619
|
+
>();
|
|
620
|
+
for (const receipt of receipts) {
|
|
621
|
+
for (const sourceUpdateId of new Set(receipt.sourceUpdateIds)) {
|
|
622
|
+
const existing = receiptsByUpdateId.get(sourceUpdateId);
|
|
623
|
+
if (existing) existing.count += 1;
|
|
624
|
+
else receiptsByUpdateId.set(sourceUpdateId, { receipt, count: 1 });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const reports = [...bindings].map(([sourceUpdateId, binding]) => {
|
|
628
|
+
const match = receiptsByUpdateId.get(sourceUpdateId);
|
|
629
|
+
if (!match || match.count !== 1) {
|
|
630
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
631
|
+
`Telegram update ${sourceUpdateId} requires one exact queue receipt.`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
binding,
|
|
636
|
+
outcome: {
|
|
637
|
+
kind: "queued" as const,
|
|
638
|
+
queueKind: match.receipt.queueKind,
|
|
639
|
+
receiptId: match.receipt.receiptId,
|
|
640
|
+
sourceUpdateIds: [...match.receipt.sourceUpdateIds],
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
});
|
|
644
|
+
for (const report of reports) report.binding.report(report.outcome);
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export type TelegramUpdateFlowAction<
|
|
649
|
+
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
650
|
+
TelegramMessageReactionUpdated,
|
|
651
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
652
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
653
|
+
TGuestMessage extends TelegramGuestMessage = TelegramGuestMessage,
|
|
654
|
+
> =
|
|
655
|
+
| { kind: "ignore" }
|
|
656
|
+
| { kind: "deleted"; messageIds: number[] }
|
|
657
|
+
| { kind: "reaction"; reactionUpdate: TReactionUpdate }
|
|
658
|
+
| {
|
|
659
|
+
kind: "topic-lifecycle";
|
|
660
|
+
lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
|
|
661
|
+
}
|
|
662
|
+
| {
|
|
663
|
+
kind: "callback";
|
|
664
|
+
query: TCallbackQuery;
|
|
665
|
+
authorization: TelegramAuthorizationState;
|
|
666
|
+
}
|
|
667
|
+
| {
|
|
668
|
+
kind: "message";
|
|
669
|
+
message: TMessage & { from: TelegramUser };
|
|
670
|
+
authorization: TelegramAuthorizationState;
|
|
671
|
+
}
|
|
672
|
+
| {
|
|
673
|
+
kind: "edited-message";
|
|
674
|
+
message: TMessage & { from: TelegramUser };
|
|
675
|
+
authorization: TelegramAuthorizationState;
|
|
676
|
+
}
|
|
677
|
+
| {
|
|
678
|
+
kind: "guest";
|
|
679
|
+
guestMessage: TGuestMessage & { from: TelegramUser };
|
|
680
|
+
authorization: TelegramAuthorizationState;
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
export function buildTelegramUpdateFlowAction<
|
|
684
|
+
TUpdate extends TelegramUpdateFlow,
|
|
685
|
+
>(
|
|
686
|
+
update: TUpdate,
|
|
687
|
+
allowedUserId?: number,
|
|
688
|
+
): TelegramUpdateFlowAction<
|
|
689
|
+
NonNullable<TUpdate["message_reaction"]>,
|
|
690
|
+
NonNullable<TUpdate["callback_query"]>,
|
|
691
|
+
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>,
|
|
692
|
+
NonNullable<TUpdate["guest_message"]>
|
|
693
|
+
> {
|
|
694
|
+
const deletedMessageIds = extractDeletedTelegramMessageIds(update);
|
|
695
|
+
if (deletedMessageIds.length > 0) {
|
|
696
|
+
return { kind: "deleted", messageIds: deletedMessageIds };
|
|
697
|
+
}
|
|
698
|
+
if (update.message_reaction) {
|
|
699
|
+
return { kind: "reaction", reactionUpdate: update.message_reaction };
|
|
700
|
+
}
|
|
701
|
+
const topicLifecycle = getTelegramTopicLifecycleUpdate(update.message);
|
|
702
|
+
if (topicLifecycle) {
|
|
703
|
+
return { kind: "topic-lifecycle", lifecycle: topicLifecycle };
|
|
704
|
+
}
|
|
705
|
+
const query = getAuthorizedTelegramCallbackQuery(update, allowedUserId);
|
|
706
|
+
if (query) {
|
|
707
|
+
return {
|
|
708
|
+
kind: "callback",
|
|
709
|
+
query: query as NonNullable<TUpdate["callback_query"]>,
|
|
710
|
+
authorization: getTelegramAuthorizationState(
|
|
711
|
+
query.from.id,
|
|
712
|
+
allowedUserId,
|
|
713
|
+
),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const message = getAuthorizedTelegramMessage(update, allowedUserId);
|
|
717
|
+
if (message?.from) {
|
|
718
|
+
return {
|
|
719
|
+
kind: "message",
|
|
720
|
+
message: message as NonNullable<
|
|
721
|
+
TUpdate["message"] | TUpdate["edited_message"]
|
|
722
|
+
> & { from: TelegramUser },
|
|
723
|
+
authorization: getTelegramAuthorizationState(
|
|
724
|
+
message.from.id,
|
|
725
|
+
allowedUserId,
|
|
726
|
+
),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
const editedMessage = getAuthorizedTelegramEditedMessage(
|
|
730
|
+
update,
|
|
731
|
+
allowedUserId,
|
|
732
|
+
);
|
|
733
|
+
if (editedMessage?.from) {
|
|
734
|
+
return {
|
|
735
|
+
kind: "edited-message",
|
|
736
|
+
message: editedMessage as NonNullable<
|
|
737
|
+
TUpdate["message"] | TUpdate["edited_message"]
|
|
738
|
+
> & { from: TelegramUser },
|
|
739
|
+
authorization: getTelegramAuthorizationState(
|
|
740
|
+
editedMessage.from.id,
|
|
741
|
+
allowedUserId,
|
|
742
|
+
),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const guestMessage = getAuthorizedTelegramGuestMessage(update);
|
|
746
|
+
if (guestMessage?.from) {
|
|
747
|
+
return {
|
|
748
|
+
kind: "guest",
|
|
749
|
+
guestMessage: guestMessage as NonNullable<TUpdate["guest_message"]> & {
|
|
750
|
+
from: TelegramUser;
|
|
751
|
+
},
|
|
752
|
+
authorization: getTelegramAuthorizationState(
|
|
753
|
+
guestMessage.from.id,
|
|
754
|
+
allowedUserId,
|
|
755
|
+
),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return { kind: "ignore" };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// --- Execution Planning ---
|
|
762
|
+
|
|
763
|
+
export type TelegramUpdateExecutionPlan<
|
|
764
|
+
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
765
|
+
TelegramMessageReactionUpdated,
|
|
766
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
767
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
768
|
+
TGuestMessage extends TelegramGuestMessage = TelegramGuestMessage,
|
|
769
|
+
> =
|
|
770
|
+
| { kind: "ignore" }
|
|
771
|
+
| { kind: "deleted"; messageIds: number[] }
|
|
772
|
+
| {
|
|
773
|
+
kind: "reaction";
|
|
774
|
+
reactionUpdate: TReactionUpdate;
|
|
775
|
+
}
|
|
776
|
+
| {
|
|
777
|
+
kind: "topic-lifecycle";
|
|
778
|
+
lifecycle: TelegramTopicLifecycleUpdate<TMessage>;
|
|
779
|
+
}
|
|
780
|
+
| {
|
|
781
|
+
kind: "callback";
|
|
782
|
+
query: TCallbackQuery;
|
|
783
|
+
shouldPair: boolean;
|
|
784
|
+
shouldDeny: boolean;
|
|
785
|
+
}
|
|
786
|
+
| {
|
|
787
|
+
kind: "message";
|
|
788
|
+
message: TMessage & { from: TelegramUser };
|
|
789
|
+
shouldPair: boolean;
|
|
790
|
+
shouldNotifyPaired: boolean;
|
|
791
|
+
shouldDeny: boolean;
|
|
792
|
+
}
|
|
793
|
+
| {
|
|
794
|
+
kind: "edited-message";
|
|
795
|
+
message: TMessage & { from: TelegramUser };
|
|
796
|
+
shouldPair: boolean;
|
|
797
|
+
shouldDeny: boolean;
|
|
798
|
+
}
|
|
799
|
+
| {
|
|
800
|
+
kind: "guest";
|
|
801
|
+
guestMessage: TGuestMessage & { from: TelegramUser };
|
|
802
|
+
shouldDeny: boolean;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
export function buildTelegramUpdateExecutionPlan<
|
|
806
|
+
TReactionUpdate extends TelegramMessageReactionUpdated,
|
|
807
|
+
TCallbackQuery extends TelegramCallbackQuery,
|
|
808
|
+
TMessage extends TelegramUpdateMessage,
|
|
809
|
+
TGuestMessage extends TelegramGuestMessage,
|
|
810
|
+
>(
|
|
811
|
+
action: TelegramUpdateFlowAction<
|
|
812
|
+
TReactionUpdate,
|
|
813
|
+
TCallbackQuery,
|
|
814
|
+
TMessage,
|
|
815
|
+
TGuestMessage
|
|
816
|
+
>,
|
|
817
|
+
): TelegramUpdateExecutionPlan<
|
|
818
|
+
TReactionUpdate,
|
|
819
|
+
TCallbackQuery,
|
|
820
|
+
TMessage,
|
|
821
|
+
TGuestMessage
|
|
822
|
+
> {
|
|
823
|
+
switch (action.kind) {
|
|
824
|
+
case "ignore":
|
|
825
|
+
return { kind: "ignore" };
|
|
826
|
+
case "deleted":
|
|
827
|
+
return { kind: "deleted", messageIds: action.messageIds };
|
|
828
|
+
case "reaction":
|
|
829
|
+
return { kind: "reaction", reactionUpdate: action.reactionUpdate };
|
|
830
|
+
case "topic-lifecycle":
|
|
831
|
+
return { kind: "topic-lifecycle", lifecycle: action.lifecycle };
|
|
832
|
+
case "callback":
|
|
833
|
+
return {
|
|
834
|
+
kind: "callback",
|
|
835
|
+
query: action.query,
|
|
836
|
+
shouldPair: action.authorization.kind === "pair",
|
|
837
|
+
shouldDeny: action.authorization.kind === "deny",
|
|
838
|
+
};
|
|
839
|
+
case "message":
|
|
840
|
+
return {
|
|
841
|
+
kind: "message",
|
|
842
|
+
message: action.message,
|
|
843
|
+
shouldPair: action.authorization.kind === "pair",
|
|
844
|
+
shouldNotifyPaired: action.authorization.kind === "pair",
|
|
845
|
+
shouldDeny: action.authorization.kind === "deny",
|
|
846
|
+
};
|
|
847
|
+
case "edited-message":
|
|
848
|
+
return {
|
|
849
|
+
kind: "edited-message",
|
|
850
|
+
message: action.message,
|
|
851
|
+
shouldPair: action.authorization.kind === "pair",
|
|
852
|
+
shouldDeny: action.authorization.kind === "deny",
|
|
853
|
+
};
|
|
854
|
+
case "guest":
|
|
855
|
+
return {
|
|
856
|
+
kind: "guest",
|
|
857
|
+
guestMessage: action.guestMessage,
|
|
858
|
+
// Guest mode is an extension of an already paired bridge, not a pairing surface.
|
|
859
|
+
shouldDeny: action.authorization.kind !== "allow",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
export function buildTelegramUpdateExecutionPlanFromUpdate<
|
|
865
|
+
TUpdate extends TelegramUpdateFlow,
|
|
866
|
+
>(
|
|
867
|
+
update: TUpdate,
|
|
868
|
+
allowedUserId?: number,
|
|
869
|
+
): TelegramUpdateExecutionPlan<
|
|
870
|
+
NonNullable<TUpdate["message_reaction"]>,
|
|
871
|
+
NonNullable<TUpdate["callback_query"]>,
|
|
872
|
+
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
|
|
873
|
+
> {
|
|
874
|
+
return buildTelegramUpdateExecutionPlan(
|
|
875
|
+
buildTelegramUpdateFlowAction(update, allowedUserId),
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// --- Runtime ---
|
|
880
|
+
|
|
881
|
+
export type TelegramMessageOwnershipRecorderInput = Parameters<
|
|
882
|
+
TelegramMessageOwnershipStore["record"]
|
|
883
|
+
>[0];
|
|
884
|
+
|
|
885
|
+
export type TelegramMessageOwnershipRecorder = (
|
|
886
|
+
input: TelegramMessageOwnershipRecorderInput,
|
|
887
|
+
) => void;
|
|
888
|
+
|
|
889
|
+
interface TelegramUnauthorizedReplyOptions {
|
|
890
|
+
parseMode?: "HTML";
|
|
891
|
+
target?: { chatId: number; threadId?: number };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const TELEGRAM_UNAUTHORIZED_DENIAL_COPY = "Access denied.";
|
|
895
|
+
|
|
896
|
+
function formatTelegramUnauthorizedDenial(format: "plain" | "html"): string {
|
|
897
|
+
return format === "html"
|
|
898
|
+
? `🚫 <b>${TELEGRAM_UNAUTHORIZED_DENIAL_COPY}</b>`
|
|
899
|
+
: `🚫 ${TELEGRAM_UNAUTHORIZED_DENIAL_COPY}`;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export interface TelegramUpdateRuntimeDeps<
|
|
903
|
+
TContext = unknown,
|
|
904
|
+
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
905
|
+
TelegramMessageReactionUpdated,
|
|
906
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
907
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
908
|
+
> {
|
|
909
|
+
ctx: TContext;
|
|
910
|
+
execution?: TelegramUpdateExecutionFence;
|
|
911
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
912
|
+
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
913
|
+
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
914
|
+
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
|
|
915
|
+
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
|
|
916
|
+
TContext,
|
|
917
|
+
TReactionUpdate,
|
|
918
|
+
TCallbackQuery,
|
|
919
|
+
TMessage
|
|
920
|
+
>;
|
|
921
|
+
removePendingMediaGroupMessages: (messageIds: number[]) => void;
|
|
922
|
+
removeQueuedTelegramTurnsByMessageIds: (
|
|
923
|
+
messageIds: number[],
|
|
924
|
+
ctx: TContext,
|
|
925
|
+
) => number;
|
|
926
|
+
handleAuthorizedTelegramReactionUpdate: (
|
|
927
|
+
reactionUpdate: TReactionUpdate,
|
|
928
|
+
ctx: TContext,
|
|
929
|
+
) => Promise<void>;
|
|
930
|
+
handleTelegramTopicLifecycleUpdate?: (
|
|
931
|
+
lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
|
|
932
|
+
ctx: TContext,
|
|
933
|
+
) => Promise<void> | void;
|
|
934
|
+
pairTelegramUserIfNeeded: (
|
|
935
|
+
userId: number,
|
|
936
|
+
ctx: TContext,
|
|
937
|
+
assertExecutionCurrent?: () => void,
|
|
938
|
+
) => Promise<boolean>;
|
|
939
|
+
answerCallbackQuery: (
|
|
940
|
+
callbackQueryId: string,
|
|
941
|
+
text?: string,
|
|
942
|
+
) => Promise<void>;
|
|
943
|
+
answerGuestQuery: (
|
|
944
|
+
guestQueryId: string,
|
|
945
|
+
text?: string,
|
|
946
|
+
options?: Pick<TelegramUnauthorizedReplyOptions, "parseMode">,
|
|
947
|
+
) => Promise<void>;
|
|
948
|
+
handleAuthorizedTelegramCallbackQuery: (
|
|
949
|
+
query: TCallbackQuery,
|
|
950
|
+
ctx: TContext,
|
|
951
|
+
) => Promise<void>;
|
|
952
|
+
sendTextReply: (
|
|
953
|
+
chatId: number,
|
|
954
|
+
replyToMessageId: number,
|
|
955
|
+
text: string,
|
|
956
|
+
options?: TelegramUnauthorizedReplyOptions,
|
|
957
|
+
) => Promise<number | undefined>;
|
|
958
|
+
handleAuthorizedTelegramMessage: (
|
|
959
|
+
message: TMessage,
|
|
960
|
+
ctx: TContext,
|
|
961
|
+
) => Promise<void>;
|
|
962
|
+
handleAuthorizedTelegramEditedMessage: (
|
|
963
|
+
message: TMessage,
|
|
964
|
+
ctx: TContext,
|
|
965
|
+
) => unknown;
|
|
966
|
+
handleAuthorizedTelegramGuestMessage?: (
|
|
967
|
+
guestMessage: TelegramGuestMessage & { from: TelegramUser },
|
|
968
|
+
ctx: TContext,
|
|
969
|
+
) => Promise<void>;
|
|
970
|
+
/** Called when the owner writes in an unbound thread no live instance owns. */
|
|
971
|
+
handleUnboundTelegramTopicMessage?: (
|
|
972
|
+
message: TMessage & { from: TelegramUser },
|
|
973
|
+
ctx: TContext,
|
|
974
|
+
) => Promise<void>;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export interface TelegramUpdateRuntimeControllerDeps<
|
|
978
|
+
TContext = unknown,
|
|
979
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
980
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
981
|
+
> {
|
|
982
|
+
getAllowedUserId: () => number | undefined;
|
|
983
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
984
|
+
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
985
|
+
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
986
|
+
recordMessageOwnership?: TelegramMessageOwnershipRecorder;
|
|
987
|
+
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<
|
|
988
|
+
TContext,
|
|
989
|
+
TelegramMessageReactionUpdated,
|
|
990
|
+
TCallbackQuery,
|
|
991
|
+
TMessage
|
|
992
|
+
>;
|
|
993
|
+
removePendingMediaGroupMessages: (messageIds: number[]) => void;
|
|
994
|
+
flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
995
|
+
flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
996
|
+
removeQueuedTelegramTurnsByMessageIds: (
|
|
997
|
+
messageIds: number[],
|
|
998
|
+
ctx: TContext,
|
|
999
|
+
scope?: { chatId?: number; threadId?: number },
|
|
1000
|
+
) => number;
|
|
1001
|
+
applyQueuedTelegramTurnReactionByMessageId: (
|
|
1002
|
+
messageId: number,
|
|
1003
|
+
disposition: TelegramQueueReactionDisposition,
|
|
1004
|
+
ctx: TContext,
|
|
1005
|
+
scope?: { chatId?: number; threadId?: number },
|
|
1006
|
+
) => boolean;
|
|
1007
|
+
pairTelegramUserIfNeeded: (
|
|
1008
|
+
userId: number,
|
|
1009
|
+
ctx: TContext,
|
|
1010
|
+
assertExecutionCurrent?: () => void,
|
|
1011
|
+
) => Promise<boolean>;
|
|
1012
|
+
answerCallbackQuery: (
|
|
1013
|
+
callbackQueryId: string,
|
|
1014
|
+
text?: string,
|
|
1015
|
+
) => Promise<void>;
|
|
1016
|
+
answerGuestQuery: (
|
|
1017
|
+
guestQueryId: string,
|
|
1018
|
+
text?: string,
|
|
1019
|
+
options?: Pick<TelegramUnauthorizedReplyOptions, "parseMode">,
|
|
1020
|
+
) => Promise<void>;
|
|
1021
|
+
handleAuthorizedTelegramCallbackQuery: (
|
|
1022
|
+
query: TCallbackQuery,
|
|
1023
|
+
ctx: TContext,
|
|
1024
|
+
) => Promise<void>;
|
|
1025
|
+
sendTextReply: (
|
|
1026
|
+
chatId: number,
|
|
1027
|
+
replyToMessageId: number,
|
|
1028
|
+
text: string,
|
|
1029
|
+
options?: TelegramUnauthorizedReplyOptions,
|
|
1030
|
+
) => Promise<number | undefined>;
|
|
1031
|
+
handleAuthorizedTelegramMessage: (
|
|
1032
|
+
message: TMessage,
|
|
1033
|
+
ctx: TContext,
|
|
1034
|
+
) => Promise<void>;
|
|
1035
|
+
handleAuthorizedTelegramEditedMessage: (
|
|
1036
|
+
message: TMessage,
|
|
1037
|
+
ctx: TContext,
|
|
1038
|
+
) => unknown;
|
|
1039
|
+
handleAuthorizedTelegramGuestMessage?: (
|
|
1040
|
+
guestMessage: TelegramGuestMessage & { from: TelegramUser },
|
|
1041
|
+
ctx: TContext,
|
|
1042
|
+
) => Promise<void>;
|
|
1043
|
+
handleTelegramTopicLifecycleUpdate?: (
|
|
1044
|
+
lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
|
|
1045
|
+
ctx: TContext,
|
|
1046
|
+
) => Promise<void> | void;
|
|
1047
|
+
/** Called when the owner writes in an unbound thread no live instance owns. */
|
|
1048
|
+
handleUnboundTelegramTopicMessage?: (
|
|
1049
|
+
message: TMessage & { from: TelegramUser },
|
|
1050
|
+
ctx: TContext,
|
|
1051
|
+
) => Promise<void>;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
export interface TelegramUpdateRuntimeController<
|
|
1055
|
+
TContext = unknown,
|
|
1056
|
+
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
|
|
1057
|
+
> {
|
|
1058
|
+
handleAuthorizedReactionUpdate: (
|
|
1059
|
+
reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
|
|
1060
|
+
ctx: TContext,
|
|
1061
|
+
) => Promise<void>;
|
|
1062
|
+
handleUpdate: (
|
|
1063
|
+
update: TUpdate,
|
|
1064
|
+
ctx: TContext,
|
|
1065
|
+
execution?: TelegramUpdateExecutionFence,
|
|
1066
|
+
) => Promise<void>;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function getTelegramCallbackQueryId(
|
|
1070
|
+
query: TelegramCallbackQuery,
|
|
1071
|
+
): string | undefined {
|
|
1072
|
+
return typeof query.id === "string" ? query.id : undefined;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function getTelegramMessageReplyTarget(
|
|
1076
|
+
message: TelegramUpdateMessage,
|
|
1077
|
+
): { chatId: number; messageId: number; threadId?: number } | undefined {
|
|
1078
|
+
if (
|
|
1079
|
+
typeof message.chat.id !== "number" ||
|
|
1080
|
+
typeof message.message_id !== "number"
|
|
1081
|
+
) {
|
|
1082
|
+
return undefined;
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
chatId: message.chat.id,
|
|
1086
|
+
messageId: message.message_id,
|
|
1087
|
+
...(typeof message.message_thread_id === "number"
|
|
1088
|
+
? { threadId: message.message_thread_id }
|
|
1089
|
+
: {}),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function getForeignTelegramMessageOwnership(
|
|
1094
|
+
target: { chatId: number; messageId: number } | undefined,
|
|
1095
|
+
deps: {
|
|
1096
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
1097
|
+
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
1098
|
+
},
|
|
1099
|
+
): TelegramMessageOwnershipView | undefined {
|
|
1100
|
+
if (!target || !deps.getMessageOwnership || !deps.getCurrentInstanceId) {
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
const currentInstanceId = deps.getCurrentInstanceId();
|
|
1104
|
+
if (!currentInstanceId) return undefined;
|
|
1105
|
+
const ownership = deps.getMessageOwnership(target.chatId, target.messageId);
|
|
1106
|
+
return ownership && ownership.instanceId !== currentInstanceId
|
|
1107
|
+
? ownership
|
|
1108
|
+
: undefined;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function getForeignTelegramCallbackOwnership(
|
|
1112
|
+
query: TelegramCallbackQuery,
|
|
1113
|
+
deps: {
|
|
1114
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
1115
|
+
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
1116
|
+
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
1117
|
+
},
|
|
1118
|
+
): TelegramMessageOwnershipView | undefined {
|
|
1119
|
+
return (
|
|
1120
|
+
getForeignTelegramMessageOwnership(
|
|
1121
|
+
getTelegramCallbackMessageTarget(query),
|
|
1122
|
+
deps,
|
|
1123
|
+
) ??
|
|
1124
|
+
getForeignTelegramTargetOwnership(
|
|
1125
|
+
query.message ? getTelegramMessageTarget(query.message) : undefined,
|
|
1126
|
+
deps,
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function getTelegramCallbackMessageTarget(
|
|
1132
|
+
query: TelegramCallbackQuery,
|
|
1133
|
+
): { chatId: number; messageId: number } | undefined {
|
|
1134
|
+
return query.message
|
|
1135
|
+
? getTelegramMessageReplyTarget(query.message)
|
|
1136
|
+
: undefined;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function getTelegramReactionMessageTarget(
|
|
1140
|
+
reactionUpdate: TelegramMessageReactionUpdated,
|
|
1141
|
+
): { chatId: number; messageId: number } | undefined {
|
|
1142
|
+
return typeof reactionUpdate.chat.id === "number"
|
|
1143
|
+
? { chatId: reactionUpdate.chat.id, messageId: reactionUpdate.message_id }
|
|
1144
|
+
: undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function getForeignTelegramTargetOwnership(
|
|
1148
|
+
target: TelegramTarget | undefined,
|
|
1149
|
+
deps: {
|
|
1150
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
1151
|
+
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
1152
|
+
},
|
|
1153
|
+
): TelegramTargetOwnershipView | undefined {
|
|
1154
|
+
if (!target || !deps.getTargetOwnership || !deps.getCurrentInstanceId) {
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const currentInstanceId = deps.getCurrentInstanceId();
|
|
1158
|
+
if (!currentInstanceId) return undefined;
|
|
1159
|
+
const ownership = deps.getTargetOwnership(target);
|
|
1160
|
+
return ownership && ownership.instanceId !== currentInstanceId
|
|
1161
|
+
? ownership
|
|
1162
|
+
: undefined;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export async function executeTelegramUpdate<
|
|
1166
|
+
TUpdate extends TelegramUpdateFlow,
|
|
1167
|
+
TContext = unknown,
|
|
1168
|
+
>(
|
|
1169
|
+
update: TUpdate,
|
|
1170
|
+
allowedUserId: number | undefined,
|
|
1171
|
+
deps: TelegramUpdateRuntimeDeps<
|
|
1172
|
+
TContext,
|
|
1173
|
+
NonNullable<TUpdate["message_reaction"]>,
|
|
1174
|
+
NonNullable<TUpdate["callback_query"]>,
|
|
1175
|
+
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
|
|
1176
|
+
>,
|
|
1177
|
+
): Promise<void> {
|
|
1178
|
+
const runtimeDeps = update[TELEGRAM_INTERNAL_AGENT_MESSAGE]
|
|
1179
|
+
? { ...deps, getMessageOwnership: undefined }
|
|
1180
|
+
: deps;
|
|
1181
|
+
await executeTelegramUpdatePlan(
|
|
1182
|
+
buildTelegramUpdateExecutionPlanFromUpdate(update, allowedUserId),
|
|
1183
|
+
runtimeDeps,
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
export type TelegramPairedUpdateRuntimeControllerDeps<
|
|
1188
|
+
TContext = unknown,
|
|
1189
|
+
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
|
|
1190
|
+
> = Omit<
|
|
1191
|
+
TelegramUpdateRuntimeControllerDeps<
|
|
1192
|
+
TContext,
|
|
1193
|
+
NonNullable<TUpdate["callback_query"]>,
|
|
1194
|
+
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
|
|
1195
|
+
>,
|
|
1196
|
+
"pairTelegramUserIfNeeded"
|
|
1197
|
+
> &
|
|
1198
|
+
TelegramUserPairingRuntimeDeps<TContext>;
|
|
1199
|
+
|
|
1200
|
+
export function createTelegramPairedUpdateRuntime<
|
|
1201
|
+
TContext = unknown,
|
|
1202
|
+
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
|
|
1203
|
+
>(
|
|
1204
|
+
deps: TelegramPairedUpdateRuntimeControllerDeps<TContext, TUpdate>,
|
|
1205
|
+
): TelegramUpdateRuntimeController<TContext, TUpdate> {
|
|
1206
|
+
return createTelegramUpdateRuntime({
|
|
1207
|
+
getAllowedUserId: deps.getAllowedUserId,
|
|
1208
|
+
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1209
|
+
getMessageOwnership: deps.getMessageOwnership,
|
|
1210
|
+
getTargetOwnership: deps.getTargetOwnership,
|
|
1211
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
1212
|
+
handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
|
|
1213
|
+
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
1214
|
+
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
|
|
1215
|
+
flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
|
|
1216
|
+
flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
|
|
1217
|
+
removeQueuedTelegramTurnsByMessageIds:
|
|
1218
|
+
deps.removeQueuedTelegramTurnsByMessageIds,
|
|
1219
|
+
applyQueuedTelegramTurnReactionByMessageId:
|
|
1220
|
+
deps.applyQueuedTelegramTurnReactionByMessageId,
|
|
1221
|
+
pairTelegramUserIfNeeded: (userId, ctx, assertExecutionCurrent) =>
|
|
1222
|
+
createTelegramUserPairingRuntime({
|
|
1223
|
+
getAllowedUserId: deps.getAllowedUserId,
|
|
1224
|
+
setAllowedUserId: deps.setAllowedUserId,
|
|
1225
|
+
persistConfig: deps.persistConfig,
|
|
1226
|
+
updateStatus: deps.updateStatus,
|
|
1227
|
+
}).pairIfNeeded(userId, ctx, assertExecutionCurrent),
|
|
1228
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1229
|
+
answerGuestQuery: deps.answerGuestQuery,
|
|
1230
|
+
handleAuthorizedTelegramCallbackQuery:
|
|
1231
|
+
deps.handleAuthorizedTelegramCallbackQuery,
|
|
1232
|
+
sendTextReply: deps.sendTextReply,
|
|
1233
|
+
handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage,
|
|
1234
|
+
handleAuthorizedTelegramEditedMessage:
|
|
1235
|
+
deps.handleAuthorizedTelegramEditedMessage,
|
|
1236
|
+
handleAuthorizedTelegramGuestMessage:
|
|
1237
|
+
deps.handleAuthorizedTelegramGuestMessage,
|
|
1238
|
+
handleUnboundTelegramTopicMessage: deps.handleUnboundTelegramTopicMessage,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export function createTelegramUpdateRuntime<
|
|
1243
|
+
TContext = unknown,
|
|
1244
|
+
TUpdate extends TelegramUpdateFlow = TelegramUpdateFlow,
|
|
1245
|
+
>(
|
|
1246
|
+
deps: TelegramUpdateRuntimeControllerDeps<
|
|
1247
|
+
TContext,
|
|
1248
|
+
NonNullable<TUpdate["callback_query"]>,
|
|
1249
|
+
NonNullable<TUpdate["message"] | TUpdate["edited_message"]>
|
|
1250
|
+
>,
|
|
1251
|
+
): TelegramUpdateRuntimeController<TContext, TUpdate> {
|
|
1252
|
+
const handleAuthorizedReactionUpdate = async (
|
|
1253
|
+
reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
|
|
1254
|
+
ctx: TContext,
|
|
1255
|
+
): Promise<void> => {
|
|
1256
|
+
await handleAuthorizedTelegramReactionUpdate(reactionUpdate, {
|
|
1257
|
+
allowedUserId: deps.getAllowedUserId(),
|
|
1258
|
+
ctx,
|
|
1259
|
+
flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
|
|
1260
|
+
flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
|
|
1261
|
+
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1262
|
+
getMessageOwnership: deps.getMessageOwnership,
|
|
1263
|
+
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
1264
|
+
assertExecutionCurrent: createTelegramUpdateExecutionFenceGuard(
|
|
1265
|
+
reactionUpdate,
|
|
1266
|
+
),
|
|
1267
|
+
applyQueuedTelegramTurnReactionByMessageId:
|
|
1268
|
+
deps.applyQueuedTelegramTurnReactionByMessageId,
|
|
1269
|
+
});
|
|
1270
|
+
};
|
|
1271
|
+
return {
|
|
1272
|
+
handleAuthorizedReactionUpdate,
|
|
1273
|
+
handleUpdate: (update, ctx, execution) =>
|
|
1274
|
+
executeTelegramUpdate(update, deps.getAllowedUserId(), {
|
|
1275
|
+
ctx,
|
|
1276
|
+
execution,
|
|
1277
|
+
getCurrentInstanceId: deps.getCurrentInstanceId,
|
|
1278
|
+
getMessageOwnership: deps.getMessageOwnership,
|
|
1279
|
+
getTargetOwnership: deps.getTargetOwnership,
|
|
1280
|
+
recordMessageOwnership: deps.recordMessageOwnership,
|
|
1281
|
+
foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
|
|
1282
|
+
removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
|
|
1283
|
+
removeQueuedTelegramTurnsByMessageIds:
|
|
1284
|
+
deps.removeQueuedTelegramTurnsByMessageIds,
|
|
1285
|
+
handleAuthorizedTelegramReactionUpdate: handleAuthorizedReactionUpdate,
|
|
1286
|
+
handleTelegramTopicLifecycleUpdate:
|
|
1287
|
+
deps.handleTelegramTopicLifecycleUpdate,
|
|
1288
|
+
pairTelegramUserIfNeeded: deps.pairTelegramUserIfNeeded,
|
|
1289
|
+
answerCallbackQuery: deps.answerCallbackQuery,
|
|
1290
|
+
answerGuestQuery: deps.answerGuestQuery,
|
|
1291
|
+
handleAuthorizedTelegramCallbackQuery:
|
|
1292
|
+
deps.handleAuthorizedTelegramCallbackQuery,
|
|
1293
|
+
sendTextReply: deps.sendTextReply,
|
|
1294
|
+
handleAuthorizedTelegramMessage: deps.handleAuthorizedTelegramMessage,
|
|
1295
|
+
handleAuthorizedTelegramEditedMessage:
|
|
1296
|
+
deps.handleAuthorizedTelegramEditedMessage,
|
|
1297
|
+
handleAuthorizedTelegramGuestMessage:
|
|
1298
|
+
deps.handleAuthorizedTelegramGuestMessage,
|
|
1299
|
+
handleUnboundTelegramTopicMessage:
|
|
1300
|
+
deps.handleUnboundTelegramTopicMessage,
|
|
1301
|
+
}),
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
|
|
1306
|
+
allowedUserId?: number;
|
|
1307
|
+
ctx: TContext;
|
|
1308
|
+
getCurrentInstanceId?: () => string | undefined;
|
|
1309
|
+
getMessageOwnership?: TelegramMessageOwnershipLookup;
|
|
1310
|
+
foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<TContext>;
|
|
1311
|
+
assertExecutionCurrent?: () => void;
|
|
1312
|
+
flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
1313
|
+
flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
|
|
1314
|
+
applyQueuedTelegramTurnReactionByMessageId: (
|
|
1315
|
+
messageId: number,
|
|
1316
|
+
disposition: TelegramQueueReactionDisposition,
|
|
1317
|
+
ctx: TContext,
|
|
1318
|
+
scope?: { chatId?: number; threadId?: number },
|
|
1319
|
+
) => boolean;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
export async function handleAuthorizedTelegramReactionUpdate<TContext>(
|
|
1323
|
+
reactionUpdate: TelegramMessageReactionUpdated,
|
|
1324
|
+
deps: AuthorizedTelegramReactionUpdateDeps<TContext>,
|
|
1325
|
+
): Promise<void> {
|
|
1326
|
+
const foreignOwnership = getForeignTelegramMessageOwnership(
|
|
1327
|
+
getTelegramReactionMessageTarget(reactionUpdate),
|
|
1328
|
+
deps,
|
|
1329
|
+
);
|
|
1330
|
+
if (foreignOwnership) {
|
|
1331
|
+
deps.assertExecutionCurrent?.();
|
|
1332
|
+
const settlement =
|
|
1333
|
+
await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
|
|
1334
|
+
reactionUpdate,
|
|
1335
|
+
ownership: foreignOwnership,
|
|
1336
|
+
ctx: deps.ctx,
|
|
1337
|
+
});
|
|
1338
|
+
deps.assertExecutionCurrent?.();
|
|
1339
|
+
if (settlement?.status !== "accepted") {
|
|
1340
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1341
|
+
settlement,
|
|
1342
|
+
"reaction",
|
|
1343
|
+
reactionUpdate,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
const reactionUser = reactionUpdate.user;
|
|
1349
|
+
if (!reactionUser || reactionUser.is_bot) return;
|
|
1350
|
+
if (
|
|
1351
|
+
reactionUpdate.chat.type !== "private" &&
|
|
1352
|
+
reactionUser.id !== deps.allowedUserId
|
|
1353
|
+
) {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const reactionScope =
|
|
1357
|
+
typeof reactionUpdate.chat.id === "number"
|
|
1358
|
+
? { chatId: reactionUpdate.chat.id }
|
|
1359
|
+
: undefined;
|
|
1360
|
+
const reactionTransition = getTelegramQueueReactionTransition(
|
|
1361
|
+
reactionUpdate.old_reaction,
|
|
1362
|
+
reactionUpdate.new_reaction,
|
|
1363
|
+
);
|
|
1364
|
+
if (!reactionTransition) return;
|
|
1365
|
+
deps.assertExecutionCurrent?.();
|
|
1366
|
+
await deps.flushPendingMediaGroupMessage?.(reactionUpdate.message_id);
|
|
1367
|
+
deps.assertExecutionCurrent?.();
|
|
1368
|
+
await deps.flushPendingTextGroupMessage?.(reactionUpdate.message_id);
|
|
1369
|
+
deps.assertExecutionCurrent?.();
|
|
1370
|
+
deps.applyQueuedTelegramTurnReactionByMessageId(
|
|
1371
|
+
reactionUpdate.message_id,
|
|
1372
|
+
reactionTransition,
|
|
1373
|
+
deps.ctx,
|
|
1374
|
+
reactionScope,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function isTelegramStaleContextError(error: unknown): boolean {
|
|
1379
|
+
return (
|
|
1380
|
+
error instanceof Error &&
|
|
1381
|
+
(error.message.includes("stale after session") ||
|
|
1382
|
+
error.message.includes("stale ctx"))
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export async function executeTelegramUpdatePlan<
|
|
1387
|
+
TContext = unknown,
|
|
1388
|
+
TReactionUpdate extends TelegramMessageReactionUpdated =
|
|
1389
|
+
TelegramMessageReactionUpdated,
|
|
1390
|
+
TCallbackQuery extends TelegramCallbackQuery = TelegramCallbackQuery,
|
|
1391
|
+
TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
|
|
1392
|
+
>(
|
|
1393
|
+
plan: TelegramUpdateExecutionPlan<TReactionUpdate, TCallbackQuery, TMessage>,
|
|
1394
|
+
deps: TelegramUpdateRuntimeDeps<
|
|
1395
|
+
TContext,
|
|
1396
|
+
TReactionUpdate,
|
|
1397
|
+
TCallbackQuery,
|
|
1398
|
+
TMessage
|
|
1399
|
+
>,
|
|
1400
|
+
): Promise<void> {
|
|
1401
|
+
try {
|
|
1402
|
+
const assertExecutionCurrent = (): void =>
|
|
1403
|
+
deps.execution?.assertCurrent();
|
|
1404
|
+
if (plan.kind === "ignore") return;
|
|
1405
|
+
if (plan.kind === "deleted") {
|
|
1406
|
+
assertExecutionCurrent();
|
|
1407
|
+
deps.removePendingMediaGroupMessages(plan.messageIds);
|
|
1408
|
+
deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (plan.kind === "reaction") {
|
|
1412
|
+
assertExecutionCurrent();
|
|
1413
|
+
await deps.handleAuthorizedTelegramReactionUpdate(
|
|
1414
|
+
plan.reactionUpdate,
|
|
1415
|
+
deps.ctx,
|
|
1416
|
+
);
|
|
1417
|
+
assertExecutionCurrent();
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (plan.kind === "topic-lifecycle") {
|
|
1421
|
+
assertExecutionCurrent();
|
|
1422
|
+
await deps.handleTelegramTopicLifecycleUpdate?.(plan.lifecycle, deps.ctx);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (plan.kind === "callback") {
|
|
1426
|
+
const foreignOwnership = getForeignTelegramCallbackOwnership(
|
|
1427
|
+
plan.query,
|
|
1428
|
+
deps,
|
|
1429
|
+
);
|
|
1430
|
+
if (foreignOwnership) {
|
|
1431
|
+
assertExecutionCurrent();
|
|
1432
|
+
const settlement =
|
|
1433
|
+
await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
|
|
1434
|
+
query: plan.query,
|
|
1435
|
+
ownership: foreignOwnership,
|
|
1436
|
+
ctx: deps.ctx,
|
|
1437
|
+
});
|
|
1438
|
+
if (settlement?.status !== "accepted") {
|
|
1439
|
+
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
|
|
1440
|
+
try {
|
|
1441
|
+
if (callbackQueryId) {
|
|
1442
|
+
assertExecutionCurrent();
|
|
1443
|
+
await deps.answerCallbackQuery(
|
|
1444
|
+
callbackQueryId,
|
|
1445
|
+
"This Telegram message belongs to another Pi instance.",
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
} finally {
|
|
1449
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1450
|
+
settlement,
|
|
1451
|
+
"callback",
|
|
1452
|
+
plan.query,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
assertExecutionCurrent();
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (plan.shouldPair) {
|
|
1460
|
+
assertExecutionCurrent();
|
|
1461
|
+
await deps.pairTelegramUserIfNeeded(
|
|
1462
|
+
plan.query.from.id,
|
|
1463
|
+
deps.ctx,
|
|
1464
|
+
assertExecutionCurrent,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
if (plan.shouldDeny) {
|
|
1468
|
+
const callbackQueryId = getTelegramCallbackQueryId(plan.query);
|
|
1469
|
+
if (callbackQueryId) {
|
|
1470
|
+
assertExecutionCurrent();
|
|
1471
|
+
await deps.answerCallbackQuery(
|
|
1472
|
+
callbackQueryId,
|
|
1473
|
+
formatTelegramUnauthorizedDenial("plain"),
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
assertExecutionCurrent();
|
|
1479
|
+
await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
|
|
1480
|
+
assertExecutionCurrent();
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (plan.kind === "guest") {
|
|
1484
|
+
if (plan.shouldDeny) {
|
|
1485
|
+
assertExecutionCurrent();
|
|
1486
|
+
await deps.answerGuestQuery(
|
|
1487
|
+
plan.guestMessage.guest_query_id,
|
|
1488
|
+
formatTelegramUnauthorizedDenial("html"),
|
|
1489
|
+
{ parseMode: "HTML" },
|
|
1490
|
+
);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (deps.handleAuthorizedTelegramGuestMessage) {
|
|
1494
|
+
assertExecutionCurrent();
|
|
1495
|
+
await deps.handleAuthorizedTelegramGuestMessage(
|
|
1496
|
+
plan.guestMessage,
|
|
1497
|
+
deps.ctx,
|
|
1498
|
+
);
|
|
1499
|
+
assertExecutionCurrent();
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const foreignMessageOwnership = getForeignTelegramMessageOwnership(
|
|
1504
|
+
getTelegramMessageReplyTarget(plan.message),
|
|
1505
|
+
deps,
|
|
1506
|
+
);
|
|
1507
|
+
if (foreignMessageOwnership) {
|
|
1508
|
+
assertExecutionCurrent();
|
|
1509
|
+
const settlement =
|
|
1510
|
+
plan.kind === "edited-message"
|
|
1511
|
+
? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1512
|
+
message: plan.message,
|
|
1513
|
+
ownership: foreignMessageOwnership,
|
|
1514
|
+
ctx: deps.ctx,
|
|
1515
|
+
})
|
|
1516
|
+
: await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
|
|
1517
|
+
message: plan.message,
|
|
1518
|
+
ownership: foreignMessageOwnership,
|
|
1519
|
+
ctx: deps.ctx,
|
|
1520
|
+
});
|
|
1521
|
+
if (settlement?.status !== "accepted") {
|
|
1522
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1523
|
+
settlement,
|
|
1524
|
+
plan.kind,
|
|
1525
|
+
plan.message,
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
assertExecutionCurrent();
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const messageTarget = getTelegramMessageTarget(plan.message);
|
|
1532
|
+
const foreignTargetOwnership = getForeignTelegramTargetOwnership(
|
|
1533
|
+
messageTarget,
|
|
1534
|
+
deps,
|
|
1535
|
+
);
|
|
1536
|
+
if (foreignTargetOwnership) {
|
|
1537
|
+
if (typeof plan.message.message_id === "number") {
|
|
1538
|
+
assertExecutionCurrent();
|
|
1539
|
+
deps.recordMessageOwnership?.({
|
|
1540
|
+
chatId: messageTarget!.chatId,
|
|
1541
|
+
messageId: plan.message.message_id,
|
|
1542
|
+
target: messageTarget,
|
|
1543
|
+
instanceId: foreignTargetOwnership.instanceId,
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
assertExecutionCurrent();
|
|
1547
|
+
const settlement =
|
|
1548
|
+
plan.kind === "edited-message"
|
|
1549
|
+
? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
|
|
1550
|
+
message: plan.message,
|
|
1551
|
+
ownership: foreignTargetOwnership,
|
|
1552
|
+
ctx: deps.ctx,
|
|
1553
|
+
})
|
|
1554
|
+
: await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
|
|
1555
|
+
message: plan.message,
|
|
1556
|
+
ownership: foreignTargetOwnership,
|
|
1557
|
+
ctx: deps.ctx,
|
|
1558
|
+
});
|
|
1559
|
+
if (settlement?.status !== "accepted") {
|
|
1560
|
+
rejectTelegramForeignUpdateSettlement(
|
|
1561
|
+
settlement,
|
|
1562
|
+
plan.kind,
|
|
1563
|
+
plan.message,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
assertExecutionCurrent();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (
|
|
1570
|
+
plan.kind === "message" &&
|
|
1571
|
+
messageTarget?.threadId != null &&
|
|
1572
|
+
deps.handleUnboundTelegramTopicMessage
|
|
1573
|
+
) {
|
|
1574
|
+
assertExecutionCurrent();
|
|
1575
|
+
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
|
|
1576
|
+
assertExecutionCurrent();
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (plan.shouldPair) assertExecutionCurrent();
|
|
1580
|
+
const pairedNow = plan.shouldPair
|
|
1581
|
+
? await deps.pairTelegramUserIfNeeded(
|
|
1582
|
+
plan.message.from.id,
|
|
1583
|
+
deps.ctx,
|
|
1584
|
+
assertExecutionCurrent,
|
|
1585
|
+
)
|
|
1586
|
+
: false;
|
|
1587
|
+
const replyTarget = getTelegramMessageReplyTarget(plan.message);
|
|
1588
|
+
if (
|
|
1589
|
+
plan.kind === "message" &&
|
|
1590
|
+
pairedNow &&
|
|
1591
|
+
plan.shouldNotifyPaired &&
|
|
1592
|
+
replyTarget
|
|
1593
|
+
) {
|
|
1594
|
+
assertExecutionCurrent();
|
|
1595
|
+
await deps.sendTextReply(
|
|
1596
|
+
replyTarget.chatId,
|
|
1597
|
+
replyTarget.messageId,
|
|
1598
|
+
"Telegram bridge paired with this account.",
|
|
1599
|
+
{ target: replyTarget },
|
|
1600
|
+
);
|
|
1601
|
+
assertExecutionCurrent();
|
|
1602
|
+
}
|
|
1603
|
+
if (plan.shouldDeny) {
|
|
1604
|
+
if (replyTarget) {
|
|
1605
|
+
assertExecutionCurrent();
|
|
1606
|
+
await deps.sendTextReply(
|
|
1607
|
+
replyTarget.chatId,
|
|
1608
|
+
replyTarget.messageId,
|
|
1609
|
+
formatTelegramUnauthorizedDenial("html"),
|
|
1610
|
+
{ parseMode: "HTML", target: replyTarget },
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
if (plan.kind === "edited-message") {
|
|
1616
|
+
assertExecutionCurrent();
|
|
1617
|
+
await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx);
|
|
1618
|
+
assertExecutionCurrent();
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
assertExecutionCurrent();
|
|
1622
|
+
await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
|
|
1623
|
+
assertExecutionCurrent();
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
if (!isTelegramStaleContextError(error)) throw error;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// --- Durable update worker ---
|
|
1630
|
+
|
|
1631
|
+
export const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
|
|
1632
|
+
export const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
|
|
1633
|
+
export const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
|
|
1634
|
+
|
|
1635
|
+
export type TelegramUpdateWorkerPhase =
|
|
1636
|
+
| "stopped"
|
|
1637
|
+
| "idle"
|
|
1638
|
+
| "executing"
|
|
1639
|
+
| "retry-wait"
|
|
1640
|
+
| "failed"
|
|
1641
|
+
| "deferred"
|
|
1642
|
+
| "queued"
|
|
1643
|
+
| "blocked";
|
|
1644
|
+
|
|
1645
|
+
export type TelegramUpdateWorkerBlockedReason =
|
|
1646
|
+
| "authority-lost"
|
|
1647
|
+
| "authority-check"
|
|
1648
|
+
| "journal-read"
|
|
1649
|
+
| "journal-write"
|
|
1650
|
+
| "execution"
|
|
1651
|
+
| "prior-generation-executing"
|
|
1652
|
+
| "invalid-outcome";
|
|
1653
|
+
|
|
1654
|
+
export interface TelegramUpdateWorkerStateSnapshot {
|
|
1655
|
+
phase: TelegramUpdateWorkerPhase;
|
|
1656
|
+
generation: number;
|
|
1657
|
+
phaseStartedAtMs?: number;
|
|
1658
|
+
currentUpdateId?: number;
|
|
1659
|
+
blockedReason?: TelegramUpdateWorkerBlockedReason;
|
|
1660
|
+
journalEntryCount: number;
|
|
1661
|
+
journalSerializedBytes: number;
|
|
1662
|
+
oldestAdmittedAtMs?: number;
|
|
1663
|
+
deferredClaimCount: number;
|
|
1664
|
+
queuedClaimCount: number;
|
|
1665
|
+
foreignQueuedCount: number;
|
|
1666
|
+
foreignQueuedOwner?: TelegramUpdateJournalQueueOwner;
|
|
1667
|
+
foreignQueuedOwnerLiveness?: TelegramProcessLiveness;
|
|
1668
|
+
retryWaitCount: number;
|
|
1669
|
+
failedCount: number;
|
|
1670
|
+
nextRetryUpdateId?: number;
|
|
1671
|
+
nextRetryAtMs?: number;
|
|
1672
|
+
nextRetryAttemptCount?: number;
|
|
1673
|
+
nextRetryFailureClass?: string;
|
|
1674
|
+
failedUpdateId?: number;
|
|
1675
|
+
failedFailureId?: string;
|
|
1676
|
+
failedAttemptCount?: number;
|
|
1677
|
+
failedClass?: string;
|
|
1678
|
+
failedSummary?: string;
|
|
1679
|
+
terminalFailureAtMs?: number;
|
|
1680
|
+
unsettledExecutionCount: number;
|
|
1681
|
+
lastCompletedUpdateId?: number;
|
|
1682
|
+
lastCompletedAtMs?: number;
|
|
1683
|
+
lastFailureAtMs?: number;
|
|
1684
|
+
lastFailurePhase?: string;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
export interface TelegramUpdateWorkerJournalSnapshot {
|
|
1688
|
+
entries: readonly {
|
|
1689
|
+
updateId: number;
|
|
1690
|
+
update: TelegramJournaledUpdate;
|
|
1691
|
+
admittedAtMs: number;
|
|
1692
|
+
state: "pending" | "retry-wait" | "queued" | "failed";
|
|
1693
|
+
queueKind?: "prompt" | "control";
|
|
1694
|
+
queueReceiptId?: string;
|
|
1695
|
+
queueOwner?: TelegramUpdateJournalQueueOwner;
|
|
1696
|
+
queueHandoff?: {
|
|
1697
|
+
handoffId: string;
|
|
1698
|
+
offeredAtMs: number;
|
|
1699
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1700
|
+
};
|
|
1701
|
+
failure?: {
|
|
1702
|
+
attemptCount: number;
|
|
1703
|
+
failedAtMs: number;
|
|
1704
|
+
failureClass: string;
|
|
1705
|
+
summary: string;
|
|
1706
|
+
};
|
|
1707
|
+
nextRetryAtMs?: number;
|
|
1708
|
+
terminalAtMs?: number;
|
|
1709
|
+
terminalReason?: string;
|
|
1710
|
+
terminalFailureId?: string;
|
|
1711
|
+
}[];
|
|
1712
|
+
serializedBytes: number;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
export interface TelegramUpdateWorkerJournalPort {
|
|
1716
|
+
read: () => TelegramUpdateWorkerJournalSnapshot;
|
|
1717
|
+
markQueued: (receipt: {
|
|
1718
|
+
queueKind: "prompt" | "control";
|
|
1719
|
+
receiptId: string;
|
|
1720
|
+
sourceUpdateIds: readonly number[];
|
|
1721
|
+
owner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1722
|
+
}) => {
|
|
1723
|
+
queuedUpdateIds: readonly number[];
|
|
1724
|
+
duplicateUpdateIds: readonly number[];
|
|
1725
|
+
queueOwner?: TelegramUpdateJournalQueueOwner;
|
|
1726
|
+
};
|
|
1727
|
+
completeQueued: (
|
|
1728
|
+
receipts: readonly {
|
|
1729
|
+
queueKind: "prompt" | "control";
|
|
1730
|
+
receiptId: string;
|
|
1731
|
+
sourceUpdateIds: readonly number[];
|
|
1732
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
1733
|
+
}[],
|
|
1734
|
+
) => {
|
|
1735
|
+
removedUpdateIds: readonly number[];
|
|
1736
|
+
};
|
|
1737
|
+
markExecutionFailure: (input: {
|
|
1738
|
+
updateId: number;
|
|
1739
|
+
expectedAttemptCount: number;
|
|
1740
|
+
failedAtMs: number;
|
|
1741
|
+
failureClass: string;
|
|
1742
|
+
summary: string;
|
|
1743
|
+
disposition: "retry-wait" | "failed";
|
|
1744
|
+
nextRetryAtMs?: number;
|
|
1745
|
+
terminalReason?: string;
|
|
1746
|
+
}) => {
|
|
1747
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number];
|
|
1748
|
+
};
|
|
1749
|
+
removeCompleted: (updateIds: readonly number[]) => {
|
|
1750
|
+
removedUpdateIds: readonly number[];
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
export interface TelegramUpdateRetryPolicy {
|
|
1755
|
+
baseDelayMs: number;
|
|
1756
|
+
maxDelayMs: number;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
export interface TelegramUpdateExecutionFailureClassification {
|
|
1760
|
+
disposition: "retryable" | "terminal";
|
|
1761
|
+
failureClass: string;
|
|
1762
|
+
summary: string;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
1766
|
+
journal: TelegramUpdateWorkerJournalPort;
|
|
1767
|
+
executeUpdate: (
|
|
1768
|
+
update: TelegramJournaledUpdate,
|
|
1769
|
+
ctx: TContext,
|
|
1770
|
+
signal: AbortSignal,
|
|
1771
|
+
) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
|
|
1772
|
+
hasAuthority: (ctx: TContext) => boolean;
|
|
1773
|
+
getJournalBindingKey?: () => string | undefined;
|
|
1774
|
+
getQueueOwnerIdentity?: (
|
|
1775
|
+
ctx: TContext,
|
|
1776
|
+
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
1777
|
+
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1778
|
+
createAbortController?: () => AbortController;
|
|
1779
|
+
getNowMs?: () => number;
|
|
1780
|
+
retryPolicy?: Partial<TelegramUpdateRetryPolicy>;
|
|
1781
|
+
classifyExecutionFailure?: (
|
|
1782
|
+
error: unknown,
|
|
1783
|
+
) => TelegramUpdateExecutionFailureClassification;
|
|
1784
|
+
scheduleRetry?: (callback: () => void, delayMs: number) => unknown;
|
|
1785
|
+
cancelRetry?: (handle: unknown) => void;
|
|
1786
|
+
batchSize?: number;
|
|
1787
|
+
yieldToEventLoop?: () => Promise<void>;
|
|
1788
|
+
onStateChange?: (state: TelegramUpdateWorkerStateSnapshot) => void;
|
|
1789
|
+
onQueueReceiptCommitted?: (
|
|
1790
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1791
|
+
ctx: TContext,
|
|
1792
|
+
) => void;
|
|
1793
|
+
onUpdateCompleted?: (updateId: number, ctx: TContext) => void;
|
|
1794
|
+
recordRuntimeEvent?: (
|
|
1795
|
+
category: string,
|
|
1796
|
+
error: unknown,
|
|
1797
|
+
details?: Record<string, unknown>,
|
|
1798
|
+
) => void;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
export type TelegramQueueReceiptCompletionReason =
|
|
1802
|
+
| "prompt-handoff"
|
|
1803
|
+
| "control-settlement"
|
|
1804
|
+
| "discard";
|
|
1805
|
+
|
|
1806
|
+
export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
1807
|
+
start: (ctx: TContext) => void;
|
|
1808
|
+
signal: () => void;
|
|
1809
|
+
settleDeferred: (input: {
|
|
1810
|
+
updateId: number;
|
|
1811
|
+
outcome: Extract<
|
|
1812
|
+
TelegramUpdateAdmissionOutcome,
|
|
1813
|
+
{ kind: "deferred" | "queued" }
|
|
1814
|
+
>;
|
|
1815
|
+
signal: AbortSignal;
|
|
1816
|
+
}) => void;
|
|
1817
|
+
isQueueReceiptCommitted: (
|
|
1818
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1819
|
+
) => boolean;
|
|
1820
|
+
getQueueReceiptOwner: (
|
|
1821
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1822
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
1823
|
+
completeQueueReceipts: (input: {
|
|
1824
|
+
receipts: readonly TelegramQueueAdmissionReceiptLike[];
|
|
1825
|
+
ctx: TContext;
|
|
1826
|
+
reason: TelegramQueueReceiptCompletionReason;
|
|
1827
|
+
}) => void;
|
|
1828
|
+
stop: () => Promise<void>;
|
|
1829
|
+
waitForDrain: () => Promise<void>;
|
|
1830
|
+
getState: () => TelegramUpdateWorkerStateSnapshot;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
export class TelegramUpdateAdmissionOutcomeError extends Error {
|
|
1834
|
+
constructor(message: string) {
|
|
1835
|
+
super(message);
|
|
1836
|
+
this.name = "TelegramUpdateAdmissionOutcomeError";
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
interface TelegramUpdateWorkerOwner<TContext> {
|
|
1841
|
+
generation: number;
|
|
1842
|
+
ctx: TContext;
|
|
1843
|
+
controller: AbortController;
|
|
1844
|
+
queueOwnerIdentity: TelegramUpdateJournalQueueOwnerIdentity;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
type TelegramUpdateWorkerClaim = "deferred" | "queued";
|
|
1848
|
+
type TelegramUpdateWorkerDrainResult = "idle" | "blocked" | "aborted";
|
|
1849
|
+
type TelegramUpdateWorkerExecutionSettlement =
|
|
1850
|
+
| { ok: true; outcome: TelegramUpdateAdmissionOutcome }
|
|
1851
|
+
| { ok: false; error: unknown };
|
|
1852
|
+
|
|
1853
|
+
const TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED = Symbol(
|
|
1854
|
+
"telegram.update-worker.execution-aborted",
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
function getTelegramUpdateWorkerStateSnapshot(
|
|
1858
|
+
state: TelegramUpdateWorkerStateSnapshot,
|
|
1859
|
+
): TelegramUpdateWorkerStateSnapshot {
|
|
1860
|
+
return { ...state };
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function isTelegramUpdateAdmissionRecord(
|
|
1864
|
+
value: unknown,
|
|
1865
|
+
): value is Record<string, unknown> {
|
|
1866
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function isTelegramUpdateAdmissionString(value: unknown): value is string {
|
|
1870
|
+
return typeof value === "string" && value.length > 0;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function validateTelegramUpdateAdmissionOutcome(
|
|
1874
|
+
value: unknown,
|
|
1875
|
+
currentUpdateId: number,
|
|
1876
|
+
claimableUpdateIds: ReadonlySet<number>,
|
|
1877
|
+
): TelegramUpdateAdmissionOutcome {
|
|
1878
|
+
if (!isTelegramUpdateAdmissionRecord(value)) {
|
|
1879
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1880
|
+
`Telegram update ${currentUpdateId} returned no admission outcome.`,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
if (value.kind === "complete") return { kind: "complete" };
|
|
1884
|
+
if (value.kind === "deferred") return { kind: "deferred" };
|
|
1885
|
+
if (value.kind === "queued") {
|
|
1886
|
+
if (
|
|
1887
|
+
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
1888
|
+
!isTelegramUpdateAdmissionString(value.receiptId) ||
|
|
1889
|
+
!Array.isArray(value.sourceUpdateIds) ||
|
|
1890
|
+
value.sourceUpdateIds.length === 0 ||
|
|
1891
|
+
!value.sourceUpdateIds.every(
|
|
1892
|
+
(updateId) =>
|
|
1893
|
+
Number.isSafeInteger(updateId) &&
|
|
1894
|
+
(updateId as number) >= 0 &&
|
|
1895
|
+
claimableUpdateIds.has(updateId as number),
|
|
1896
|
+
)
|
|
1897
|
+
) {
|
|
1898
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1899
|
+
`Telegram update ${currentUpdateId} returned an invalid queue receipt.`,
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
const sourceUpdateIds = [...new Set(value.sourceUpdateIds as number[])];
|
|
1903
|
+
if (
|
|
1904
|
+
sourceUpdateIds.length !== value.sourceUpdateIds.length ||
|
|
1905
|
+
!sourceUpdateIds.includes(currentUpdateId)
|
|
1906
|
+
) {
|
|
1907
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1908
|
+
`Telegram update ${currentUpdateId} returned a mismatched queue receipt.`,
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1911
|
+
return {
|
|
1912
|
+
kind: "queued",
|
|
1913
|
+
queueKind: value.queueKind,
|
|
1914
|
+
receiptId: value.receiptId,
|
|
1915
|
+
sourceUpdateIds,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
1919
|
+
`Telegram update ${currentUpdateId} returned an unknown admission outcome.`,
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function normalizeTelegramUpdateRetryPolicy(
|
|
1924
|
+
input: Partial<TelegramUpdateRetryPolicy> | undefined,
|
|
1925
|
+
): TelegramUpdateRetryPolicy {
|
|
1926
|
+
const policy = {
|
|
1927
|
+
baseDelayMs: input?.baseDelayMs ?? TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS,
|
|
1928
|
+
maxDelayMs: input?.maxDelayMs ?? TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS,
|
|
1929
|
+
};
|
|
1930
|
+
if (
|
|
1931
|
+
!Number.isSafeInteger(policy.baseDelayMs) ||
|
|
1932
|
+
policy.baseDelayMs <= 0 ||
|
|
1933
|
+
!Number.isSafeInteger(policy.maxDelayMs) ||
|
|
1934
|
+
policy.maxDelayMs < policy.baseDelayMs
|
|
1935
|
+
) {
|
|
1936
|
+
throw new Error("Telegram update retry policy is invalid.");
|
|
1937
|
+
}
|
|
1938
|
+
return policy;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function normalizeTelegramUpdateFailureClass(value: string): string {
|
|
1942
|
+
const normalized = value
|
|
1943
|
+
.trim()
|
|
1944
|
+
.replace(/[^A-Za-z0-9._:-]+/gu, "-")
|
|
1945
|
+
.replace(/^-+|-+$/gu, "")
|
|
1946
|
+
.slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH);
|
|
1947
|
+
return normalized || "execution-error";
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function normalizeTelegramUpdateFailureSummary(value: string): string {
|
|
1951
|
+
const normalized = value
|
|
1952
|
+
.trim()
|
|
1953
|
+
.slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH);
|
|
1954
|
+
return normalized || "Telegram update execution failed.";
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function classifyTelegramUpdateExecutionFailure(
|
|
1958
|
+
error: unknown,
|
|
1959
|
+
): TelegramUpdateExecutionFailureClassification {
|
|
1960
|
+
if (error instanceof TelegramForeignUpdateSettlementError) {
|
|
1961
|
+
return {
|
|
1962
|
+
disposition:
|
|
1963
|
+
error.settlement.status === "retryable" ? "retryable" : "terminal",
|
|
1964
|
+
failureClass: normalizeTelegramUpdateFailureClass(
|
|
1965
|
+
error.settlement.failureClass,
|
|
1966
|
+
),
|
|
1967
|
+
summary: normalizeTelegramUpdateFailureSummary(
|
|
1968
|
+
error.settlement.message,
|
|
1969
|
+
),
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
const errorName =
|
|
1973
|
+
error instanceof Error && error.name ? error.name : "UnknownError";
|
|
1974
|
+
return {
|
|
1975
|
+
disposition: "retryable",
|
|
1976
|
+
failureClass: normalizeTelegramUpdateFailureClass(
|
|
1977
|
+
`execution-${errorName}`,
|
|
1978
|
+
),
|
|
1979
|
+
summary: normalizeTelegramUpdateFailureSummary(
|
|
1980
|
+
`${errorName}: Telegram update execution failed.`,
|
|
1981
|
+
),
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function getTelegramUpdateRetryDelayMs(
|
|
1986
|
+
attemptCount: number,
|
|
1987
|
+
policy: TelegramUpdateRetryPolicy,
|
|
1988
|
+
): number {
|
|
1989
|
+
const multiplier = 2 ** Math.max(0, attemptCount - 1);
|
|
1990
|
+
return Math.min(policy.maxDelayMs, policy.baseDelayMs * multiplier);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function scheduleTelegramUpdateRetry(
|
|
1994
|
+
callback: () => void,
|
|
1995
|
+
delayMs: number,
|
|
1996
|
+
): ReturnType<typeof setTimeout> {
|
|
1997
|
+
const handle = setTimeout(callback, delayMs);
|
|
1998
|
+
handle.unref?.();
|
|
1999
|
+
return handle;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function normalizeTelegramUpdateQueueOwnerIdentity(
|
|
2003
|
+
value: TelegramUpdateJournalQueueOwnerIdentity,
|
|
2004
|
+
): TelegramUpdateJournalQueueOwnerIdentity {
|
|
2005
|
+
if (
|
|
2006
|
+
typeof value.instanceId !== "string" ||
|
|
2007
|
+
value.instanceId.length === 0 ||
|
|
2008
|
+
value.instanceId.length >
|
|
2009
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
|
|
2010
|
+
!Number.isSafeInteger(value.processId) ||
|
|
2011
|
+
value.processId <= 0 ||
|
|
2012
|
+
typeof value.processBirthId !== "string" ||
|
|
2013
|
+
value.processBirthId.length === 0 ||
|
|
2014
|
+
value.processBirthId.length >
|
|
2015
|
+
TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
|
|
2016
|
+
!Number.isSafeInteger(value.sessionGeneration) ||
|
|
2017
|
+
value.sessionGeneration <= 0
|
|
2018
|
+
) {
|
|
2019
|
+
throw new Error("Telegram update queue owner identity is invalid.");
|
|
2020
|
+
}
|
|
2021
|
+
return { ...value };
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
2025
|
+
deps: TelegramUpdateWorkerRuntimeDeps<TContext>,
|
|
2026
|
+
): TelegramUpdateWorkerRuntime<TContext> {
|
|
2027
|
+
if (
|
|
2028
|
+
(deps.scheduleRetry === undefined) !==
|
|
2029
|
+
(deps.cancelRetry === undefined)
|
|
2030
|
+
) {
|
|
2031
|
+
throw new Error(
|
|
2032
|
+
"Telegram update retry scheduling requires matching schedule and cancel ports.",
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
2036
|
+
const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
|
|
2037
|
+
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
2038
|
+
throw new Error("Telegram update worker batch size must be positive.");
|
|
2039
|
+
}
|
|
2040
|
+
const yieldToEventLoop = deps.yieldToEventLoop ??
|
|
2041
|
+
(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
|
2042
|
+
const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
|
|
2043
|
+
const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
|
|
2044
|
+
const createAbortController =
|
|
2045
|
+
deps.createAbortController ?? (() => new AbortController());
|
|
2046
|
+
const retryPolicy = normalizeTelegramUpdateRetryPolicy(deps.retryPolicy);
|
|
2047
|
+
const scheduleRetry = deps.scheduleRetry ?? scheduleTelegramUpdateRetry;
|
|
2048
|
+
const cancelRetry =
|
|
2049
|
+
deps.cancelRetry ??
|
|
2050
|
+
((handle: unknown) =>
|
|
2051
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
2052
|
+
const resolveQueueOwnerIdentity = (
|
|
2053
|
+
ctx: TContext,
|
|
2054
|
+
generation: number,
|
|
2055
|
+
): TelegramUpdateJournalQueueOwnerIdentity =>
|
|
2056
|
+
normalizeTelegramUpdateQueueOwnerIdentity(
|
|
2057
|
+
deps.getQueueOwnerIdentity?.(ctx) ?? {
|
|
2058
|
+
instanceId: fallbackQueueOwnerInstanceId,
|
|
2059
|
+
processId: fallbackQueueOwnerProcessId,
|
|
2060
|
+
processBirthId: `${fallbackQueueOwnerProcessId}:${fallbackQueueOwnerInstanceId}`,
|
|
2061
|
+
sessionGeneration: generation,
|
|
2062
|
+
},
|
|
2063
|
+
);
|
|
2064
|
+
const state: TelegramUpdateWorkerStateSnapshot = {
|
|
2065
|
+
phase: "stopped",
|
|
2066
|
+
generation: 0,
|
|
2067
|
+
journalEntryCount: 0,
|
|
2068
|
+
journalSerializedBytes: 0,
|
|
2069
|
+
deferredClaimCount: 0,
|
|
2070
|
+
queuedClaimCount: 0,
|
|
2071
|
+
foreignQueuedCount: 0,
|
|
2072
|
+
retryWaitCount: 0,
|
|
2073
|
+
failedCount: 0,
|
|
2074
|
+
unsettledExecutionCount: 0,
|
|
2075
|
+
};
|
|
2076
|
+
const claims = new Map<number, TelegramUpdateWorkerClaim>();
|
|
2077
|
+
const unsettledExecutionsByUpdateId = new Map<
|
|
2078
|
+
number,
|
|
2079
|
+
Set<Promise<TelegramUpdateWorkerExecutionSettlement>>
|
|
2080
|
+
>();
|
|
2081
|
+
const committedQueueReceipts = new Map<
|
|
2082
|
+
string,
|
|
2083
|
+
{
|
|
2084
|
+
receipt: TelegramQueueAdmissionReceiptLike;
|
|
2085
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2086
|
+
}
|
|
2087
|
+
>();
|
|
2088
|
+
const unsettledExecutions =
|
|
2089
|
+
new Set<Promise<TelegramUpdateWorkerExecutionSettlement>>();
|
|
2090
|
+
let owner: TelegramUpdateWorkerOwner<TContext> | undefined;
|
|
2091
|
+
let drainPromise: Promise<void> | undefined;
|
|
2092
|
+
let pendingSignal = false;
|
|
2093
|
+
let blocked = false;
|
|
2094
|
+
let nextGeneration = 0;
|
|
2095
|
+
let retryTimer: unknown;
|
|
2096
|
+
let retryTimerAtMs: number | undefined;
|
|
2097
|
+
let retryTimerToken: object | undefined;
|
|
2098
|
+
let launchDrain: () => void = () => {};
|
|
2099
|
+
|
|
2100
|
+
const recordRuntimeEvent = (
|
|
2101
|
+
error: unknown,
|
|
2102
|
+
details: Record<string, unknown>,
|
|
2103
|
+
): void => {
|
|
2104
|
+
try {
|
|
2105
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, details);
|
|
2106
|
+
} catch {
|
|
2107
|
+
// Diagnostics cannot own or terminate worker progress.
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
|
|
2111
|
+
const notifyStateChange = (): void => {
|
|
2112
|
+
try {
|
|
2113
|
+
deps.onStateChange?.(getTelegramUpdateWorkerStateSnapshot(state));
|
|
2114
|
+
} catch (error) {
|
|
2115
|
+
recordRuntimeEvent(error, { phase: "state-observer" });
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
|
|
2119
|
+
const updateClaimCounts = (): void => {
|
|
2120
|
+
let deferredClaimCount = 0;
|
|
2121
|
+
let queuedClaimCount = 0;
|
|
2122
|
+
for (const claim of claims.values()) {
|
|
2123
|
+
if (claim === "queued") queuedClaimCount += 1;
|
|
2124
|
+
else deferredClaimCount += 1;
|
|
2125
|
+
}
|
|
2126
|
+
state.deferredClaimCount = deferredClaimCount;
|
|
2127
|
+
state.queuedClaimCount = queuedClaimCount;
|
|
2128
|
+
};
|
|
2129
|
+
|
|
2130
|
+
const releaseDeferredClaims = (updateIds: readonly number[]): void => {
|
|
2131
|
+
let changed = false;
|
|
2132
|
+
for (const updateId of updateIds) {
|
|
2133
|
+
if (claims.get(updateId) !== "deferred") continue;
|
|
2134
|
+
claims.delete(updateId);
|
|
2135
|
+
changed = true;
|
|
2136
|
+
}
|
|
2137
|
+
if (!changed) return;
|
|
2138
|
+
updateClaimCounts();
|
|
2139
|
+
notifyStateChange();
|
|
2140
|
+
};
|
|
2141
|
+
|
|
2142
|
+
const transition = (
|
|
2143
|
+
phase: TelegramUpdateWorkerPhase,
|
|
2144
|
+
currentUpdateId?: number,
|
|
2145
|
+
blockedReason?: TelegramUpdateWorkerBlockedReason,
|
|
2146
|
+
): void => {
|
|
2147
|
+
state.phase = phase;
|
|
2148
|
+
state.phaseStartedAtMs = getNowMs();
|
|
2149
|
+
state.currentUpdateId = currentUpdateId;
|
|
2150
|
+
state.blockedReason = phase === "blocked" ? blockedReason : undefined;
|
|
2151
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2152
|
+
updateClaimCounts();
|
|
2153
|
+
notifyStateChange();
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
const blockWithFailure = (
|
|
2157
|
+
blockedReason: Exclude<TelegramUpdateWorkerBlockedReason, "authority-lost">,
|
|
2158
|
+
failurePhase: string,
|
|
2159
|
+
error: unknown,
|
|
2160
|
+
currentUpdateId?: number,
|
|
2161
|
+
): "blocked" => {
|
|
2162
|
+
blocked = true;
|
|
2163
|
+
state.lastFailureAtMs = getNowMs();
|
|
2164
|
+
state.lastFailurePhase = failurePhase;
|
|
2165
|
+
recordRuntimeEvent(error, {
|
|
2166
|
+
phase: failurePhase,
|
|
2167
|
+
generation: owner?.generation,
|
|
2168
|
+
...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
|
|
2169
|
+
});
|
|
2170
|
+
transition("blocked", currentUpdateId, blockedReason);
|
|
2171
|
+
return "blocked";
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
const normalizeQueueReceipt = (
|
|
2175
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2176
|
+
): TelegramQueueAdmissionReceiptLike => ({
|
|
2177
|
+
queueKind: receipt.queueKind,
|
|
2178
|
+
receiptId: receipt.receiptId,
|
|
2179
|
+
sourceUpdateIds: [...receipt.sourceUpdateIds].sort(
|
|
2180
|
+
(left, right) => left - right,
|
|
2181
|
+
),
|
|
2182
|
+
...(receipt.journalBindingKey
|
|
2183
|
+
? { journalBindingKey: receipt.journalBindingKey }
|
|
2184
|
+
: {}),
|
|
2185
|
+
});
|
|
2186
|
+
const bindQueueReceiptToJournal = (
|
|
2187
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2188
|
+
): TelegramQueueAdmissionReceiptLike => ({
|
|
2189
|
+
...normalizeQueueReceipt(receipt),
|
|
2190
|
+
...(deps.getJournalBindingKey?.()
|
|
2191
|
+
? { journalBindingKey: deps.getJournalBindingKey() }
|
|
2192
|
+
: {}),
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
const publishCommittedQueueReceipt = (
|
|
2196
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2197
|
+
queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2198
|
+
ctx: TContext,
|
|
2199
|
+
): boolean => {
|
|
2200
|
+
const normalized = bindQueueReceiptToJournal(receipt);
|
|
2201
|
+
const existing = committedQueueReceipts.get(receipt.receiptId);
|
|
2202
|
+
if (existing) {
|
|
2203
|
+
if (
|
|
2204
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
2205
|
+
existing.receipt,
|
|
2206
|
+
normalized,
|
|
2207
|
+
) ||
|
|
2208
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
2209
|
+
existing.queueOwner,
|
|
2210
|
+
queueOwner,
|
|
2211
|
+
)
|
|
2212
|
+
) {
|
|
2213
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2214
|
+
`Telegram queue receipt ${receipt.receiptId} has conflicting committed authority.`,
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
return false;
|
|
2218
|
+
}
|
|
2219
|
+
committedQueueReceipts.set(receipt.receiptId, {
|
|
2220
|
+
receipt: normalized,
|
|
2221
|
+
queueOwner: { ...queueOwner },
|
|
2222
|
+
});
|
|
2223
|
+
try {
|
|
2224
|
+
deps.onQueueReceiptCommitted?.(normalized, ctx);
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
recordRuntimeEvent(error, {
|
|
2227
|
+
phase: "queue-receipt-observer",
|
|
2228
|
+
receiptId: normalized.receiptId,
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
return true;
|
|
2232
|
+
};
|
|
2233
|
+
|
|
2234
|
+
const clearRetryTimer = (): void => {
|
|
2235
|
+
if (retryTimer !== undefined) cancelRetry(retryTimer);
|
|
2236
|
+
retryTimer = undefined;
|
|
2237
|
+
retryTimerAtMs = undefined;
|
|
2238
|
+
retryTimerToken = undefined;
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
const scheduleNextRetry = (
|
|
2242
|
+
nextRetryAtMs: number | undefined,
|
|
2243
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2244
|
+
): void => {
|
|
2245
|
+
if (nextRetryAtMs === undefined) {
|
|
2246
|
+
clearRetryTimer();
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (retryTimer !== undefined && retryTimerAtMs === nextRetryAtMs) {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
clearRetryTimer();
|
|
2253
|
+
const token = {};
|
|
2254
|
+
retryTimerAtMs = nextRetryAtMs;
|
|
2255
|
+
retryTimerToken = token;
|
|
2256
|
+
retryTimer = scheduleRetry(() => {
|
|
2257
|
+
if (retryTimerToken !== token) return;
|
|
2258
|
+
retryTimer = undefined;
|
|
2259
|
+
retryTimerAtMs = undefined;
|
|
2260
|
+
retryTimerToken = undefined;
|
|
2261
|
+
if (
|
|
2262
|
+
owner !== expectedOwner ||
|
|
2263
|
+
expectedOwner.controller.signal.aborted
|
|
2264
|
+
) {
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
pendingSignal = true;
|
|
2268
|
+
launchDrain();
|
|
2269
|
+
}, Math.max(0, nextRetryAtMs - getNowMs()));
|
|
2270
|
+
};
|
|
2271
|
+
|
|
2272
|
+
const refreshJournalState = (
|
|
2273
|
+
snapshot: TelegramUpdateWorkerJournalSnapshot,
|
|
2274
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2275
|
+
): number | undefined => {
|
|
2276
|
+
const availableUpdateIds = new Set<number>();
|
|
2277
|
+
const queuedReceiptEntries = new Map<
|
|
2278
|
+
string,
|
|
2279
|
+
{
|
|
2280
|
+
queueKind: "prompt" | "control";
|
|
2281
|
+
sourceUpdateIds: number[];
|
|
2282
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2283
|
+
}
|
|
2284
|
+
>();
|
|
2285
|
+
const locallyOwnedQueuedUpdateIds = new Set<number>();
|
|
2286
|
+
let foreignQueuedCount = 0;
|
|
2287
|
+
let foreignQueuedOwner: TelegramUpdateJournalQueueOwner | undefined;
|
|
2288
|
+
let oldestAdmittedAtMs: number | undefined;
|
|
2289
|
+
let retryWaitCount = 0;
|
|
2290
|
+
let scheduledRetryAtMs: number | undefined;
|
|
2291
|
+
let nextRetry: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
|
|
2292
|
+
let failedCount = 0;
|
|
2293
|
+
let latestFailure: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
|
|
2294
|
+
for (const entry of snapshot.entries) {
|
|
2295
|
+
availableUpdateIds.add(entry.updateId);
|
|
2296
|
+
oldestAdmittedAtMs =
|
|
2297
|
+
oldestAdmittedAtMs === undefined
|
|
2298
|
+
? entry.admittedAtMs
|
|
2299
|
+
: Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
|
|
2300
|
+
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
|
|
2301
|
+
scheduledRetryAtMs =
|
|
2302
|
+
scheduledRetryAtMs === undefined
|
|
2303
|
+
? entry.nextRetryAtMs
|
|
2304
|
+
: Math.min(scheduledRetryAtMs, entry.nextRetryAtMs);
|
|
2305
|
+
}
|
|
2306
|
+
if (
|
|
2307
|
+
entry.state === "retry-wait" &&
|
|
2308
|
+
entry.failure !== undefined &&
|
|
2309
|
+
entry.nextRetryAtMs !== undefined
|
|
2310
|
+
) {
|
|
2311
|
+
retryWaitCount += 1;
|
|
2312
|
+
if (
|
|
2313
|
+
!nextRetry ||
|
|
2314
|
+
nextRetry.nextRetryAtMs === undefined ||
|
|
2315
|
+
entry.nextRetryAtMs < nextRetry.nextRetryAtMs ||
|
|
2316
|
+
(entry.nextRetryAtMs === nextRetry.nextRetryAtMs &&
|
|
2317
|
+
entry.updateId < nextRetry.updateId)
|
|
2318
|
+
) {
|
|
2319
|
+
nextRetry = entry;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
if (
|
|
2323
|
+
entry.state === "failed" &&
|
|
2324
|
+
entry.failure !== undefined &&
|
|
2325
|
+
entry.terminalAtMs !== undefined
|
|
2326
|
+
) {
|
|
2327
|
+
failedCount += 1;
|
|
2328
|
+
if (
|
|
2329
|
+
!latestFailure ||
|
|
2330
|
+
latestFailure.terminalAtMs === undefined ||
|
|
2331
|
+
entry.terminalAtMs > latestFailure.terminalAtMs ||
|
|
2332
|
+
(entry.terminalAtMs === latestFailure.terminalAtMs &&
|
|
2333
|
+
entry.updateId > latestFailure.updateId)
|
|
2334
|
+
) {
|
|
2335
|
+
latestFailure = entry;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
if (entry.state !== "queued") continue;
|
|
2339
|
+
if (!entry.queueKind || !entry.queueReceiptId) {
|
|
2340
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2341
|
+
`Telegram queued update ${entry.updateId} has no receipt metadata.`,
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
if (
|
|
2345
|
+
entry.queueHandoff ||
|
|
2346
|
+
!entry.queueOwner ||
|
|
2347
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2348
|
+
entry.queueOwner,
|
|
2349
|
+
expectedOwner.queueOwnerIdentity,
|
|
2350
|
+
)
|
|
2351
|
+
) {
|
|
2352
|
+
foreignQueuedCount += 1;
|
|
2353
|
+
foreignQueuedOwner ??= entry.queueOwner;
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
locallyOwnedQueuedUpdateIds.add(entry.updateId);
|
|
2357
|
+
claims.set(entry.updateId, "queued");
|
|
2358
|
+
const receipt = queuedReceiptEntries.get(entry.queueReceiptId);
|
|
2359
|
+
if (
|
|
2360
|
+
receipt &&
|
|
2361
|
+
(receipt.queueKind !== entry.queueKind ||
|
|
2362
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
2363
|
+
receipt.queueOwner,
|
|
2364
|
+
entry.queueOwner,
|
|
2365
|
+
))
|
|
2366
|
+
) {
|
|
2367
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
2368
|
+
`Telegram queue receipt ${entry.queueReceiptId} has conflicting authority.`,
|
|
2369
|
+
);
|
|
2370
|
+
}
|
|
2371
|
+
if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
|
|
2372
|
+
else {
|
|
2373
|
+
queuedReceiptEntries.set(entry.queueReceiptId, {
|
|
2374
|
+
queueKind: entry.queueKind,
|
|
2375
|
+
sourceUpdateIds: [entry.updateId],
|
|
2376
|
+
queueOwner: { ...entry.queueOwner },
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
for (const [updateId, claim] of claims) {
|
|
2381
|
+
if (
|
|
2382
|
+
!availableUpdateIds.has(updateId) ||
|
|
2383
|
+
(claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))
|
|
2384
|
+
) {
|
|
2385
|
+
claims.delete(updateId);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
for (const [receiptId, receipt] of queuedReceiptEntries) {
|
|
2389
|
+
publishCommittedQueueReceipt(
|
|
2390
|
+
{
|
|
2391
|
+
queueKind: receipt.queueKind,
|
|
2392
|
+
receiptId,
|
|
2393
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
2394
|
+
},
|
|
2395
|
+
receipt.queueOwner,
|
|
2396
|
+
expectedOwner.ctx,
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
for (const [receiptId] of committedQueueReceipts) {
|
|
2400
|
+
if (!queuedReceiptEntries.has(receiptId)) {
|
|
2401
|
+
committedQueueReceipts.delete(receiptId);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
state.foreignQueuedCount = foreignQueuedCount;
|
|
2405
|
+
if (foreignQueuedOwner) state.foreignQueuedOwner = foreignQueuedOwner;
|
|
2406
|
+
else delete state.foreignQueuedOwner;
|
|
2407
|
+
state.journalEntryCount = snapshot.entries.length;
|
|
2408
|
+
state.journalSerializedBytes = snapshot.serializedBytes;
|
|
2409
|
+
state.oldestAdmittedAtMs = oldestAdmittedAtMs;
|
|
2410
|
+
state.retryWaitCount = retryWaitCount;
|
|
2411
|
+
state.nextRetryUpdateId = nextRetry?.updateId;
|
|
2412
|
+
state.nextRetryAtMs = nextRetry?.nextRetryAtMs;
|
|
2413
|
+
state.nextRetryAttemptCount = nextRetry?.failure?.attemptCount;
|
|
2414
|
+
state.nextRetryFailureClass = nextRetry?.failure?.failureClass;
|
|
2415
|
+
state.failedCount = failedCount;
|
|
2416
|
+
state.failedUpdateId = latestFailure?.updateId;
|
|
2417
|
+
state.failedFailureId = latestFailure?.terminalFailureId;
|
|
2418
|
+
state.failedAttemptCount = latestFailure?.failure?.attemptCount;
|
|
2419
|
+
state.failedClass = latestFailure?.failure?.failureClass;
|
|
2420
|
+
state.failedSummary = latestFailure?.failure?.summary;
|
|
2421
|
+
state.terminalFailureAtMs = latestFailure?.terminalAtMs;
|
|
2422
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2423
|
+
updateClaimCounts();
|
|
2424
|
+
return scheduledRetryAtMs;
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
const checkAuthority = (
|
|
2428
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2429
|
+
currentUpdateId?: number,
|
|
2430
|
+
):
|
|
2431
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle">
|
|
2432
|
+
| undefined => {
|
|
2433
|
+
if (blocked) return "blocked";
|
|
2434
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
|
|
2435
|
+
return "aborted";
|
|
2436
|
+
}
|
|
2437
|
+
try {
|
|
2438
|
+
if (deps.hasAuthority(expectedOwner.ctx)) return undefined;
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
return blockWithFailure(
|
|
2441
|
+
"authority-check",
|
|
2442
|
+
"authority-check",
|
|
2443
|
+
error,
|
|
2444
|
+
currentUpdateId,
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
transition("blocked", currentUpdateId, "authority-lost");
|
|
2448
|
+
return "blocked";
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
const executeWithinOwner = async (
|
|
2452
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2453
|
+
update: TelegramJournaledUpdate,
|
|
2454
|
+
): Promise<
|
|
2455
|
+
| TelegramUpdateWorkerExecutionSettlement
|
|
2456
|
+
| typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED
|
|
2457
|
+
> => {
|
|
2458
|
+
if (expectedOwner.controller.signal.aborted) {
|
|
2459
|
+
return TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED;
|
|
2460
|
+
}
|
|
2461
|
+
const execution = Promise.resolve().then(() =>
|
|
2462
|
+
deps.executeUpdate(
|
|
2463
|
+
update,
|
|
2464
|
+
expectedOwner.ctx,
|
|
2465
|
+
expectedOwner.controller.signal,
|
|
2466
|
+
),
|
|
2467
|
+
);
|
|
2468
|
+
const settlement: Promise<TelegramUpdateWorkerExecutionSettlement> =
|
|
2469
|
+
execution.then(
|
|
2470
|
+
(outcome) => ({ ok: true, outcome }),
|
|
2471
|
+
(error: unknown) => ({ ok: false, error }),
|
|
2472
|
+
);
|
|
2473
|
+
unsettledExecutions.add(settlement);
|
|
2474
|
+
const updateExecutions =
|
|
2475
|
+
unsettledExecutionsByUpdateId.get(update.update_id) ?? new Set();
|
|
2476
|
+
updateExecutions.add(settlement);
|
|
2477
|
+
unsettledExecutionsByUpdateId.set(update.update_id, updateExecutions);
|
|
2478
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2479
|
+
notifyStateChange();
|
|
2480
|
+
void settlement.then((result) => {
|
|
2481
|
+
unsettledExecutions.delete(settlement);
|
|
2482
|
+
const currentExecutions = unsettledExecutionsByUpdateId.get(
|
|
2483
|
+
update.update_id,
|
|
2484
|
+
);
|
|
2485
|
+
currentExecutions?.delete(settlement);
|
|
2486
|
+
if (currentExecutions?.size === 0) {
|
|
2487
|
+
unsettledExecutionsByUpdateId.delete(update.update_id);
|
|
2488
|
+
}
|
|
2489
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2490
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
|
|
2491
|
+
recordRuntimeEvent(
|
|
2492
|
+
result.ok
|
|
2493
|
+
? "Superseded Telegram update execution settled successfully."
|
|
2494
|
+
: result.error,
|
|
2495
|
+
{
|
|
2496
|
+
phase: result.ok ? "late-execution-success" : "late-execution",
|
|
2497
|
+
generation: expectedOwner.generation,
|
|
2498
|
+
updateId: update.update_id,
|
|
2499
|
+
},
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
notifyStateChange();
|
|
2503
|
+
});
|
|
2504
|
+
let removeAbortListener = (): void => {};
|
|
2505
|
+
const aborted = new Promise<typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED>(
|
|
2506
|
+
(resolve) => {
|
|
2507
|
+
const onAbort = () => resolve(TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED);
|
|
2508
|
+
removeAbortListener = () =>
|
|
2509
|
+
expectedOwner.controller.signal.removeEventListener(
|
|
2510
|
+
"abort",
|
|
2511
|
+
onAbort,
|
|
2512
|
+
);
|
|
2513
|
+
expectedOwner.controller.signal.addEventListener("abort", onAbort, {
|
|
2514
|
+
once: true,
|
|
2515
|
+
});
|
|
2516
|
+
if (expectedOwner.controller.signal.aborted) onAbort();
|
|
2517
|
+
},
|
|
2518
|
+
);
|
|
2519
|
+
try {
|
|
2520
|
+
return await Promise.race([settlement, aborted]);
|
|
2521
|
+
} finally {
|
|
2522
|
+
removeAbortListener();
|
|
2523
|
+
}
|
|
2524
|
+
};
|
|
2525
|
+
|
|
2526
|
+
const commitQueuedOutcome = (
|
|
2527
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2528
|
+
currentUpdateId: number,
|
|
2529
|
+
outcome: TelegramQueuedUpdateAdmissionOutcome,
|
|
2530
|
+
):
|
|
2531
|
+
| "committed"
|
|
2532
|
+
| "duplicate"
|
|
2533
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2534
|
+
const normalized = normalizeQueueReceipt(outcome);
|
|
2535
|
+
const existing = committedQueueReceipts.get(normalized.receiptId);
|
|
2536
|
+
if (existing) {
|
|
2537
|
+
if (
|
|
2538
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
2539
|
+
existing.receipt,
|
|
2540
|
+
normalized,
|
|
2541
|
+
) ||
|
|
2542
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2543
|
+
existing.queueOwner,
|
|
2544
|
+
expectedOwner.queueOwnerIdentity,
|
|
2545
|
+
)
|
|
2546
|
+
) {
|
|
2547
|
+
return blockWithFailure(
|
|
2548
|
+
"invalid-outcome",
|
|
2549
|
+
"queue-receipt-conflict",
|
|
2550
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
2551
|
+
`Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
|
|
2552
|
+
),
|
|
2553
|
+
currentUpdateId,
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
return "duplicate";
|
|
2557
|
+
}
|
|
2558
|
+
const commitAuthority = checkAuthority(expectedOwner, currentUpdateId);
|
|
2559
|
+
if (commitAuthority) return commitAuthority;
|
|
2560
|
+
let queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2561
|
+
try {
|
|
2562
|
+
const committed = deps.journal.markQueued({
|
|
2563
|
+
...normalized,
|
|
2564
|
+
owner: expectedOwner.queueOwnerIdentity,
|
|
2565
|
+
});
|
|
2566
|
+
const committedUpdateIds = new Set([
|
|
2567
|
+
...committed.queuedUpdateIds,
|
|
2568
|
+
...committed.duplicateUpdateIds,
|
|
2569
|
+
]);
|
|
2570
|
+
if (
|
|
2571
|
+
normalized.sourceUpdateIds.some(
|
|
2572
|
+
(updateId) => !committedUpdateIds.has(updateId),
|
|
2573
|
+
)
|
|
2574
|
+
) {
|
|
2575
|
+
throw new Error(
|
|
2576
|
+
`Telegram queue receipt ${normalized.receiptId} did not commit every source update.`,
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
if (
|
|
2580
|
+
!committed.queueOwner ||
|
|
2581
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
2582
|
+
committed.queueOwner,
|
|
2583
|
+
expectedOwner.queueOwnerIdentity,
|
|
2584
|
+
)
|
|
2585
|
+
) {
|
|
2586
|
+
throw new Error(
|
|
2587
|
+
`Telegram queue receipt ${normalized.receiptId} belongs to another live process.`,
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
queueOwner = committed.queueOwner;
|
|
2591
|
+
} catch (error) {
|
|
2592
|
+
return blockWithFailure(
|
|
2593
|
+
"journal-write",
|
|
2594
|
+
"queue-receipt-commit",
|
|
2595
|
+
error,
|
|
2596
|
+
currentUpdateId,
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
for (const sourceUpdateId of normalized.sourceUpdateIds) {
|
|
2600
|
+
claims.set(sourceUpdateId, "queued");
|
|
2601
|
+
}
|
|
2602
|
+
try {
|
|
2603
|
+
publishCommittedQueueReceipt(
|
|
2604
|
+
normalized,
|
|
2605
|
+
queueOwner,
|
|
2606
|
+
expectedOwner.ctx,
|
|
2607
|
+
);
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
return blockWithFailure(
|
|
2610
|
+
"invalid-outcome",
|
|
2611
|
+
"queue-receipt-publish",
|
|
2612
|
+
error,
|
|
2613
|
+
currentUpdateId,
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
return "committed";
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
const persistExecutionFailure = (
|
|
2620
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2621
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number],
|
|
2622
|
+
error: unknown,
|
|
2623
|
+
):
|
|
2624
|
+
| "retry-wait"
|
|
2625
|
+
| "failed"
|
|
2626
|
+
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2627
|
+
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
2628
|
+
if (authorityResult) return authorityResult;
|
|
2629
|
+
let rawClassification: TelegramUpdateExecutionFailureClassification;
|
|
2630
|
+
try {
|
|
2631
|
+
rawClassification = deps.classifyExecutionFailure
|
|
2632
|
+
? deps.classifyExecutionFailure(error)
|
|
2633
|
+
: classifyTelegramUpdateExecutionFailure(error);
|
|
2634
|
+
if (
|
|
2635
|
+
(rawClassification.disposition !== "retryable" &&
|
|
2636
|
+
rawClassification.disposition !== "terminal") ||
|
|
2637
|
+
typeof rawClassification.failureClass !== "string" ||
|
|
2638
|
+
typeof rawClassification.summary !== "string"
|
|
2639
|
+
) {
|
|
2640
|
+
throw new Error("Telegram update failure classifier returned invalid data.");
|
|
2641
|
+
}
|
|
2642
|
+
} catch (classificationError) {
|
|
2643
|
+
return blockWithFailure(
|
|
2644
|
+
"execution",
|
|
2645
|
+
"failure-classification",
|
|
2646
|
+
classificationError,
|
|
2647
|
+
entry.updateId,
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
const failureClass = normalizeTelegramUpdateFailureClass(
|
|
2651
|
+
rawClassification.failureClass,
|
|
2652
|
+
);
|
|
2653
|
+
const summary = normalizeTelegramUpdateFailureSummary(
|
|
2654
|
+
rawClassification.summary,
|
|
2655
|
+
);
|
|
2656
|
+
const expectedAttemptCount = entry.failure?.attemptCount ?? 0;
|
|
2657
|
+
const attemptCount = expectedAttemptCount + 1;
|
|
2658
|
+
const failedAtMs = getNowMs();
|
|
2659
|
+
const disposition = "retry-wait" as const;
|
|
2660
|
+
const nextRetryAtMs =
|
|
2661
|
+
failedAtMs + getTelegramUpdateRetryDelayMs(attemptCount, retryPolicy);
|
|
2662
|
+
try {
|
|
2663
|
+
const result = deps.journal.markExecutionFailure({
|
|
2664
|
+
updateId: entry.updateId,
|
|
2665
|
+
expectedAttemptCount,
|
|
2666
|
+
failedAtMs,
|
|
2667
|
+
failureClass,
|
|
2668
|
+
summary,
|
|
2669
|
+
disposition,
|
|
2670
|
+
nextRetryAtMs,
|
|
2671
|
+
});
|
|
2672
|
+
if (result.entry.state !== disposition) {
|
|
2673
|
+
throw new Error(
|
|
2674
|
+
`Telegram update ${entry.updateId} failure disposition did not persist.`,
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
} catch (journalError) {
|
|
2678
|
+
return blockWithFailure(
|
|
2679
|
+
"journal-write",
|
|
2680
|
+
"execution-failure-commit",
|
|
2681
|
+
journalError,
|
|
2682
|
+
entry.updateId,
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
state.lastFailureAtMs = failedAtMs;
|
|
2686
|
+
state.lastFailurePhase = "execute";
|
|
2687
|
+
recordRuntimeEvent(error, {
|
|
2688
|
+
phase: "execute",
|
|
2689
|
+
generation: expectedOwner.generation,
|
|
2690
|
+
updateId: entry.updateId,
|
|
2691
|
+
failureClass,
|
|
2692
|
+
attemptCount,
|
|
2693
|
+
disposition,
|
|
2694
|
+
nextRetryAtMs,
|
|
2695
|
+
});
|
|
2696
|
+
transition(disposition, entry.updateId);
|
|
2697
|
+
return disposition;
|
|
2698
|
+
};
|
|
2699
|
+
|
|
2700
|
+
const commitCompletedBatch = (
|
|
2701
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2702
|
+
updateIds: readonly number[],
|
|
2703
|
+
): Exclude<TelegramUpdateWorkerDrainResult, "idle"> | undefined => {
|
|
2704
|
+
if (updateIds.length === 0) return undefined;
|
|
2705
|
+
const commitAuthority = checkAuthority(
|
|
2706
|
+
expectedOwner,
|
|
2707
|
+
updateIds[updateIds.length - 1],
|
|
2708
|
+
);
|
|
2709
|
+
if (commitAuthority) return commitAuthority;
|
|
2710
|
+
try {
|
|
2711
|
+
const removed = deps.journal.removeCompleted(updateIds);
|
|
2712
|
+
const removedIds = new Set(removed.removedUpdateIds);
|
|
2713
|
+
if (updateIds.some((updateId) => !removedIds.has(updateId))) {
|
|
2714
|
+
throw new Error(
|
|
2715
|
+
"Telegram update batch changed before completion commit.",
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
} catch (error) {
|
|
2719
|
+
return blockWithFailure(
|
|
2720
|
+
"journal-write",
|
|
2721
|
+
"completion-commit",
|
|
2722
|
+
error,
|
|
2723
|
+
updateIds[updateIds.length - 1],
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
const completedAtMs = getNowMs();
|
|
2727
|
+
for (const updateId of updateIds) {
|
|
2728
|
+
claims.delete(updateId);
|
|
2729
|
+
state.lastCompletedUpdateId = updateId;
|
|
2730
|
+
state.lastCompletedAtMs = completedAtMs;
|
|
2731
|
+
try {
|
|
2732
|
+
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
|
|
2733
|
+
} catch (error) {
|
|
2734
|
+
recordRuntimeEvent(error, {
|
|
2735
|
+
phase: "update-completion-observer",
|
|
2736
|
+
updateId,
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
return undefined;
|
|
2741
|
+
};
|
|
2742
|
+
|
|
2743
|
+
const drain = async (
|
|
2744
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2745
|
+
): Promise<TelegramUpdateWorkerDrainResult> => {
|
|
2746
|
+
while (owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
2747
|
+
const authorityResult = checkAuthority(expectedOwner);
|
|
2748
|
+
if (authorityResult) return authorityResult;
|
|
2749
|
+
let snapshot: TelegramUpdateWorkerJournalSnapshot;
|
|
2750
|
+
let scheduledRetryAtMs: number | undefined;
|
|
2751
|
+
try {
|
|
2752
|
+
snapshot = deps.journal.read();
|
|
2753
|
+
scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
|
|
2754
|
+
} catch (error) {
|
|
2755
|
+
return blockWithFailure("journal-read", "journal-read", error);
|
|
2756
|
+
}
|
|
2757
|
+
const nowMs = getNowMs();
|
|
2758
|
+
const entries: TelegramUpdateWorkerJournalSnapshot["entries"][number][] =
|
|
2759
|
+
[];
|
|
2760
|
+
let hasMoreEntries = false;
|
|
2761
|
+
for (const candidate of snapshot.entries) {
|
|
2762
|
+
if (
|
|
2763
|
+
!claims.has(candidate.updateId) &&
|
|
2764
|
+
(candidate.state === "pending" ||
|
|
2765
|
+
(candidate.state === "retry-wait" &&
|
|
2766
|
+
candidate.nextRetryAtMs !== undefined &&
|
|
2767
|
+
candidate.nextRetryAtMs <= nowMs))
|
|
2768
|
+
) {
|
|
2769
|
+
if (entries.length === batchSize) {
|
|
2770
|
+
hasMoreEntries = true;
|
|
2771
|
+
break;
|
|
2772
|
+
}
|
|
2773
|
+
entries.push(candidate);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
if (entries.length === 0) {
|
|
2777
|
+
scheduleNextRetry(scheduledRetryAtMs, expectedOwner);
|
|
2778
|
+
transition("idle");
|
|
2779
|
+
return "idle";
|
|
2780
|
+
}
|
|
2781
|
+
const completedUpdateIds: number[] = [];
|
|
2782
|
+
let snapshotInvalidated = false;
|
|
2783
|
+
for (const entry of entries) {
|
|
2784
|
+
const priorExecutions = unsettledExecutionsByUpdateId.get(entry.updateId);
|
|
2785
|
+
if (priorExecutions?.size) {
|
|
2786
|
+
const completionResult = commitCompletedBatch(
|
|
2787
|
+
expectedOwner,
|
|
2788
|
+
completedUpdateIds,
|
|
2789
|
+
);
|
|
2790
|
+
if (completionResult) return completionResult;
|
|
2791
|
+
transition("blocked", entry.updateId, "prior-generation-executing");
|
|
2792
|
+
await Promise.allSettled([...priorExecutions]);
|
|
2793
|
+
if (
|
|
2794
|
+
owner !== expectedOwner ||
|
|
2795
|
+
expectedOwner.controller.signal.aborted
|
|
2796
|
+
) {
|
|
2797
|
+
return "aborted";
|
|
2798
|
+
}
|
|
2799
|
+
snapshotInvalidated = true;
|
|
2800
|
+
break;
|
|
2801
|
+
}
|
|
2802
|
+
clearRetryTimer();
|
|
2803
|
+
transition("executing", entry.updateId);
|
|
2804
|
+
const execution = await executeWithinOwner(expectedOwner, entry.update);
|
|
2805
|
+
if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
|
|
2806
|
+
return "aborted";
|
|
2807
|
+
}
|
|
2808
|
+
if (!execution.ok) {
|
|
2809
|
+
const completionResult = commitCompletedBatch(
|
|
2810
|
+
expectedOwner,
|
|
2811
|
+
completedUpdateIds,
|
|
2812
|
+
);
|
|
2813
|
+
if (completionResult) return completionResult;
|
|
2814
|
+
const failureResult = persistExecutionFailure(
|
|
2815
|
+
expectedOwner,
|
|
2816
|
+
entry,
|
|
2817
|
+
execution.error,
|
|
2818
|
+
);
|
|
2819
|
+
if (failureResult === "blocked" || failureResult === "aborted") {
|
|
2820
|
+
return failureResult;
|
|
2821
|
+
}
|
|
2822
|
+
snapshotInvalidated = true;
|
|
2823
|
+
break;
|
|
2824
|
+
}
|
|
2825
|
+
const postExecutionAuthority = checkAuthority(
|
|
2826
|
+
expectedOwner,
|
|
2827
|
+
entry.updateId,
|
|
2828
|
+
);
|
|
2829
|
+
if (postExecutionAuthority) return postExecutionAuthority;
|
|
2830
|
+
const claimableUpdateIds = new Set<number>([
|
|
2831
|
+
entry.updateId,
|
|
2832
|
+
...claims.keys(),
|
|
2833
|
+
]);
|
|
2834
|
+
let outcome: TelegramUpdateAdmissionOutcome;
|
|
2835
|
+
try {
|
|
2836
|
+
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
2837
|
+
execution.outcome,
|
|
2838
|
+
entry.updateId,
|
|
2839
|
+
claimableUpdateIds,
|
|
2840
|
+
);
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
return blockWithFailure(
|
|
2843
|
+
"invalid-outcome",
|
|
2844
|
+
"invalid-outcome",
|
|
2845
|
+
error,
|
|
2846
|
+
entry.updateId,
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
if (outcome.kind === "deferred") {
|
|
2850
|
+
claims.set(entry.updateId, "deferred");
|
|
2851
|
+
transition("deferred", entry.updateId);
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
if (outcome.kind === "queued") {
|
|
2855
|
+
const completionResult = commitCompletedBatch(
|
|
2856
|
+
expectedOwner,
|
|
2857
|
+
completedUpdateIds,
|
|
2858
|
+
);
|
|
2859
|
+
if (completionResult) return completionResult;
|
|
2860
|
+
const queuedResult = commitQueuedOutcome(
|
|
2861
|
+
expectedOwner,
|
|
2862
|
+
entry.updateId,
|
|
2863
|
+
outcome,
|
|
2864
|
+
);
|
|
2865
|
+
if (queuedResult === "blocked" || queuedResult === "aborted") {
|
|
2866
|
+
return queuedResult;
|
|
2867
|
+
}
|
|
2868
|
+
transition("queued", entry.updateId);
|
|
2869
|
+
snapshotInvalidated = true;
|
|
2870
|
+
break;
|
|
2871
|
+
}
|
|
2872
|
+
completedUpdateIds.push(entry.updateId);
|
|
2873
|
+
}
|
|
2874
|
+
const completionResult = commitCompletedBatch(
|
|
2875
|
+
expectedOwner,
|
|
2876
|
+
completedUpdateIds,
|
|
2877
|
+
);
|
|
2878
|
+
if (completionResult) return completionResult;
|
|
2879
|
+
if (!snapshotInvalidated && !hasMoreEntries) continue;
|
|
2880
|
+
await yieldToEventLoop();
|
|
2881
|
+
if (
|
|
2882
|
+
owner !== expectedOwner ||
|
|
2883
|
+
expectedOwner.controller.signal.aborted
|
|
2884
|
+
) {
|
|
2885
|
+
return "aborted";
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
return "aborted";
|
|
2889
|
+
};
|
|
2890
|
+
|
|
2891
|
+
launchDrain = (): void => {
|
|
2892
|
+
const expectedOwner = owner;
|
|
2893
|
+
if (!expectedOwner || drainPromise) return;
|
|
2894
|
+
const run = async (): Promise<void> => {
|
|
2895
|
+
while (
|
|
2896
|
+
pendingSignal &&
|
|
2897
|
+
owner === expectedOwner &&
|
|
2898
|
+
!expectedOwner.controller.signal.aborted
|
|
2899
|
+
) {
|
|
2900
|
+
pendingSignal = false;
|
|
2901
|
+
const result = await drain(expectedOwner);
|
|
2902
|
+
if (result !== "idle") {
|
|
2903
|
+
pendingSignal = false;
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
};
|
|
2908
|
+
const operation = run();
|
|
2909
|
+
let tracked: Promise<void>;
|
|
2910
|
+
const finish = (): void => {
|
|
2911
|
+
if (owner === expectedOwner && drainPromise === tracked) {
|
|
2912
|
+
drainPromise = undefined;
|
|
2913
|
+
if (pendingSignal && !expectedOwner.controller.signal.aborted) {
|
|
2914
|
+
launchDrain();
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
tracked = operation.then(
|
|
2919
|
+
() => finish(),
|
|
2920
|
+
(error: unknown) => {
|
|
2921
|
+
pendingSignal = false;
|
|
2922
|
+
if (owner === expectedOwner) {
|
|
2923
|
+
blockWithFailure("execution", "worker-loop", error);
|
|
2924
|
+
}
|
|
2925
|
+
finish();
|
|
2926
|
+
},
|
|
2927
|
+
);
|
|
2928
|
+
drainPromise = tracked;
|
|
2929
|
+
};
|
|
2930
|
+
|
|
2931
|
+
return {
|
|
2932
|
+
start(ctx) {
|
|
2933
|
+
if (owner) {
|
|
2934
|
+
if (!owner.controller.signal.aborted) {
|
|
2935
|
+
pendingSignal = true;
|
|
2936
|
+
launchDrain();
|
|
2937
|
+
}
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
clearRetryTimer();
|
|
2941
|
+
const nowMs = getNowMs();
|
|
2942
|
+
const generation = ++nextGeneration;
|
|
2943
|
+
const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
|
|
2944
|
+
owner = {
|
|
2945
|
+
generation,
|
|
2946
|
+
ctx,
|
|
2947
|
+
controller: createAbortController(),
|
|
2948
|
+
queueOwnerIdentity,
|
|
2949
|
+
};
|
|
2950
|
+
claims.clear();
|
|
2951
|
+
committedQueueReceipts.clear();
|
|
2952
|
+
blocked = false;
|
|
2953
|
+
pendingSignal = true;
|
|
2954
|
+
state.phase = "idle";
|
|
2955
|
+
state.generation = generation;
|
|
2956
|
+
state.phaseStartedAtMs = nowMs;
|
|
2957
|
+
state.currentUpdateId = undefined;
|
|
2958
|
+
state.blockedReason = undefined;
|
|
2959
|
+
state.journalEntryCount = 0;
|
|
2960
|
+
state.journalSerializedBytes = 0;
|
|
2961
|
+
state.oldestAdmittedAtMs = undefined;
|
|
2962
|
+
state.deferredClaimCount = 0;
|
|
2963
|
+
state.queuedClaimCount = 0;
|
|
2964
|
+
state.foreignQueuedCount = 0;
|
|
2965
|
+
delete state.foreignQueuedOwner;
|
|
2966
|
+
state.retryWaitCount = 0;
|
|
2967
|
+
state.failedCount = 0;
|
|
2968
|
+
state.nextRetryUpdateId = undefined;
|
|
2969
|
+
state.nextRetryAtMs = undefined;
|
|
2970
|
+
state.nextRetryAttemptCount = undefined;
|
|
2971
|
+
state.nextRetryFailureClass = undefined;
|
|
2972
|
+
state.failedUpdateId = undefined;
|
|
2973
|
+
state.failedFailureId = undefined;
|
|
2974
|
+
state.failedAttemptCount = undefined;
|
|
2975
|
+
state.failedClass = undefined;
|
|
2976
|
+
state.failedSummary = undefined;
|
|
2977
|
+
state.terminalFailureAtMs = undefined;
|
|
2978
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
2979
|
+
state.lastCompletedUpdateId = undefined;
|
|
2980
|
+
state.lastCompletedAtMs = undefined;
|
|
2981
|
+
state.lastFailureAtMs = undefined;
|
|
2982
|
+
state.lastFailurePhase = undefined;
|
|
2983
|
+
notifyStateChange();
|
|
2984
|
+
launchDrain();
|
|
2985
|
+
},
|
|
2986
|
+
signal() {
|
|
2987
|
+
if (!owner || owner.controller.signal.aborted) return;
|
|
2988
|
+
blocked = false;
|
|
2989
|
+
pendingSignal = true;
|
|
2990
|
+
launchDrain();
|
|
2991
|
+
},
|
|
2992
|
+
settleDeferred(input) {
|
|
2993
|
+
const expectedOwner = owner;
|
|
2994
|
+
if (
|
|
2995
|
+
!expectedOwner ||
|
|
2996
|
+
expectedOwner.controller.signal !== input.signal ||
|
|
2997
|
+
input.signal.aborted
|
|
2998
|
+
) {
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
const authorityResult = checkAuthority(expectedOwner, input.updateId);
|
|
3002
|
+
if (authorityResult) {
|
|
3003
|
+
if (authorityResult === "blocked") {
|
|
3004
|
+
releaseDeferredClaims(
|
|
3005
|
+
input.outcome.kind === "queued"
|
|
3006
|
+
? input.outcome.sourceUpdateIds
|
|
3007
|
+
: [input.updateId],
|
|
3008
|
+
);
|
|
3009
|
+
}
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const claim = claims.get(input.updateId);
|
|
3013
|
+
if (input.outcome.kind === "deferred") {
|
|
3014
|
+
if (claim === "queued") return;
|
|
3015
|
+
if (claim !== "deferred") {
|
|
3016
|
+
blockWithFailure(
|
|
3017
|
+
"invalid-outcome",
|
|
3018
|
+
"late-outcome-unclaimed",
|
|
3019
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3020
|
+
`Telegram update ${input.updateId} reported a late deferred outcome without a live claim.`,
|
|
3021
|
+
),
|
|
3022
|
+
input.updateId,
|
|
3023
|
+
);
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
transition("deferred", input.updateId);
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
let outcome: TelegramUpdateAdmissionOutcome;
|
|
3030
|
+
try {
|
|
3031
|
+
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
3032
|
+
input.outcome,
|
|
3033
|
+
input.updateId,
|
|
3034
|
+
new Set([input.updateId, ...claims.keys()]),
|
|
3035
|
+
);
|
|
3036
|
+
} catch (error) {
|
|
3037
|
+
blockWithFailure(
|
|
3038
|
+
"invalid-outcome",
|
|
3039
|
+
"late-invalid-outcome",
|
|
3040
|
+
error,
|
|
3041
|
+
input.updateId,
|
|
3042
|
+
);
|
|
3043
|
+
releaseDeferredClaims(input.outcome.sourceUpdateIds);
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
if (outcome.kind !== "queued") {
|
|
3047
|
+
blockWithFailure(
|
|
3048
|
+
"invalid-outcome",
|
|
3049
|
+
"late-invalid-outcome",
|
|
3050
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3051
|
+
`Telegram update ${input.updateId} reported a non-queue late outcome.`,
|
|
3052
|
+
),
|
|
3053
|
+
input.updateId,
|
|
3054
|
+
);
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
if (claim !== "deferred" && claim !== "queued") {
|
|
3058
|
+
blockWithFailure(
|
|
3059
|
+
"invalid-outcome",
|
|
3060
|
+
"late-outcome-unclaimed",
|
|
3061
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3062
|
+
`Telegram update ${input.updateId} reported a late queue outcome without a live claim.`,
|
|
3063
|
+
),
|
|
3064
|
+
input.updateId,
|
|
3065
|
+
);
|
|
3066
|
+
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
const result = commitQueuedOutcome(
|
|
3070
|
+
expectedOwner,
|
|
3071
|
+
input.updateId,
|
|
3072
|
+
outcome,
|
|
3073
|
+
);
|
|
3074
|
+
if (result === "blocked") {
|
|
3075
|
+
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
if (result === "aborted") return;
|
|
3079
|
+
transition("queued", input.updateId);
|
|
3080
|
+
},
|
|
3081
|
+
isQueueReceiptCommitted(receipt) {
|
|
3082
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3083
|
+
return (
|
|
3084
|
+
committed !== undefined &&
|
|
3085
|
+
areTelegramQueueAdmissionReceiptsEqual(
|
|
3086
|
+
committed.receipt,
|
|
3087
|
+
normalizeQueueReceipt(receipt),
|
|
3088
|
+
)
|
|
3089
|
+
);
|
|
3090
|
+
},
|
|
3091
|
+
getQueueReceiptOwner(receipt) {
|
|
3092
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3093
|
+
return committed &&
|
|
3094
|
+
areTelegramQueueAdmissionReceiptsEqual(
|
|
3095
|
+
committed.receipt,
|
|
3096
|
+
normalizeQueueReceipt(receipt),
|
|
3097
|
+
)
|
|
3098
|
+
? { ...committed.queueOwner }
|
|
3099
|
+
: undefined;
|
|
3100
|
+
},
|
|
3101
|
+
completeQueueReceipts(input) {
|
|
3102
|
+
const expectedOwner = owner;
|
|
3103
|
+
if (
|
|
3104
|
+
!expectedOwner ||
|
|
3105
|
+
expectedOwner.controller.signal.aborted ||
|
|
3106
|
+
!(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)
|
|
3107
|
+
) {
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
if (input.receipts.length === 0) return;
|
|
3111
|
+
const normalizedReceipts = input.receipts.map(normalizeQueueReceipt);
|
|
3112
|
+
const receiptIds = new Set<string>();
|
|
3113
|
+
const sourceUpdateIds = new Set<number>();
|
|
3114
|
+
const queuedCompletions: Array<{
|
|
3115
|
+
queueKind: "prompt" | "control";
|
|
3116
|
+
receiptId: string;
|
|
3117
|
+
sourceUpdateIds: readonly number[];
|
|
3118
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
3119
|
+
}> = [];
|
|
3120
|
+
for (const receipt of normalizedReceipts) {
|
|
3121
|
+
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
3122
|
+
if (!committed) continue;
|
|
3123
|
+
if (
|
|
3124
|
+
receiptIds.has(receipt.receiptId) ||
|
|
3125
|
+
!areTelegramQueueAdmissionReceiptsEqual(
|
|
3126
|
+
committed.receipt,
|
|
3127
|
+
receipt,
|
|
3128
|
+
) ||
|
|
3129
|
+
!isTelegramUpdateJournalQueueOwnerProcess(
|
|
3130
|
+
committed.queueOwner,
|
|
3131
|
+
expectedOwner.queueOwnerIdentity,
|
|
3132
|
+
) ||
|
|
3133
|
+
receipt.sourceUpdateIds.some((updateId) =>
|
|
3134
|
+
sourceUpdateIds.has(updateId),
|
|
3135
|
+
)
|
|
3136
|
+
) {
|
|
3137
|
+
blockWithFailure(
|
|
3138
|
+
"invalid-outcome",
|
|
3139
|
+
"queue-receipt-completion-invalid",
|
|
3140
|
+
new TelegramUpdateAdmissionOutcomeError(
|
|
3141
|
+
`Telegram ${input.reason} requested invalid queue receipt ${receipt.receiptId}.`,
|
|
3142
|
+
),
|
|
3143
|
+
);
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
receiptIds.add(receipt.receiptId);
|
|
3147
|
+
queuedCompletions.push({
|
|
3148
|
+
...receipt,
|
|
3149
|
+
queueOwner: { ...committed.queueOwner },
|
|
3150
|
+
});
|
|
3151
|
+
for (const updateId of receipt.sourceUpdateIds) {
|
|
3152
|
+
sourceUpdateIds.add(updateId);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
if (receiptIds.size === 0) return;
|
|
3156
|
+
let removedUpdateIds: readonly number[];
|
|
3157
|
+
try {
|
|
3158
|
+
removedUpdateIds = deps.journal.completeQueued(
|
|
3159
|
+
queuedCompletions,
|
|
3160
|
+
).removedUpdateIds;
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
blockWithFailure(
|
|
3163
|
+
"journal-write",
|
|
3164
|
+
"queue-receipt-completion",
|
|
3165
|
+
error,
|
|
3166
|
+
);
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
const removed = new Set(removedUpdateIds);
|
|
3170
|
+
if (
|
|
3171
|
+
removed.size !== sourceUpdateIds.size ||
|
|
3172
|
+
[...sourceUpdateIds].some((updateId) => !removed.has(updateId))
|
|
3173
|
+
) {
|
|
3174
|
+
blockWithFailure(
|
|
3175
|
+
"journal-write",
|
|
3176
|
+
"queue-receipt-completion",
|
|
3177
|
+
new Error(
|
|
3178
|
+
`Telegram ${input.reason} did not complete every receipt source.`,
|
|
3179
|
+
),
|
|
3180
|
+
);
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
for (const updateId of sourceUpdateIds) claims.delete(updateId);
|
|
3184
|
+
for (const receiptId of receiptIds) {
|
|
3185
|
+
committedQueueReceipts.delete(receiptId);
|
|
3186
|
+
}
|
|
3187
|
+
const completedUpdateIds = [...sourceUpdateIds];
|
|
3188
|
+
state.lastCompletedUpdateId = Math.max(
|
|
3189
|
+
state.lastCompletedUpdateId ?? -1,
|
|
3190
|
+
...completedUpdateIds,
|
|
3191
|
+
);
|
|
3192
|
+
state.lastCompletedAtMs = getNowMs();
|
|
3193
|
+
state.journalEntryCount = Math.max(
|
|
3194
|
+
0,
|
|
3195
|
+
state.journalEntryCount - completedUpdateIds.length,
|
|
3196
|
+
);
|
|
3197
|
+
updateClaimCounts();
|
|
3198
|
+
notifyStateChange();
|
|
3199
|
+
pendingSignal = true;
|
|
3200
|
+
launchDrain();
|
|
3201
|
+
},
|
|
3202
|
+
async stop() {
|
|
3203
|
+
const expectedOwner = owner;
|
|
3204
|
+
if (!expectedOwner) return;
|
|
3205
|
+
pendingSignal = false;
|
|
3206
|
+
clearRetryTimer();
|
|
3207
|
+
expectedOwner.controller.abort();
|
|
3208
|
+
await drainPromise?.catch(() => undefined);
|
|
3209
|
+
if (owner !== expectedOwner) return;
|
|
3210
|
+
owner = undefined;
|
|
3211
|
+
drainPromise = undefined;
|
|
3212
|
+
claims.clear();
|
|
3213
|
+
committedQueueReceipts.clear();
|
|
3214
|
+
blocked = false;
|
|
3215
|
+
state.phase = "stopped";
|
|
3216
|
+
state.phaseStartedAtMs = getNowMs();
|
|
3217
|
+
state.currentUpdateId = undefined;
|
|
3218
|
+
state.blockedReason = undefined;
|
|
3219
|
+
state.deferredClaimCount = 0;
|
|
3220
|
+
state.queuedClaimCount = 0;
|
|
3221
|
+
state.foreignQueuedCount = 0;
|
|
3222
|
+
delete state.foreignQueuedOwner;
|
|
3223
|
+
state.retryWaitCount = 0;
|
|
3224
|
+
state.failedCount = 0;
|
|
3225
|
+
state.nextRetryUpdateId = undefined;
|
|
3226
|
+
state.nextRetryAtMs = undefined;
|
|
3227
|
+
state.nextRetryAttemptCount = undefined;
|
|
3228
|
+
state.nextRetryFailureClass = undefined;
|
|
3229
|
+
state.failedUpdateId = undefined;
|
|
3230
|
+
state.failedFailureId = undefined;
|
|
3231
|
+
state.failedAttemptCount = undefined;
|
|
3232
|
+
state.failedClass = undefined;
|
|
3233
|
+
state.failedSummary = undefined;
|
|
3234
|
+
state.terminalFailureAtMs = undefined;
|
|
3235
|
+
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
3236
|
+
notifyStateChange();
|
|
3237
|
+
},
|
|
3238
|
+
waitForDrain() {
|
|
3239
|
+
return drainPromise ?? Promise.resolve();
|
|
3240
|
+
},
|
|
3241
|
+
getState() {
|
|
3242
|
+
return getTelegramUpdateWorkerStateSnapshot(state);
|
|
3243
|
+
},
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
// --- Public update handler registry ---
|
|
3248
|
+
|
|
3249
|
+
/**
|
|
3250
|
+
* Verdict returned by a public Telegram update handler.
|
|
3251
|
+
*
|
|
3252
|
+
* - `"consume"` — the handler processed this update; pi-telegram skips default routing.
|
|
3253
|
+
* - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
|
|
3254
|
+
*/
|
|
3255
|
+
export type TelegramUpdateHandlerVerdict = "consume" | "pass";
|
|
3256
|
+
|
|
3257
|
+
export interface TelegramUpdateExecutionFence {
|
|
3258
|
+
readonly generation: number;
|
|
3259
|
+
readonly updateId: number;
|
|
3260
|
+
readonly signal: AbortSignal;
|
|
3261
|
+
isCurrent: () => boolean;
|
|
3262
|
+
assertCurrent: () => void;
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
const TELEGRAM_UPDATE_EXECUTION_FENCE = Symbol(
|
|
3266
|
+
"pi-telegram.update-execution-fence",
|
|
3267
|
+
);
|
|
3268
|
+
|
|
3269
|
+
type TelegramExecutionFencedUpdate = {
|
|
3270
|
+
[TELEGRAM_UPDATE_EXECUTION_FENCE]?: TelegramUpdateExecutionFence;
|
|
3271
|
+
};
|
|
3272
|
+
|
|
3273
|
+
export function getTelegramUpdateExecutionFence(
|
|
3274
|
+
update: unknown,
|
|
3275
|
+
): TelegramUpdateExecutionFence | undefined {
|
|
3276
|
+
if (!update || typeof update !== "object") return undefined;
|
|
3277
|
+
return (update as TelegramExecutionFencedUpdate)[
|
|
3278
|
+
TELEGRAM_UPDATE_EXECUTION_FENCE
|
|
3279
|
+
];
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
function bindTelegramUpdateExecutionFenceCarrier<TValue>(
|
|
3283
|
+
value: TValue | undefined,
|
|
3284
|
+
execution: TelegramUpdateExecutionFence,
|
|
3285
|
+
): TValue | undefined {
|
|
3286
|
+
if (!value || typeof value !== "object") return value;
|
|
3287
|
+
Object.defineProperty(value, TELEGRAM_UPDATE_EXECUTION_FENCE, {
|
|
3288
|
+
configurable: true,
|
|
3289
|
+
enumerable: false,
|
|
3290
|
+
value: execution,
|
|
3291
|
+
});
|
|
3292
|
+
return value;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
function bindTelegramUpdateExecutionFence<
|
|
3296
|
+
TUpdate extends TelegramUpdateFlow & object,
|
|
3297
|
+
>(
|
|
3298
|
+
update: TUpdate,
|
|
3299
|
+
execution: TelegramUpdateExecutionFence,
|
|
3300
|
+
): TUpdate {
|
|
3301
|
+
bindTelegramUpdateExecutionFenceCarrier(update, execution);
|
|
3302
|
+
bindTelegramUpdateExecutionFenceCarrier(update.message, execution);
|
|
3303
|
+
bindTelegramUpdateExecutionFenceCarrier(update.edited_message, execution);
|
|
3304
|
+
bindTelegramUpdateExecutionFenceCarrier(update.callback_query, execution);
|
|
3305
|
+
bindTelegramUpdateExecutionFenceCarrier(
|
|
3306
|
+
update.callback_query?.message,
|
|
3307
|
+
execution,
|
|
3308
|
+
);
|
|
3309
|
+
bindTelegramUpdateExecutionFenceCarrier(update.guest_message, execution);
|
|
3310
|
+
bindTelegramUpdateExecutionFenceCarrier(update.message_reaction, execution);
|
|
3311
|
+
return update;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
export function assertTelegramUpdateExecutionCurrent(update: unknown): void {
|
|
3315
|
+
getTelegramUpdateExecutionFence(update)?.assertCurrent();
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
export function createTelegramUpdateExecutionFenceGuard(
|
|
3319
|
+
update: unknown,
|
|
3320
|
+
): () => void {
|
|
3321
|
+
const execution = getTelegramUpdateExecutionFence(update);
|
|
3322
|
+
return (): void => execution?.assertCurrent();
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
export function carryTelegramUpdateExecutionFence<TTarget extends object>(
|
|
3326
|
+
source: unknown,
|
|
3327
|
+
target: TTarget,
|
|
3328
|
+
): TTarget {
|
|
3329
|
+
const execution = getTelegramUpdateExecutionFence(source);
|
|
3330
|
+
return execution
|
|
3331
|
+
? bindTelegramUpdateExecutionFenceCarrier(target, execution)!
|
|
3332
|
+
: target;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
export type TelegramUpdateHandler = (
|
|
3336
|
+
update: unknown,
|
|
3337
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3338
|
+
) =>
|
|
3339
|
+
| TelegramUpdateHandlerVerdict
|
|
3340
|
+
| void
|
|
3341
|
+
| Promise<TelegramUpdateHandlerVerdict | void>;
|
|
3342
|
+
|
|
3343
|
+
export interface TelegramUpdateHandlerRegistry {
|
|
3344
|
+
/** Schema version of this registry shape. */
|
|
3345
|
+
readonly version: 1;
|
|
3346
|
+
/**
|
|
3347
|
+
* Register an update handler. Returns a disposer that removes it.
|
|
3348
|
+
*
|
|
3349
|
+
* Handlers are invoked in registration order on every Telegram update,
|
|
3350
|
+
* before pi-telegram's own routing. The first handler that returns
|
|
3351
|
+
* `"consume"` wins and stops the chain for that update.
|
|
3352
|
+
*/
|
|
3353
|
+
add: (handler: TelegramUpdateHandler) => () => void;
|
|
3354
|
+
/**
|
|
3355
|
+
* Run all registered handlers against an update.
|
|
3356
|
+
*
|
|
3357
|
+
* Used by pi-telegram's polling runtime; extension consumers should call
|
|
3358
|
+
* {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
|
|
3359
|
+
*/
|
|
3360
|
+
dispatch: (
|
|
3361
|
+
update: unknown,
|
|
3362
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3363
|
+
) => Promise<TelegramUpdateHandlerVerdict>;
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
|
|
3367
|
+
|
|
3368
|
+
function isValidV1UpdateHandlerRegistry(
|
|
3369
|
+
candidate: unknown,
|
|
3370
|
+
): candidate is TelegramUpdateHandlerRegistry {
|
|
3371
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
3372
|
+
const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
|
|
3373
|
+
return (
|
|
3374
|
+
r.version === 1 &&
|
|
3375
|
+
typeof r.add === "function" &&
|
|
3376
|
+
typeof r.dispatch === "function"
|
|
3377
|
+
);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
3381
|
+
const g = globalThis as Record<string, unknown>;
|
|
3382
|
+
const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
|
|
3383
|
+
if (isValidV1UpdateHandlerRegistry(existing)) return existing;
|
|
3384
|
+
const handlers = new Set<TelegramUpdateHandler>();
|
|
3385
|
+
const registry: TelegramUpdateHandlerRegistry = {
|
|
3386
|
+
version: 1,
|
|
3387
|
+
add(handler) {
|
|
3388
|
+
handlers.add(handler);
|
|
3389
|
+
return () => handlers.delete(handler);
|
|
3390
|
+
},
|
|
3391
|
+
async dispatch(update, execution) {
|
|
3392
|
+
for (const handler of handlers) {
|
|
3393
|
+
execution?.assertCurrent();
|
|
3394
|
+
try {
|
|
3395
|
+
const result = await handler(update, execution);
|
|
3396
|
+
if (result === "consume") return "consume";
|
|
3397
|
+
} catch {
|
|
3398
|
+
// Update handler errors must not break polling.
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
return "pass";
|
|
3402
|
+
},
|
|
3403
|
+
};
|
|
3404
|
+
g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
|
|
3405
|
+
return registry;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
/**
|
|
3409
|
+
* Called by pi-telegram's own runtime to obtain the registry it dispatches
|
|
3410
|
+
* through. Extension consumers should not call this; use
|
|
3411
|
+
* {@link registerTelegramUpdateHandler} instead.
|
|
3412
|
+
*/
|
|
3413
|
+
export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
|
|
3414
|
+
return getOrCreateUpdateHandlerRegistry();
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
|
|
3418
|
+
defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
|
|
3419
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
/**
|
|
3423
|
+
* Wrap a default polling `handleUpdate` with the public update handler registry.
|
|
3424
|
+
*/
|
|
3425
|
+
export function createTelegramUpdateHandle<TUpdate, TContext>(
|
|
3426
|
+
deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
|
|
3427
|
+
): (update: TUpdate, ctx: TContext) => Promise<void> {
|
|
3428
|
+
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
|
|
3429
|
+
const { defaultHandle } = deps;
|
|
3430
|
+
return async (update, ctx) => {
|
|
3431
|
+
const verdict = await registry.dispatch(update);
|
|
3432
|
+
if (verdict === "consume") return;
|
|
3433
|
+
await defaultHandle(update, ctx);
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
export interface TelegramUpdateAdmissionHandleDeps<
|
|
3438
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
3439
|
+
TContext,
|
|
3440
|
+
> {
|
|
3441
|
+
defaultHandle: (
|
|
3442
|
+
update: TUpdate,
|
|
3443
|
+
ctx: TContext,
|
|
3444
|
+
execution?: TelegramUpdateExecutionFence,
|
|
3445
|
+
) => Promise<void>;
|
|
3446
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
3447
|
+
onLateOutcome?: (
|
|
3448
|
+
outcome: Extract<
|
|
3449
|
+
TelegramUpdateAdmissionOutcome,
|
|
3450
|
+
{ kind: "deferred" | "queued" }
|
|
3451
|
+
>,
|
|
3452
|
+
details: {
|
|
3453
|
+
updateId: number;
|
|
3454
|
+
ctx: TContext;
|
|
3455
|
+
signal: AbortSignal;
|
|
3456
|
+
},
|
|
3457
|
+
) => void | Promise<void>;
|
|
3458
|
+
onLateOutcomeError?: (error: unknown, updateId: number) => void;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
function mergeTelegramReportedAdmissionOutcome(
|
|
3462
|
+
current:
|
|
3463
|
+
| Extract<
|
|
3464
|
+
TelegramUpdateAdmissionOutcome,
|
|
3465
|
+
{ kind: "deferred" | "queued" }
|
|
3466
|
+
>
|
|
3467
|
+
| undefined,
|
|
3468
|
+
next: Extract<
|
|
3469
|
+
TelegramUpdateAdmissionOutcome,
|
|
3470
|
+
{ kind: "deferred" | "queued" }
|
|
3471
|
+
>,
|
|
3472
|
+
updateId: number,
|
|
3473
|
+
): Extract<
|
|
3474
|
+
TelegramUpdateAdmissionOutcome,
|
|
3475
|
+
{ kind: "deferred" | "queued" }
|
|
3476
|
+
> {
|
|
3477
|
+
if (!current || current.kind === "deferred") return next;
|
|
3478
|
+
if (next.kind === "deferred") return current;
|
|
3479
|
+
if (areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
|
|
3480
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3481
|
+
`Telegram update ${updateId} reported conflicting queue outcomes.`,
|
|
3482
|
+
);
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
/**
|
|
3486
|
+
* Compose the stable public handler registry with source-bound semantic
|
|
3487
|
+
* admission. Production polling switches to this only with the journal worker.
|
|
3488
|
+
*/
|
|
3489
|
+
export function createTelegramUpdateAdmissionHandle<
|
|
3490
|
+
TUpdate extends TelegramUpdateFlow & { update_id: number },
|
|
3491
|
+
TContext,
|
|
3492
|
+
>(
|
|
3493
|
+
deps: TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
|
|
3494
|
+
): (
|
|
3495
|
+
update: TUpdate,
|
|
3496
|
+
ctx: TContext,
|
|
3497
|
+
signal: AbortSignal,
|
|
3498
|
+
) => Promise<TelegramUpdateAdmissionOutcome> {
|
|
3499
|
+
if (deps.onLateOutcome && !deps.onLateOutcomeError) {
|
|
3500
|
+
throw new Error(
|
|
3501
|
+
"Telegram late admission outcomes require a diagnostic error sink.",
|
|
3502
|
+
);
|
|
3503
|
+
}
|
|
3504
|
+
const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
|
|
3505
|
+
let nextExecutionGeneration = 0;
|
|
3506
|
+
return async (update, ctx, signal) => {
|
|
3507
|
+
const generation = ++nextExecutionGeneration;
|
|
3508
|
+
const execution: TelegramUpdateExecutionFence = {
|
|
3509
|
+
generation,
|
|
3510
|
+
updateId: update.update_id,
|
|
3511
|
+
signal,
|
|
3512
|
+
isCurrent: () => !signal.aborted,
|
|
3513
|
+
assertCurrent() {
|
|
3514
|
+
if (signal.aborted) {
|
|
3515
|
+
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
3516
|
+
}
|
|
3517
|
+
},
|
|
3518
|
+
};
|
|
3519
|
+
execution.assertCurrent();
|
|
3520
|
+
const verdict = await registry.dispatch(update, execution);
|
|
3521
|
+
execution.assertCurrent();
|
|
3522
|
+
if (verdict === "consume") return { kind: "complete" };
|
|
3523
|
+
let immediate = true;
|
|
3524
|
+
let outcome:
|
|
3525
|
+
| Extract<
|
|
3526
|
+
TelegramUpdateAdmissionOutcome,
|
|
3527
|
+
{ kind: "deferred" | "queued" }
|
|
3528
|
+
>
|
|
3529
|
+
| undefined;
|
|
3530
|
+
const boundUpdate = bindTelegramUpdateExecutionFence(
|
|
3531
|
+
bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
3532
|
+
if (immediate) {
|
|
3533
|
+
outcome = mergeTelegramReportedAdmissionOutcome(
|
|
3534
|
+
outcome,
|
|
3535
|
+
next,
|
|
3536
|
+
update.update_id,
|
|
3537
|
+
);
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
if (!deps.onLateOutcome) {
|
|
3541
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3542
|
+
`Telegram update ${update.update_id} reported a late outcome without an owner.`,
|
|
3543
|
+
);
|
|
3544
|
+
}
|
|
3545
|
+
void Promise.resolve()
|
|
3546
|
+
.then(() =>
|
|
3547
|
+
deps.onLateOutcome!(next, {
|
|
3548
|
+
updateId: update.update_id,
|
|
3549
|
+
ctx,
|
|
3550
|
+
signal,
|
|
3551
|
+
}),
|
|
3552
|
+
)
|
|
3553
|
+
.catch((error) => {
|
|
3554
|
+
try {
|
|
3555
|
+
deps.onLateOutcomeError?.(error, update.update_id);
|
|
3556
|
+
} catch {
|
|
3557
|
+
// Diagnostic sinks must not create an unhandled late Promise.
|
|
3558
|
+
}
|
|
3559
|
+
});
|
|
3560
|
+
}),
|
|
3561
|
+
execution,
|
|
3562
|
+
);
|
|
3563
|
+
try {
|
|
3564
|
+
execution.assertCurrent();
|
|
3565
|
+
await deps.defaultHandle(boundUpdate, ctx, execution);
|
|
3566
|
+
} finally {
|
|
3567
|
+
immediate = false;
|
|
3568
|
+
}
|
|
3569
|
+
return outcome ?? { kind: "complete" };
|
|
3570
|
+
};
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
export interface TelegramQueueAdmissionItemLike {
|
|
3574
|
+
admissionReceipts?: readonly TelegramQueueAdmissionReceiptLike[];
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3578
|
+
isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
|
|
3579
|
+
getQueueReceiptOwner: (
|
|
3580
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
3581
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
3582
|
+
onPromptHandedOff: (
|
|
3583
|
+
item: TelegramQueueAdmissionItemLike,
|
|
3584
|
+
ctx: TContext,
|
|
3585
|
+
) => void;
|
|
3586
|
+
onControlSettled: (
|
|
3587
|
+
item: TelegramQueueAdmissionItemLike,
|
|
3588
|
+
ctx: TContext,
|
|
3589
|
+
) => void;
|
|
3590
|
+
onItemsDiscarded: (
|
|
3591
|
+
items: readonly TelegramQueueAdmissionItemLike[],
|
|
3592
|
+
ctx: TContext,
|
|
3593
|
+
) => void;
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
|
|
3597
|
+
runtimes: readonly TelegramQueueAdmissionSettlementRuntime<TContext>[],
|
|
3598
|
+
): TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3599
|
+
const settle = (
|
|
3600
|
+
operation: (
|
|
3601
|
+
runtime: TelegramQueueAdmissionSettlementRuntime<TContext>,
|
|
3602
|
+
) => void,
|
|
3603
|
+
): void => {
|
|
3604
|
+
for (const runtime of runtimes) operation(runtime);
|
|
3605
|
+
};
|
|
3606
|
+
return {
|
|
3607
|
+
isItemReady: (item) =>
|
|
3608
|
+
(item.admissionReceipts ?? []).every((receipt) =>
|
|
3609
|
+
runtimes.some((runtime) =>
|
|
3610
|
+
runtime.isItemReady({ admissionReceipts: [receipt] }),
|
|
3611
|
+
),
|
|
3612
|
+
),
|
|
3613
|
+
getQueueReceiptOwner(receipt) {
|
|
3614
|
+
let owner: TelegramUpdateJournalQueueOwner | undefined;
|
|
3615
|
+
for (const runtime of runtimes) {
|
|
3616
|
+
const candidate = runtime.getQueueReceiptOwner(receipt);
|
|
3617
|
+
if (!candidate) continue;
|
|
3618
|
+
if (
|
|
3619
|
+
owner &&
|
|
3620
|
+
!areTelegramUpdateJournalQueueOwnersEqual(owner, candidate)
|
|
3621
|
+
) {
|
|
3622
|
+
throw new TelegramUpdateAdmissionOutcomeError(
|
|
3623
|
+
`Telegram queue receipt ${receipt.receiptId} has multiple live owners.`,
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
owner = candidate;
|
|
3627
|
+
}
|
|
3628
|
+
return owner ? { ...owner } : undefined;
|
|
3629
|
+
},
|
|
3630
|
+
onPromptHandedOff: (item, ctx) =>
|
|
3631
|
+
settle((runtime) => runtime.onPromptHandedOff(item, ctx)),
|
|
3632
|
+
onControlSettled: (item, ctx) =>
|
|
3633
|
+
settle((runtime) => runtime.onControlSettled(item, ctx)),
|
|
3634
|
+
onItemsDiscarded: (items, ctx) =>
|
|
3635
|
+
settle((runtime) => runtime.onItemsDiscarded(items, ctx)),
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
3640
|
+
worker: TelegramUpdateWorkerRuntime<TContext>,
|
|
3641
|
+
): TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
3642
|
+
const complete = (
|
|
3643
|
+
items: readonly TelegramQueueAdmissionItemLike[],
|
|
3644
|
+
ctx: TContext,
|
|
3645
|
+
reason: TelegramQueueReceiptCompletionReason,
|
|
3646
|
+
): void => {
|
|
3647
|
+
const receipts: TelegramQueueAdmissionReceiptLike[] = [];
|
|
3648
|
+
for (const item of items) {
|
|
3649
|
+
if (item.admissionReceipts) receipts.push(...item.admissionReceipts);
|
|
3650
|
+
}
|
|
3651
|
+
worker.completeQueueReceipts({ receipts, ctx, reason });
|
|
3652
|
+
};
|
|
3653
|
+
return {
|
|
3654
|
+
isItemReady: (item) =>
|
|
3655
|
+
(item.admissionReceipts ?? []).every(
|
|
3656
|
+
worker.isQueueReceiptCommitted,
|
|
3657
|
+
),
|
|
3658
|
+
getQueueReceiptOwner: worker.getQueueReceiptOwner,
|
|
3659
|
+
onPromptHandedOff: (item, ctx) =>
|
|
3660
|
+
complete([item], ctx, "prompt-handoff"),
|
|
3661
|
+
onControlSettled: (item, ctx) =>
|
|
3662
|
+
complete([item], ctx, "control-settlement"),
|
|
3663
|
+
onItemsDiscarded: (items, ctx) =>
|
|
3664
|
+
complete(items, ctx, "discard"),
|
|
3665
|
+
};
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
export interface TelegramUpdateAdmissionLifecycleJournalBinding {
|
|
3669
|
+
runtimeKey: string;
|
|
3670
|
+
recoveryKey: string;
|
|
3671
|
+
journal: TelegramUpdateWorkerJournalPort & {
|
|
3672
|
+
appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
|
|
3673
|
+
applyOperatorDisposition?: (
|
|
3674
|
+
input: TelegramUpdateJournalOperatorDispositionInput,
|
|
3675
|
+
) => TelegramUpdateJournalOperatorDispositionResult;
|
|
3676
|
+
discardQueued?: (input: {
|
|
3677
|
+
queueKind: "prompt" | "control";
|
|
3678
|
+
receiptId: string;
|
|
3679
|
+
sourceUpdateIds: readonly number[];
|
|
3680
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3681
|
+
}) => TelegramUpdateJournalQueueDiscardResult;
|
|
3682
|
+
offerQueuedHandoff?: (
|
|
3683
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3684
|
+
) => TelegramUpdateJournalQueueHandoffOfferResult;
|
|
3685
|
+
acceptQueuedHandoff?: (
|
|
3686
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3687
|
+
) => TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
3688
|
+
cancelQueuedHandoff?: (
|
|
3689
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
3690
|
+
) => TelegramUpdateJournalQueueHandoffCancelResult;
|
|
3691
|
+
recoverDeadQueueOwner?: (input: {
|
|
3692
|
+
queueKind: "prompt" | "control";
|
|
3693
|
+
receiptId: string;
|
|
3694
|
+
sourceUpdateIds: readonly number[];
|
|
3695
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
3696
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3697
|
+
}) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
|
|
3698
|
+
};
|
|
3699
|
+
hasAuthority?: () => boolean;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
export interface TelegramQueueHandoffControlExecutionDeps<TContext> {
|
|
3703
|
+
isContextCurrent: (ctx: TContext) => boolean;
|
|
3704
|
+
showStatus: (
|
|
3705
|
+
chatId: number,
|
|
3706
|
+
replyToMessageId: number,
|
|
3707
|
+
ctx: TContext,
|
|
3708
|
+
threadId?: number,
|
|
3709
|
+
) => Promise<void>;
|
|
3710
|
+
openModelMenu: (
|
|
3711
|
+
chatId: number,
|
|
3712
|
+
replyToMessageId: number,
|
|
3713
|
+
ctx: TContext,
|
|
3714
|
+
threadId?: number,
|
|
3715
|
+
) => Promise<void>;
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
export function createTelegramQueueHandoffControlExecutionFactory<TContext>(
|
|
3719
|
+
deps: TelegramQueueHandoffControlExecutionDeps<TContext>,
|
|
3720
|
+
): (
|
|
3721
|
+
payload: TelegramControlQueueHandoffPayload,
|
|
3722
|
+
) => PendingTelegramControlItem<TContext>["execute"] {
|
|
3723
|
+
return (payload) => async (ctx) => {
|
|
3724
|
+
if (!deps.isContextCurrent(ctx)) return;
|
|
3725
|
+
if (payload.controlType === "status") {
|
|
3726
|
+
await deps.showStatus(
|
|
3727
|
+
payload.chatId,
|
|
3728
|
+
payload.replyToMessageId,
|
|
3729
|
+
ctx,
|
|
3730
|
+
payload.target?.threadId,
|
|
3731
|
+
);
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
await deps.openModelMenu(
|
|
3735
|
+
payload.chatId,
|
|
3736
|
+
payload.replyToMessageId,
|
|
3737
|
+
ctx,
|
|
3738
|
+
payload.target?.threadId,
|
|
3739
|
+
);
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
export interface TelegramQueueHandoffCoordinatorInput<TContext> {
|
|
3744
|
+
item: TelegramQueueItem<TContext>;
|
|
3745
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3746
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3747
|
+
handoffToken: string;
|
|
3748
|
+
stageRemote: (input: {
|
|
3749
|
+
handoffToken: string;
|
|
3750
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
3751
|
+
recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
3752
|
+
payload: TelegramQueueHandoffPayload;
|
|
3753
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
3754
|
+
lifecycle: Pick<
|
|
3755
|
+
TelegramUpdateAdmissionLifecycleRuntime<TContext>,
|
|
3756
|
+
| "offerQueueReceiptHandoff"
|
|
3757
|
+
| "acceptQueueReceiptHandoff"
|
|
3758
|
+
| "cancelQueueReceiptHandoff"
|
|
3759
|
+
>;
|
|
3760
|
+
removeDonorItem: (receipt: TelegramQueueAdmissionReceipt) => boolean;
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
export type TelegramQueueHandoffCoordinatorResult =
|
|
3764
|
+
| {
|
|
3765
|
+
status: "transferred";
|
|
3766
|
+
receipt: TelegramQueueAdmissionReceipt;
|
|
3767
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
3768
|
+
}
|
|
3769
|
+
| {
|
|
3770
|
+
status: "retained";
|
|
3771
|
+
receipt: TelegramQueueAdmissionReceipt;
|
|
3772
|
+
error: unknown;
|
|
3773
|
+
cancelled: boolean;
|
|
3774
|
+
};
|
|
3775
|
+
|
|
3776
|
+
function assertTelegramQueueHandoffStageMatches(
|
|
3777
|
+
stage: TelegramQueueHandoffStageResult,
|
|
3778
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3779
|
+
): void {
|
|
3780
|
+
if (
|
|
3781
|
+
stage.status !== "staged" ||
|
|
3782
|
+
stage.receiptId !== receipt.receiptId ||
|
|
3783
|
+
stage.sourceUpdateIds.length !== receipt.sourceUpdateIds.length ||
|
|
3784
|
+
stage.sourceUpdateIds.some(
|
|
3785
|
+
(updateId, index) => updateId !== receipt.sourceUpdateIds[index],
|
|
3786
|
+
)
|
|
3787
|
+
) {
|
|
3788
|
+
throw new Error(
|
|
3789
|
+
"Telegram queue handoff staging returned a mismatched receipt.",
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
export async function coordinateTelegramQueueHandoff<TContext>(
|
|
3795
|
+
input: TelegramQueueHandoffCoordinatorInput<TContext>,
|
|
3796
|
+
): Promise<TelegramQueueHandoffCoordinatorResult> {
|
|
3797
|
+
const handoff = createTelegramQueueHandoff({
|
|
3798
|
+
handoffToken: input.handoffToken,
|
|
3799
|
+
item: input.item,
|
|
3800
|
+
});
|
|
3801
|
+
const receipt = handoff.payload.admissionReceipts[0];
|
|
3802
|
+
if (!receipt || handoff.payload.admissionReceipts.length !== 1) {
|
|
3803
|
+
throw new Error(
|
|
3804
|
+
"Telegram queue handoff requires exactly one complete receipt.",
|
|
3805
|
+
);
|
|
3806
|
+
}
|
|
3807
|
+
const handoffInput: TelegramUpdateJournalQueueHandoffInput = {
|
|
3808
|
+
queueKind: receipt.queueKind,
|
|
3809
|
+
receiptId: receipt.receiptId,
|
|
3810
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
3811
|
+
expectedOwner: input.expectedOwner,
|
|
3812
|
+
recipientOwner: input.recipientOwner,
|
|
3813
|
+
handoffToken: input.handoffToken,
|
|
3814
|
+
};
|
|
3815
|
+
input.lifecycle.offerQueueReceiptHandoff(handoffInput);
|
|
3816
|
+
let stage: TelegramQueueHandoffStageResult;
|
|
3817
|
+
try {
|
|
3818
|
+
stage = await input.stageRemote({
|
|
3819
|
+
handoffToken: input.handoffToken,
|
|
3820
|
+
expectedOwner: input.expectedOwner,
|
|
3821
|
+
recipientOwner: input.recipientOwner,
|
|
3822
|
+
payload: handoff.payload,
|
|
3823
|
+
});
|
|
3824
|
+
assertTelegramQueueHandoffStageMatches(stage, receipt);
|
|
3825
|
+
} catch (error) {
|
|
3826
|
+
let cancelled = false;
|
|
3827
|
+
try {
|
|
3828
|
+
input.lifecycle.cancelQueueReceiptHandoff(handoffInput);
|
|
3829
|
+
cancelled = true;
|
|
3830
|
+
} catch {
|
|
3831
|
+
return {
|
|
3832
|
+
status: "retained",
|
|
3833
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3834
|
+
error,
|
|
3835
|
+
cancelled: false,
|
|
3836
|
+
};
|
|
3837
|
+
}
|
|
3838
|
+
return {
|
|
3839
|
+
status: "retained",
|
|
3840
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3841
|
+
error,
|
|
3842
|
+
cancelled,
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3845
|
+
let donorRemoved = false;
|
|
3846
|
+
try {
|
|
3847
|
+
donorRemoved = input.removeDonorItem(receipt);
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
throw new Error(
|
|
3850
|
+
`Telegram queue handoff donor removal failed after acceptance: ${error instanceof Error ? error.message : String(error)}`,
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
if (!donorRemoved) {
|
|
3854
|
+
throw new Error(
|
|
3855
|
+
`Telegram queue handoff donor item ${receipt.receiptId} disappeared after acceptance.`,
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3858
|
+
return {
|
|
3859
|
+
status: "transferred",
|
|
3860
|
+
receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
|
|
3861
|
+
queueOwner: { ...stage.queueOwner },
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
export interface TelegramQueueHandoffReconciliationBinding<TContext> {
|
|
3866
|
+
request: (ctx: TContext) => void;
|
|
3867
|
+
set: (reconcile: (ctx: TContext) => Promise<void>) => void;
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
export function createTelegramQueueHandoffReconciliationBinding<TContext>(
|
|
3871
|
+
recordFailure?: (error: unknown) => void,
|
|
3872
|
+
): TelegramQueueHandoffReconciliationBinding<TContext> {
|
|
3873
|
+
let reconcile: ((ctx: TContext) => Promise<void>) | undefined;
|
|
3874
|
+
return {
|
|
3875
|
+
request(ctx) {
|
|
3876
|
+
void reconcile?.(ctx).catch((error) => recordFailure?.(error));
|
|
3877
|
+
},
|
|
3878
|
+
set(next) {
|
|
3879
|
+
reconcile = next;
|
|
3880
|
+
},
|
|
3881
|
+
};
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
export interface TelegramQueueHandoffRecipientRuntimeDeps<TContext> {
|
|
3885
|
+
staging: TelegramQueueHandoffStagingRuntime;
|
|
3886
|
+
getRecipientOwner: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
3887
|
+
getLifecycleForBinding: (
|
|
3888
|
+
journalBindingKey: string,
|
|
3889
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
3890
|
+
isTransportStampActive?: (
|
|
3891
|
+
stamp: TelegramQueueHandoffPayload["transportStamp"],
|
|
3892
|
+
) => boolean;
|
|
3893
|
+
dispatchNext: (ctx: TContext) => void;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
export function createTelegramQueueHandoffRecipientRuntime<TContext>(
|
|
3897
|
+
deps: TelegramQueueHandoffRecipientRuntimeDeps<TContext>,
|
|
3898
|
+
): (
|
|
3899
|
+
envelope: Extract<
|
|
3900
|
+
TelegramBusEnvelope,
|
|
3901
|
+
{ kind: "leader.offerQueueHandoff" }
|
|
3902
|
+
>,
|
|
3903
|
+
ctx: TContext,
|
|
3904
|
+
) => Promise<TelegramQueueHandoffStageResult> {
|
|
3905
|
+
return async (envelope, ctx) => {
|
|
3906
|
+
const stage = deps.staging.stage(envelope.payload);
|
|
3907
|
+
const receipt = envelope.payload.admissionReceipts[0];
|
|
3908
|
+
if (!receipt || envelope.payload.admissionReceipts.length !== 1) {
|
|
3909
|
+
deps.staging.cancel(
|
|
3910
|
+
receipt ?? {
|
|
3911
|
+
queueKind: envelope.payload.kind,
|
|
3912
|
+
receiptId: stage.receiptId,
|
|
3913
|
+
sourceUpdateIds: stage.sourceUpdateIds,
|
|
3914
|
+
},
|
|
3915
|
+
);
|
|
3916
|
+
throw new Error(
|
|
3917
|
+
"Telegram queue handoff requires exactly one complete receipt.",
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
const journalBindingKey = receipt.journalBindingKey;
|
|
3921
|
+
if (!journalBindingKey) {
|
|
3922
|
+
deps.staging.cancel(receipt);
|
|
3923
|
+
throw new Error(
|
|
3924
|
+
"Telegram queue handoff receipt omitted its journal binding.",
|
|
3925
|
+
);
|
|
3926
|
+
}
|
|
3927
|
+
if (
|
|
3928
|
+
deps.isTransportStampActive &&
|
|
3929
|
+
!deps.isTransportStampActive(envelope.payload.transportStamp)
|
|
3930
|
+
) {
|
|
3931
|
+
deps.staging.cancel(receipt);
|
|
3932
|
+
throw new Error(
|
|
3933
|
+
"Telegram queue handoff payload belongs to an inactive transport generation.",
|
|
3934
|
+
);
|
|
3935
|
+
}
|
|
3936
|
+
const lifecycle = deps.getLifecycleForBinding(journalBindingKey);
|
|
3937
|
+
if (!lifecycle) {
|
|
3938
|
+
deps.staging.cancel(receipt);
|
|
3939
|
+
throw new Error(
|
|
3940
|
+
"Telegram queue handoff journal binding is not active.",
|
|
3941
|
+
);
|
|
3942
|
+
}
|
|
3943
|
+
const donorOwner: TelegramUpdateJournalQueueOwner = {
|
|
3944
|
+
instanceId: envelope.donorInstanceId,
|
|
3945
|
+
processId: envelope.donorProcessId,
|
|
3946
|
+
processBirthId: envelope.donorProcessBirthId,
|
|
3947
|
+
sessionGeneration: envelope.donorSessionGeneration,
|
|
3948
|
+
acquisitionId: envelope.donorAcquisitionId,
|
|
3949
|
+
acquiredAtMs: envelope.donorAcquiredAtMs,
|
|
3950
|
+
};
|
|
3951
|
+
let accepted: TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
3952
|
+
try {
|
|
3953
|
+
accepted = lifecycle.acceptQueueReceiptHandoff({
|
|
3954
|
+
queueKind: receipt.queueKind,
|
|
3955
|
+
receiptId: receipt.receiptId,
|
|
3956
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
3957
|
+
expectedOwner: donorOwner,
|
|
3958
|
+
recipientOwner: deps.getRecipientOwner(),
|
|
3959
|
+
handoffToken: envelope.handoffToken,
|
|
3960
|
+
});
|
|
3961
|
+
await lifecycle.publishAcceptedQueueReceipt({
|
|
3962
|
+
receipt,
|
|
3963
|
+
queueOwner: accepted.queueOwner,
|
|
3964
|
+
ctx,
|
|
3965
|
+
});
|
|
3966
|
+
if (!deps.staging.accept(receipt)) {
|
|
3967
|
+
throw new Error(
|
|
3968
|
+
"Telegram queue handoff payload disappeared before readiness publication.",
|
|
3969
|
+
);
|
|
3970
|
+
}
|
|
3971
|
+
} catch (error) {
|
|
3972
|
+
deps.staging.cancel(receipt);
|
|
3973
|
+
throw error;
|
|
3974
|
+
}
|
|
3975
|
+
deps.dispatchNext(ctx);
|
|
3976
|
+
return { ...stage, queueOwner: { ...accepted.queueOwner } };
|
|
3977
|
+
};
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
export interface TelegramQueueHandoffReconcilerDeps<TContext> {
|
|
3981
|
+
ownsDirect: () => boolean;
|
|
3982
|
+
isFollowerRegistered: () => boolean;
|
|
3983
|
+
isBusEnabled: () => boolean;
|
|
3984
|
+
canHandoffWithLeader?: () => boolean;
|
|
3985
|
+
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
3986
|
+
createRecipientJournalBindingKey: (
|
|
3987
|
+
recipient: TelegramBusFollowerView,
|
|
3988
|
+
) => string | undefined;
|
|
3989
|
+
getQueuedItems: () => readonly TelegramQueueItem<TContext>[];
|
|
3990
|
+
getReceiptOwner: (
|
|
3991
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3992
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
3993
|
+
getLifecycleForReceipt: (
|
|
3994
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
3995
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
3996
|
+
createHandoffToken: () => string;
|
|
3997
|
+
createRequestId: () => string;
|
|
3998
|
+
donorInstanceId: string;
|
|
3999
|
+
authSecret?: string;
|
|
4000
|
+
stageThroughFollower: (input: {
|
|
4001
|
+
recipient: TelegramBusFollowerView;
|
|
4002
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
4003
|
+
handoffToken: string;
|
|
4004
|
+
payload: TelegramQueueHandoffPayload;
|
|
4005
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
4006
|
+
routeThroughLeader: (input: {
|
|
4007
|
+
requestId: string;
|
|
4008
|
+
auth?: string;
|
|
4009
|
+
recipientInstanceId: string;
|
|
4010
|
+
recipientRegistrationGeneration: string;
|
|
4011
|
+
donorInstanceId: string;
|
|
4012
|
+
donorProcessId: number;
|
|
4013
|
+
donorProcessBirthId: string;
|
|
4014
|
+
donorSessionGeneration: number;
|
|
4015
|
+
donorAcquisitionId: string;
|
|
4016
|
+
donorAcquiredAtMs: number;
|
|
4017
|
+
handoffToken: string;
|
|
4018
|
+
payload: TelegramQueueHandoffPayload;
|
|
4019
|
+
sentAtMs: number;
|
|
4020
|
+
}) => Promise<TelegramBusEnvelope>;
|
|
4021
|
+
removeDonorItem: (
|
|
4022
|
+
receipt: TelegramQueueAdmissionReceipt,
|
|
4023
|
+
ctx: TContext,
|
|
4024
|
+
) => boolean;
|
|
4025
|
+
recordFailure?: (
|
|
4026
|
+
error: unknown,
|
|
4027
|
+
details: Record<string, unknown>,
|
|
4028
|
+
) => void;
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
|
|
4032
|
+
TContext,
|
|
4033
|
+
> {
|
|
4034
|
+
ownsDirect: () => boolean;
|
|
4035
|
+
isFollowerRegistered: () => boolean;
|
|
4036
|
+
isBusEnabled: () => boolean;
|
|
4037
|
+
canHandoffWithLeader?: () => boolean;
|
|
4038
|
+
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
4039
|
+
createRecipientJournalResolver: (
|
|
4040
|
+
profileKey: string,
|
|
4041
|
+
) => (() => { recoveryKey: string } | undefined);
|
|
4042
|
+
queueStore: {
|
|
4043
|
+
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
4044
|
+
setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
|
|
4045
|
+
};
|
|
4046
|
+
admission: Pick<
|
|
4047
|
+
TelegramUpdateAdmissionRuntimeBinding<TContext>,
|
|
4048
|
+
"getSettlement" | "getLifecycleForJournalBinding"
|
|
4049
|
+
>;
|
|
4050
|
+
createHandoffToken: () => string;
|
|
4051
|
+
createRequestId: () => string;
|
|
4052
|
+
donorInstanceId: string;
|
|
4053
|
+
authSecret?: string;
|
|
4054
|
+
stageThroughFollower: (input: {
|
|
4055
|
+
recipientInstanceId: string;
|
|
4056
|
+
recipientRegistrationGeneration: string;
|
|
4057
|
+
donorProcessId: number;
|
|
4058
|
+
donorProcessBirthId: string;
|
|
4059
|
+
donorSessionGeneration: number;
|
|
4060
|
+
donorAcquisitionId: string;
|
|
4061
|
+
donorAcquiredAtMs: number;
|
|
4062
|
+
handoffToken: string;
|
|
4063
|
+
payload: TelegramQueueHandoffPayload;
|
|
4064
|
+
}) => Promise<TelegramQueueHandoffStageResult>;
|
|
4065
|
+
routeThroughLeader: TelegramQueueHandoffReconcilerDeps<TContext>["routeThroughLeader"];
|
|
4066
|
+
recordRuntimeEvent?: (
|
|
4067
|
+
category: string,
|
|
4068
|
+
error: unknown,
|
|
4069
|
+
details?: Record<string, unknown>,
|
|
4070
|
+
) => void;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
/** Own queue-handoff projections over journals, admission, IPC, and live queue state. */
|
|
4074
|
+
export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
|
|
4075
|
+
TContext,
|
|
4076
|
+
>(
|
|
4077
|
+
deps: TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<TContext>,
|
|
4078
|
+
): (ctx: TContext) => Promise<void> {
|
|
4079
|
+
return createTelegramQueueHandoffReconciler({
|
|
4080
|
+
ownsDirect: deps.ownsDirect,
|
|
4081
|
+
isFollowerRegistered: deps.isFollowerRegistered,
|
|
4082
|
+
isBusEnabled: deps.isBusEnabled,
|
|
4083
|
+
canHandoffWithLeader: deps.canHandoffWithLeader,
|
|
4084
|
+
listFollowers: deps.listFollowers,
|
|
4085
|
+
createRecipientJournalBindingKey(recipient) {
|
|
4086
|
+
if (!recipient.profileKey) return undefined;
|
|
4087
|
+
return deps.createRecipientJournalResolver(recipient.profileKey)()
|
|
4088
|
+
?.recoveryKey;
|
|
4089
|
+
},
|
|
4090
|
+
getQueuedItems: deps.queueStore.getQueuedItems,
|
|
4091
|
+
getReceiptOwner(receipt) {
|
|
4092
|
+
return deps.admission.getSettlement()?.getQueueReceiptOwner(receipt);
|
|
4093
|
+
},
|
|
4094
|
+
getLifecycleForReceipt(receipt) {
|
|
4095
|
+
const bindingKey = receipt.journalBindingKey;
|
|
4096
|
+
return bindingKey
|
|
4097
|
+
? deps.admission.getLifecycleForJournalBinding(bindingKey)
|
|
4098
|
+
: undefined;
|
|
4099
|
+
},
|
|
4100
|
+
createHandoffToken: deps.createHandoffToken,
|
|
4101
|
+
createRequestId: deps.createRequestId,
|
|
4102
|
+
donorInstanceId: deps.donorInstanceId,
|
|
4103
|
+
authSecret: deps.authSecret,
|
|
4104
|
+
stageThroughFollower(input) {
|
|
4105
|
+
const registrationGeneration = input.recipient.registrationGeneration;
|
|
4106
|
+
if (!registrationGeneration) {
|
|
4107
|
+
throw new Error(
|
|
4108
|
+
"Telegram queue handoff recipient registration generation is unavailable.",
|
|
4109
|
+
);
|
|
4110
|
+
}
|
|
4111
|
+
return deps.stageThroughFollower({
|
|
4112
|
+
recipientInstanceId: input.recipient.instanceId,
|
|
4113
|
+
recipientRegistrationGeneration: registrationGeneration,
|
|
4114
|
+
donorProcessId: input.expectedOwner.processId,
|
|
4115
|
+
donorProcessBirthId: input.expectedOwner.processBirthId,
|
|
4116
|
+
donorSessionGeneration: input.expectedOwner.sessionGeneration,
|
|
4117
|
+
donorAcquisitionId: input.expectedOwner.acquisitionId,
|
|
4118
|
+
donorAcquiredAtMs: input.expectedOwner.acquiredAtMs,
|
|
4119
|
+
handoffToken: input.handoffToken,
|
|
4120
|
+
payload: input.payload,
|
|
4121
|
+
});
|
|
4122
|
+
},
|
|
4123
|
+
routeThroughLeader: deps.routeThroughLeader,
|
|
4124
|
+
removeDonorItem(receipt) {
|
|
4125
|
+
return removeTelegramQueueItemByReceipt({
|
|
4126
|
+
receipt,
|
|
4127
|
+
store: deps.queueStore,
|
|
4128
|
+
});
|
|
4129
|
+
},
|
|
4130
|
+
recordFailure(error, details) {
|
|
4131
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, details);
|
|
4132
|
+
},
|
|
4133
|
+
});
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
export function createTelegramQueueHandoffReconciler<TContext>(
|
|
4137
|
+
deps: TelegramQueueHandoffReconcilerDeps<TContext>,
|
|
4138
|
+
): (ctx: TContext) => Promise<void> {
|
|
4139
|
+
let operation: Promise<void> | undefined;
|
|
4140
|
+
const reconcile = async (ctx: TContext): Promise<void> => {
|
|
4141
|
+
const followerRegistered = deps.isFollowerRegistered();
|
|
4142
|
+
if (
|
|
4143
|
+
(!deps.ownsDirect() && !followerRegistered) ||
|
|
4144
|
+
!deps.isBusEnabled() ||
|
|
4145
|
+
(followerRegistered && deps.canHandoffWithLeader?.() === false)
|
|
4146
|
+
) {
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const followers = deps.listFollowers().filter(
|
|
4150
|
+
(follower) => follower.instanceId !== deps.donorInstanceId,
|
|
4151
|
+
);
|
|
4152
|
+
if (followers.length === 0) return;
|
|
4153
|
+
for (const item of [...deps.getQueuedItems()]) {
|
|
4154
|
+
const target = item.target;
|
|
4155
|
+
const recipient = target
|
|
4156
|
+
? followers.find(
|
|
4157
|
+
(candidate) =>
|
|
4158
|
+
candidate.target?.chatId === target.chatId &&
|
|
4159
|
+
candidate.target?.threadId === target.threadId,
|
|
4160
|
+
)
|
|
4161
|
+
: undefined;
|
|
4162
|
+
if (!recipient) continue;
|
|
4163
|
+
const receipt = item.admissionReceipts?.[0];
|
|
4164
|
+
if (!receipt || item.admissionReceipts?.length !== 1) continue;
|
|
4165
|
+
if (
|
|
4166
|
+
!receipt.journalBindingKey ||
|
|
4167
|
+
!getTelegramUpdateJournalBindingPath(receipt.journalBindingKey)
|
|
4168
|
+
) {
|
|
4169
|
+
continue;
|
|
4170
|
+
}
|
|
4171
|
+
const recipientJournalBindingKey =
|
|
4172
|
+
deps.createRecipientJournalBindingKey(recipient);
|
|
4173
|
+
if (!recipientJournalBindingKey) continue;
|
|
4174
|
+
const recipientItem = structuredClone(item);
|
|
4175
|
+
recipientItem.admissionReceipts = [
|
|
4176
|
+
{ ...receipt, journalBindingKey: recipientJournalBindingKey },
|
|
4177
|
+
];
|
|
4178
|
+
const expectedOwner = deps.getReceiptOwner(receipt);
|
|
4179
|
+
const lifecycle = deps.getLifecycleForReceipt(receipt);
|
|
4180
|
+
if (
|
|
4181
|
+
!expectedOwner ||
|
|
4182
|
+
!lifecycle ||
|
|
4183
|
+
!recipient.registrationGeneration ||
|
|
4184
|
+
!recipient.pid ||
|
|
4185
|
+
!recipient.processBirthId ||
|
|
4186
|
+
!recipient.sessionGeneration
|
|
4187
|
+
) {
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
const handoffToken = deps.createHandoffToken();
|
|
4191
|
+
const result = await coordinateTelegramQueueHandoff({
|
|
4192
|
+
item,
|
|
4193
|
+
expectedOwner,
|
|
4194
|
+
recipientOwner: {
|
|
4195
|
+
instanceId: recipient.instanceId,
|
|
4196
|
+
processId: recipient.pid,
|
|
4197
|
+
processBirthId: recipient.processBirthId,
|
|
4198
|
+
sessionGeneration: recipient.sessionGeneration,
|
|
4199
|
+
},
|
|
4200
|
+
handoffToken,
|
|
4201
|
+
lifecycle,
|
|
4202
|
+
stageRemote: async () => {
|
|
4203
|
+
const payload = createTelegramQueueHandoff({
|
|
4204
|
+
handoffToken,
|
|
4205
|
+
item: recipientItem,
|
|
4206
|
+
}).payload;
|
|
4207
|
+
if (followerRegistered) {
|
|
4208
|
+
return deps.stageThroughFollower({
|
|
4209
|
+
recipient,
|
|
4210
|
+
expectedOwner,
|
|
4211
|
+
handoffToken,
|
|
4212
|
+
payload,
|
|
4213
|
+
});
|
|
4214
|
+
}
|
|
4215
|
+
const response = await deps.routeThroughLeader({
|
|
4216
|
+
requestId: deps.createRequestId(),
|
|
4217
|
+
auth: deps.authSecret,
|
|
4218
|
+
recipientInstanceId: recipient.instanceId,
|
|
4219
|
+
recipientRegistrationGeneration:
|
|
4220
|
+
recipient.registrationGeneration!,
|
|
4221
|
+
donorInstanceId: deps.donorInstanceId,
|
|
4222
|
+
donorProcessId: expectedOwner.processId,
|
|
4223
|
+
donorProcessBirthId: expectedOwner.processBirthId,
|
|
4224
|
+
donorSessionGeneration: expectedOwner.sessionGeneration,
|
|
4225
|
+
donorAcquisitionId: expectedOwner.acquisitionId,
|
|
4226
|
+
donorAcquiredAtMs: expectedOwner.acquiredAtMs,
|
|
4227
|
+
handoffToken,
|
|
4228
|
+
payload,
|
|
4229
|
+
sentAtMs: Date.now(),
|
|
4230
|
+
});
|
|
4231
|
+
const queueOwner =
|
|
4232
|
+
response.kind === "bus.ack" &&
|
|
4233
|
+
response.result &&
|
|
4234
|
+
typeof response.result === "object"
|
|
4235
|
+
? parseTelegramUpdateJournalQueueOwner(
|
|
4236
|
+
(response.result as Record<string, unknown>).queueOwner,
|
|
4237
|
+
)
|
|
4238
|
+
: undefined;
|
|
4239
|
+
if (
|
|
4240
|
+
response.kind !== "bus.ack" ||
|
|
4241
|
+
!response.ok ||
|
|
4242
|
+
!response.result ||
|
|
4243
|
+
typeof response.result !== "object" ||
|
|
4244
|
+
!queueOwner
|
|
4245
|
+
) {
|
|
4246
|
+
throw new Error(
|
|
4247
|
+
response.kind === "bus.ack"
|
|
4248
|
+
? response.message ?? "Telegram queue handoff was rejected."
|
|
4249
|
+
: "Telegram queue handoff returned no acknowledgement.",
|
|
4250
|
+
);
|
|
4251
|
+
}
|
|
4252
|
+
return {
|
|
4253
|
+
...(response.result as Omit<TelegramQueueHandoffStageResult, "queueOwner">),
|
|
4254
|
+
queueOwner,
|
|
4255
|
+
};
|
|
4256
|
+
},
|
|
4257
|
+
removeDonorItem: (exactReceipt) =>
|
|
4258
|
+
deps.removeDonorItem(exactReceipt, ctx),
|
|
4259
|
+
});
|
|
4260
|
+
if (result.status === "retained") {
|
|
4261
|
+
deps.recordFailure?.(result.error, {
|
|
4262
|
+
phase: "queue-handoff-retained",
|
|
4263
|
+
receiptId: result.receipt.receiptId,
|
|
4264
|
+
recipientInstanceId: recipient.instanceId,
|
|
4265
|
+
cancelled: result.cancelled,
|
|
4266
|
+
});
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
};
|
|
4270
|
+
return (ctx) => {
|
|
4271
|
+
if (operation) return operation;
|
|
4272
|
+
const current = reconcile(ctx).finally(() => {
|
|
4273
|
+
if (operation === current) operation = undefined;
|
|
4274
|
+
});
|
|
4275
|
+
operation = current;
|
|
4276
|
+
return current;
|
|
4277
|
+
};
|
|
4278
|
+
}
|
|
4279
|
+
|
|
4280
|
+
export interface TelegramQueueMutationDependencyItem {
|
|
4281
|
+
chatId: number;
|
|
4282
|
+
target?: { chatId: number };
|
|
4283
|
+
replyToMessageId: number;
|
|
4284
|
+
sourceMessageIds?: readonly number[];
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
export interface TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext> {
|
|
4288
|
+
resolveBinding: () =>
|
|
4289
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4290
|
+
| undefined;
|
|
4291
|
+
getQueueOwnerIdentity?: (
|
|
4292
|
+
ctx: TContext,
|
|
4293
|
+
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
4294
|
+
createWorker: (
|
|
4295
|
+
journal: TelegramUpdateWorkerJournalPort,
|
|
4296
|
+
binding: TelegramUpdateAdmissionLifecycleJournalBinding,
|
|
4297
|
+
) => TelegramUpdateWorkerRuntime<TContext>;
|
|
4298
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4301
|
+
export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
|
|
4302
|
+
extends TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
4303
|
+
onSessionStart: (ctx: TContext) => Promise<void>;
|
|
4304
|
+
onSessionShutdown: () => Promise<void>;
|
|
4305
|
+
onTransportChanged: (ctx?: TContext) => Promise<void>;
|
|
4306
|
+
appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
|
|
4307
|
+
discardQueueReceipt: (input: {
|
|
4308
|
+
queueKind: "prompt" | "control";
|
|
4309
|
+
receiptId: string;
|
|
4310
|
+
sourceUpdateIds: readonly number[];
|
|
4311
|
+
expectedOwner: TelegramUpdateJournalQueueOwner;
|
|
4312
|
+
}) => TelegramUpdateJournalQueueDiscardResult;
|
|
4313
|
+
recoverDeadQueueReceipt: (input: {
|
|
4314
|
+
queueKind: "prompt" | "control";
|
|
4315
|
+
receiptId: string;
|
|
4316
|
+
sourceUpdateIds: readonly number[];
|
|
4317
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
4318
|
+
recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
|
|
4319
|
+
}) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
|
|
4320
|
+
offerQueueReceiptHandoff: (
|
|
4321
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4322
|
+
) => TelegramUpdateJournalQueueHandoffOfferResult;
|
|
4323
|
+
acceptQueueReceiptHandoff: (
|
|
4324
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4325
|
+
) => TelegramUpdateJournalQueueHandoffAcceptResult;
|
|
4326
|
+
cancelQueueReceiptHandoff: (
|
|
4327
|
+
input: TelegramUpdateJournalQueueHandoffInput,
|
|
4328
|
+
) => TelegramUpdateJournalQueueHandoffCancelResult;
|
|
4329
|
+
publishAcceptedQueueReceipt: (input: {
|
|
4330
|
+
receipt: TelegramQueueAdmissionReceiptLike;
|
|
4331
|
+
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
4332
|
+
ctx: TContext;
|
|
4333
|
+
}) => Promise<void>;
|
|
4334
|
+
getQueueReceiptOwner: (
|
|
4335
|
+
receipt: TelegramQueueAdmissionReceiptLike,
|
|
4336
|
+
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
4337
|
+
getJournalBindingKey: () => string | undefined;
|
|
4338
|
+
getJournalPath: () => string | undefined;
|
|
4339
|
+
ownsJournalBinding: (journalBindingKey: string) => boolean;
|
|
4340
|
+
getJournalEntryCount: () => number;
|
|
4341
|
+
getForeignQueueOwnerLiveness: () => TelegramProcessLiveness | undefined;
|
|
4342
|
+
hasPendingQueueMutationForItem: (
|
|
4343
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4344
|
+
) => boolean;
|
|
4345
|
+
signal: () => void;
|
|
4346
|
+
getState: () => TelegramUpdateWorkerStateSnapshot | undefined;
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
export interface TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
4350
|
+
getQueueOwnerIdentity: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
4351
|
+
onQueueReceiptCommitted: (receipt: unknown, ctx: TContext) => void;
|
|
4352
|
+
onUpdateCompleted: (updateId: number, ctx: TContext) => void;
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
4356
|
+
instanceId: string;
|
|
4357
|
+
processId: number;
|
|
4358
|
+
processBirthId: string;
|
|
4359
|
+
getSessionGeneration: () => number;
|
|
4360
|
+
isContextCurrent: (ctx: TContext) => boolean;
|
|
4361
|
+
dispatchNext: (ctx: TContext) => void;
|
|
4362
|
+
requestQueueHandoffReconciliation: (ctx: TContext) => void;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
4366
|
+
deps: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>,
|
|
4367
|
+
): TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
4368
|
+
return {
|
|
4369
|
+
getQueueOwnerIdentity() {
|
|
4370
|
+
return {
|
|
4371
|
+
instanceId: deps.instanceId,
|
|
4372
|
+
processId: deps.processId,
|
|
4373
|
+
processBirthId: deps.processBirthId,
|
|
4374
|
+
sessionGeneration: deps.getSessionGeneration(),
|
|
4375
|
+
};
|
|
4376
|
+
},
|
|
4377
|
+
onQueueReceiptCommitted(_receipt, ctx) {
|
|
4378
|
+
if (!deps.isContextCurrent(ctx)) return;
|
|
4379
|
+
deps.dispatchNext(ctx);
|
|
4380
|
+
deps.requestQueueHandoffReconciliation(ctx);
|
|
4381
|
+
},
|
|
4382
|
+
onUpdateCompleted(_updateId, ctx) {
|
|
4383
|
+
if (deps.isContextCurrent(ctx)) deps.dispatchNext(ctx);
|
|
4384
|
+
},
|
|
4385
|
+
};
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
export interface TelegramUpdateAdmissionRuntimeBinding<TContext> {
|
|
4389
|
+
bind: (input: {
|
|
4390
|
+
leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4391
|
+
follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4392
|
+
}) => void;
|
|
4393
|
+
getLeader: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4394
|
+
getFollower: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4395
|
+
getActive: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4396
|
+
getSettlement: () => TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4397
|
+
getLifecycleForJournalBinding: (
|
|
4398
|
+
journalBindingKey: string,
|
|
4399
|
+
) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4400
|
+
hasPendingQueueMutationForItem: (
|
|
4401
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4402
|
+
) => boolean;
|
|
4403
|
+
onSessionShutdown: () => Promise<void>;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
4407
|
+
isFollowerRegistered: () => boolean;
|
|
4408
|
+
}): TelegramUpdateAdmissionRuntimeBinding<TContext> {
|
|
4409
|
+
let leader: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4410
|
+
let follower: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
4411
|
+
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4412
|
+
return {
|
|
4413
|
+
bind(input) {
|
|
4414
|
+
leader = input.leader;
|
|
4415
|
+
follower = input.follower;
|
|
4416
|
+
settlement = createTelegramQueueAdmissionSettlementMuxRuntime([
|
|
4417
|
+
leader,
|
|
4418
|
+
follower,
|
|
4419
|
+
]);
|
|
4420
|
+
},
|
|
4421
|
+
getLeader: () => leader,
|
|
4422
|
+
getFollower: () => follower,
|
|
4423
|
+
getActive: () => (deps.isFollowerRegistered() ? follower : leader),
|
|
4424
|
+
getSettlement: () => settlement,
|
|
4425
|
+
getLifecycleForJournalBinding(journalBindingKey) {
|
|
4426
|
+
if (follower?.ownsJournalBinding(journalBindingKey)) return follower;
|
|
4427
|
+
return leader?.ownsJournalBinding(journalBindingKey) ? leader : undefined;
|
|
4428
|
+
},
|
|
4429
|
+
hasPendingQueueMutationForItem(item) {
|
|
4430
|
+
return Boolean(
|
|
4431
|
+
leader?.hasPendingQueueMutationForItem(item) ||
|
|
4432
|
+
follower?.hasPendingQueueMutationForItem(item),
|
|
4433
|
+
);
|
|
4434
|
+
},
|
|
4435
|
+
async onSessionShutdown() {
|
|
4436
|
+
await Promise.all([
|
|
4437
|
+
leader?.onSessionShutdown(),
|
|
4438
|
+
follower?.onSessionShutdown(),
|
|
4439
|
+
]);
|
|
4440
|
+
},
|
|
4441
|
+
};
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
function isTelegramReactionDependencyForQueueItem(
|
|
4445
|
+
update: TelegramJournaledUpdate,
|
|
4446
|
+
item: TelegramQueueMutationDependencyItem,
|
|
4447
|
+
): boolean {
|
|
4448
|
+
const reaction = update.message_reaction;
|
|
4449
|
+
if (!isTelegramUpdateAdmissionRecord(reaction)) return false;
|
|
4450
|
+
const chat = reaction.chat;
|
|
4451
|
+
if (
|
|
4452
|
+
!isTelegramUpdateAdmissionRecord(chat) ||
|
|
4453
|
+
!Number.isSafeInteger(chat.id) ||
|
|
4454
|
+
!Number.isSafeInteger(reaction.message_id)
|
|
4455
|
+
) {
|
|
4456
|
+
return false;
|
|
4457
|
+
}
|
|
4458
|
+
const itemMessageIds = new Set([
|
|
4459
|
+
item.replyToMessageId,
|
|
4460
|
+
...(item.sourceMessageIds ?? []),
|
|
4461
|
+
]);
|
|
4462
|
+
return (
|
|
4463
|
+
chat.id === (item.target?.chatId ?? item.chatId) &&
|
|
4464
|
+
itemMessageIds.has(reaction.message_id as number)
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
/** Own one worker per active transport identity without assuming queued-owner death. */
|
|
4469
|
+
export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
|
|
4470
|
+
deps: TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext>,
|
|
4471
|
+
): TelegramUpdateAdmissionLifecycleRuntime<TContext> {
|
|
4472
|
+
let activeRuntimeKey: string | undefined;
|
|
4473
|
+
let journal: TelegramUpdateAdmissionLifecycleJournalBinding["journal"] | undefined;
|
|
4474
|
+
let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
|
|
4475
|
+
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
4476
|
+
let journalBindingKey: string | undefined;
|
|
4477
|
+
let operation: Promise<void> = Promise.resolve();
|
|
4478
|
+
let foreignQueueOwnerLiveness: TelegramProcessLiveness | undefined;
|
|
4479
|
+
|
|
4480
|
+
const stopCurrent = async (forget: boolean): Promise<void> => {
|
|
4481
|
+
await worker?.stop();
|
|
4482
|
+
if (!forget) return;
|
|
4483
|
+
journal = undefined;
|
|
4484
|
+
worker = undefined;
|
|
4485
|
+
settlement = undefined;
|
|
4486
|
+
journalBindingKey = undefined;
|
|
4487
|
+
activeRuntimeKey = undefined;
|
|
4488
|
+
foreignQueueOwnerLiveness = undefined;
|
|
4489
|
+
};
|
|
4490
|
+
const bind = async (ctx: TContext): Promise<void> => {
|
|
4491
|
+
const binding = deps.resolveBinding();
|
|
4492
|
+
if (!binding) {
|
|
4493
|
+
await stopCurrent(true);
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
if (!worker || activeRuntimeKey !== binding.runtimeKey) {
|
|
4497
|
+
await stopCurrent(true);
|
|
4498
|
+
journal = binding.journal;
|
|
4499
|
+
worker = deps.createWorker(binding.journal, binding);
|
|
4500
|
+
settlement = createTelegramQueueAdmissionSettlementRuntime(worker);
|
|
4501
|
+
journalBindingKey = binding.recoveryKey;
|
|
4502
|
+
activeRuntimeKey = binding.runtimeKey;
|
|
4503
|
+
}
|
|
4504
|
+
if (binding.journal.applyOperatorDisposition) {
|
|
4505
|
+
for (const entry of binding.journal.read().entries) {
|
|
4506
|
+
if (entry.state !== "failed" || !entry.terminalFailureId) continue;
|
|
4507
|
+
const result = binding.journal.applyOperatorDisposition({
|
|
4508
|
+
action: "retry",
|
|
4509
|
+
updateId: entry.updateId,
|
|
4510
|
+
failureId: entry.terminalFailureId,
|
|
4511
|
+
});
|
|
4512
|
+
if (result.duplicate) continue;
|
|
4513
|
+
try {
|
|
4514
|
+
deps.recordRuntimeEvent?.(
|
|
4515
|
+
"inbound-worker",
|
|
4516
|
+
"Resumed legacy terminal update under automatic retry policy.",
|
|
4517
|
+
{
|
|
4518
|
+
phase: "automatic-terminal-retry",
|
|
4519
|
+
updateId: entry.updateId,
|
|
4520
|
+
attemptCount: result.disposition.attemptCount,
|
|
4521
|
+
},
|
|
4522
|
+
);
|
|
4523
|
+
} catch {
|
|
4524
|
+
// Diagnostics cannot revoke the committed retry.
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
if (deps.getQueueOwnerIdentity && binding.journal.recoverDeadQueueOwner) {
|
|
4529
|
+
const recoveryOwner = deps.getQueueOwnerIdentity(ctx);
|
|
4530
|
+
const foreignReceipts = new Map<
|
|
4531
|
+
string,
|
|
4532
|
+
{
|
|
4533
|
+
queueKind: "prompt" | "control";
|
|
4534
|
+
sourceUpdateIds: number[];
|
|
4535
|
+
deadOwner: TelegramUpdateJournalQueueOwner;
|
|
4536
|
+
}
|
|
4537
|
+
>();
|
|
4538
|
+
for (const entry of binding.journal.read().entries) {
|
|
4539
|
+
if (
|
|
4540
|
+
entry.state !== "queued" ||
|
|
4541
|
+
!entry.queueKind ||
|
|
4542
|
+
!entry.queueReceiptId ||
|
|
4543
|
+
!entry.queueOwner ||
|
|
4544
|
+
isTelegramUpdateJournalQueueOwnerProcess(
|
|
4545
|
+
entry.queueOwner,
|
|
4546
|
+
recoveryOwner,
|
|
4547
|
+
)
|
|
4548
|
+
) {
|
|
4549
|
+
continue;
|
|
4550
|
+
}
|
|
4551
|
+
const receipt = foreignReceipts.get(entry.queueReceiptId);
|
|
4552
|
+
if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
|
|
4553
|
+
else {
|
|
4554
|
+
foreignReceipts.set(entry.queueReceiptId, {
|
|
4555
|
+
queueKind: entry.queueKind,
|
|
4556
|
+
sourceUpdateIds: [entry.updateId],
|
|
4557
|
+
deadOwner: { ...entry.queueOwner },
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
for (const [receiptId, receipt] of foreignReceipts) {
|
|
4562
|
+
const result = binding.journal.recoverDeadQueueOwner({
|
|
4563
|
+
queueKind: receipt.queueKind,
|
|
4564
|
+
receiptId,
|
|
4565
|
+
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
4566
|
+
deadOwner: receipt.deadOwner,
|
|
4567
|
+
recoveryOwner,
|
|
4568
|
+
});
|
|
4569
|
+
foreignQueueOwnerLiveness =
|
|
4570
|
+
result.status === "recovered"
|
|
4571
|
+
? "dead"
|
|
4572
|
+
: result.status === "owner-alive"
|
|
4573
|
+
? "alive"
|
|
4574
|
+
: "unverifiable";
|
|
4575
|
+
if (result.status !== "recovered") continue;
|
|
4576
|
+
try {
|
|
4577
|
+
deps.recordRuntimeEvent?.(
|
|
4578
|
+
"inbound-worker",
|
|
4579
|
+
"Recovered queued authority from a confirmed-dead process.",
|
|
4580
|
+
{
|
|
4581
|
+
phase: "dead-queue-owner-recovery",
|
|
4582
|
+
receiptId,
|
|
4583
|
+
recoveredUpdateCount: result.recoveredUpdateIds.length,
|
|
4584
|
+
},
|
|
4585
|
+
);
|
|
4586
|
+
} catch {
|
|
4587
|
+
// Diagnostics cannot revoke the committed recovery.
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
worker.start(ctx);
|
|
4592
|
+
};
|
|
4593
|
+
const runExclusive = (task: () => Promise<void>): Promise<void> => {
|
|
4594
|
+
const next = operation.then(task, task);
|
|
4595
|
+
operation = next.catch(() => undefined);
|
|
4596
|
+
return next;
|
|
4597
|
+
};
|
|
4598
|
+
return {
|
|
4599
|
+
onSessionStart: (ctx) => runExclusive(() => bind(ctx)),
|
|
4600
|
+
onSessionShutdown: () => runExclusive(() => stopCurrent(false)),
|
|
4601
|
+
onTransportChanged: (ctx) =>
|
|
4602
|
+
runExclusive(async () => {
|
|
4603
|
+
await stopCurrent(true);
|
|
4604
|
+
if (ctx !== undefined) await bind(ctx);
|
|
4605
|
+
}),
|
|
4606
|
+
appendBatch(updates) {
|
|
4607
|
+
if (!journal || !worker) {
|
|
4608
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4609
|
+
}
|
|
4610
|
+
return journal.appendBatch(updates);
|
|
4611
|
+
},
|
|
4612
|
+
discardQueueReceipt(input) {
|
|
4613
|
+
if (!journal || !worker || !journal.discardQueued) {
|
|
4614
|
+
throw new Error(
|
|
4615
|
+
"Telegram update journal queue discard is not available.",
|
|
4616
|
+
);
|
|
4617
|
+
}
|
|
4618
|
+
const result = journal.discardQueued(input);
|
|
4619
|
+
worker.signal();
|
|
4620
|
+
return result;
|
|
4621
|
+
},
|
|
4622
|
+
recoverDeadQueueReceipt(input) {
|
|
4623
|
+
if (!journal || !worker || !journal.recoverDeadQueueOwner) {
|
|
4624
|
+
throw new Error(
|
|
4625
|
+
"Telegram update journal dead-owner recovery is not available.",
|
|
4626
|
+
);
|
|
4627
|
+
}
|
|
4628
|
+
const result = journal.recoverDeadQueueOwner(input);
|
|
4629
|
+
worker.signal();
|
|
4630
|
+
return result;
|
|
4631
|
+
},
|
|
4632
|
+
offerQueueReceiptHandoff(input) {
|
|
4633
|
+
if (!journal || !worker || !journal.offerQueuedHandoff) {
|
|
4634
|
+
throw new Error(
|
|
4635
|
+
"Telegram update journal queue handoff offer is not available.",
|
|
4636
|
+
);
|
|
4637
|
+
}
|
|
4638
|
+
const result = journal.offerQueuedHandoff(input);
|
|
4639
|
+
worker.signal();
|
|
4640
|
+
return result;
|
|
4641
|
+
},
|
|
4642
|
+
acceptQueueReceiptHandoff(input) {
|
|
4643
|
+
if (!journal || !worker || !journal.acceptQueuedHandoff) {
|
|
4644
|
+
throw new Error(
|
|
4645
|
+
"Telegram update journal queue handoff acceptance is not available.",
|
|
4646
|
+
);
|
|
4647
|
+
}
|
|
4648
|
+
return journal.acceptQueuedHandoff(input);
|
|
4649
|
+
},
|
|
4650
|
+
cancelQueueReceiptHandoff(input) {
|
|
4651
|
+
if (!journal || !worker || !journal.cancelQueuedHandoff) {
|
|
4652
|
+
throw new Error(
|
|
4653
|
+
"Telegram update journal queue handoff cancellation is not available.",
|
|
4654
|
+
);
|
|
4655
|
+
}
|
|
4656
|
+
const result = journal.cancelQueuedHandoff(input);
|
|
4657
|
+
worker.signal();
|
|
4658
|
+
return result;
|
|
4659
|
+
},
|
|
4660
|
+
async publishAcceptedQueueReceipt(input) {
|
|
4661
|
+
if (!worker || !journal) {
|
|
4662
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4663
|
+
}
|
|
4664
|
+
const entry = journal
|
|
4665
|
+
.read()
|
|
4666
|
+
.entries.find(
|
|
4667
|
+
(candidate) =>
|
|
4668
|
+
candidate.state === "queued" &&
|
|
4669
|
+
candidate.queueReceiptId === input.receipt.receiptId &&
|
|
4670
|
+
candidate.queueOwner?.acquisitionId ===
|
|
4671
|
+
input.queueOwner.acquisitionId,
|
|
4672
|
+
);
|
|
4673
|
+
if (!entry) {
|
|
4674
|
+
throw new Error(
|
|
4675
|
+
`Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this journal.`,
|
|
4676
|
+
);
|
|
4677
|
+
}
|
|
4678
|
+
worker.signal();
|
|
4679
|
+
await worker.waitForDrain();
|
|
4680
|
+
const currentOwner = worker.getQueueReceiptOwner(input.receipt);
|
|
4681
|
+
if (
|
|
4682
|
+
!currentOwner ||
|
|
4683
|
+
!areTelegramUpdateJournalQueueOwnersEqual(
|
|
4684
|
+
currentOwner,
|
|
4685
|
+
input.queueOwner,
|
|
4686
|
+
)
|
|
4687
|
+
) {
|
|
4688
|
+
throw new Error(
|
|
4689
|
+
`Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this runtime.`,
|
|
4690
|
+
);
|
|
4691
|
+
}
|
|
4692
|
+
},
|
|
4693
|
+
getQueueReceiptOwner(receipt) {
|
|
4694
|
+
if (
|
|
4695
|
+
!journalBindingKey ||
|
|
4696
|
+
receipt.journalBindingKey !== journalBindingKey
|
|
4697
|
+
) {
|
|
4698
|
+
return undefined;
|
|
4699
|
+
}
|
|
4700
|
+
return worker?.getQueueReceiptOwner(receipt);
|
|
4701
|
+
},
|
|
4702
|
+
getJournalBindingKey: () => journalBindingKey,
|
|
4703
|
+
getJournalPath: () =>
|
|
4704
|
+
journalBindingKey
|
|
4705
|
+
? getTelegramUpdateJournalBindingPath(journalBindingKey)
|
|
4706
|
+
: undefined,
|
|
4707
|
+
ownsJournalBinding: (candidate) =>
|
|
4708
|
+
journalBindingKey !== undefined && journalBindingKey === candidate,
|
|
4709
|
+
getForeignQueueOwnerLiveness: () => foreignQueueOwnerLiveness,
|
|
4710
|
+
getJournalEntryCount() {
|
|
4711
|
+
if (!journal || !worker) {
|
|
4712
|
+
throw new Error("Telegram update admission worker is not active.");
|
|
4713
|
+
}
|
|
4714
|
+
return journal.read().entries.length;
|
|
4715
|
+
},
|
|
4716
|
+
hasPendingQueueMutationForItem(item) {
|
|
4717
|
+
return Boolean(
|
|
4718
|
+
journal &&
|
|
4719
|
+
worker &&
|
|
4720
|
+
journal
|
|
4721
|
+
.read()
|
|
4722
|
+
.entries.some(
|
|
4723
|
+
(entry) =>
|
|
4724
|
+
entry.state !== "queued" &&
|
|
4725
|
+
isTelegramReactionDependencyForQueueItem(
|
|
4726
|
+
entry.update,
|
|
4727
|
+
item,
|
|
4728
|
+
),
|
|
4729
|
+
),
|
|
4730
|
+
);
|
|
4731
|
+
},
|
|
4732
|
+
signal: () => worker?.signal(),
|
|
4733
|
+
getState: () => {
|
|
4734
|
+
const state = worker?.getState();
|
|
4735
|
+
if (!state) return undefined;
|
|
4736
|
+
return {
|
|
4737
|
+
...state,
|
|
4738
|
+
...(state.foreignQueuedOwner && foreignQueueOwnerLiveness
|
|
4739
|
+
? { foreignQueuedOwnerLiveness: foreignQueueOwnerLiveness }
|
|
4740
|
+
: {}),
|
|
4741
|
+
};
|
|
4742
|
+
},
|
|
4743
|
+
isItemReady: (item) =>
|
|
4744
|
+
settlement?.isItemReady(item) ??
|
|
4745
|
+
(item.admissionReceipts?.length ?? 0) === 0,
|
|
4746
|
+
onPromptHandedOff: (item, ctx) =>
|
|
4747
|
+
settlement?.onPromptHandedOff(item, ctx),
|
|
4748
|
+
onControlSettled: (item, ctx) =>
|
|
4749
|
+
settlement?.onControlSettled(item, ctx),
|
|
4750
|
+
onItemsDiscarded: (items, ctx) =>
|
|
4751
|
+
settlement?.onItemsDiscarded(items, ctx),
|
|
4752
|
+
};
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
export interface TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4756
|
+
leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4757
|
+
follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
4761
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4762
|
+
TContext,
|
|
4763
|
+
> {
|
|
4764
|
+
runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
|
|
4765
|
+
worker: Omit<
|
|
4766
|
+
TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
|
|
4767
|
+
| "journal"
|
|
4768
|
+
| "getJournalBindingKey"
|
|
4769
|
+
| "hasAuthority"
|
|
4770
|
+
| "prepareUpdateForExecution"
|
|
4771
|
+
>;
|
|
4772
|
+
leader: {
|
|
4773
|
+
resolveBinding: () =>
|
|
4774
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4775
|
+
| undefined;
|
|
4776
|
+
hasAuthority: (ctx: TContext) => boolean;
|
|
4777
|
+
};
|
|
4778
|
+
follower: {
|
|
4779
|
+
resolveBinding: () =>
|
|
4780
|
+
| TelegramUpdateAdmissionLifecycleJournalBinding
|
|
4781
|
+
| undefined;
|
|
4782
|
+
isRegistered: () => boolean;
|
|
4783
|
+
getGeneration: () => string | undefined;
|
|
4784
|
+
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
4785
|
+
};
|
|
4786
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
export type TelegramUpdateAdmissionWorkerRuntimeDeps<
|
|
4790
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4791
|
+
TContext,
|
|
4792
|
+
> = Omit<TelegramUpdateWorkerRuntimeDeps<TContext>, "executeUpdate"> & {
|
|
4793
|
+
defaultHandle: (
|
|
4794
|
+
update: TUpdate,
|
|
4795
|
+
ctx: TContext,
|
|
4796
|
+
execution?: TelegramUpdateExecutionFence,
|
|
4797
|
+
) => Promise<void>;
|
|
4798
|
+
prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
|
|
4799
|
+
registry?: TelegramUpdateHandlerRegistry;
|
|
4800
|
+
};
|
|
4801
|
+
|
|
4802
|
+
/** Compose source-bound routing and late grouped settlement under one worker. */
|
|
4803
|
+
export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
4804
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4805
|
+
TContext,
|
|
4806
|
+
>(
|
|
4807
|
+
deps: TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
|
|
4808
|
+
): TelegramUpdateWorkerRuntime<TContext> {
|
|
4809
|
+
let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
|
|
4810
|
+
const executeUpdate = createTelegramUpdateAdmissionHandle<TUpdate, TContext>({
|
|
4811
|
+
defaultHandle: deps.defaultHandle,
|
|
4812
|
+
registry: deps.registry,
|
|
4813
|
+
onLateOutcome(outcome, details) {
|
|
4814
|
+
worker?.settleDeferred({
|
|
4815
|
+
updateId: details.updateId,
|
|
4816
|
+
outcome,
|
|
4817
|
+
signal: details.signal,
|
|
4818
|
+
});
|
|
4819
|
+
},
|
|
4820
|
+
onLateOutcomeError(error, updateId) {
|
|
4821
|
+
deps.recordRuntimeEvent?.("inbound-worker", error, {
|
|
4822
|
+
phase: "late-admission-handler",
|
|
4823
|
+
updateId,
|
|
4824
|
+
});
|
|
4825
|
+
},
|
|
4826
|
+
});
|
|
4827
|
+
worker = createTelegramUpdateWorkerRuntime({
|
|
4828
|
+
...deps,
|
|
4829
|
+
executeUpdate: (update, ctx, signal) => {
|
|
4830
|
+
const typedUpdate = update as TUpdate;
|
|
4831
|
+
return executeUpdate(
|
|
4832
|
+
deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
|
|
4833
|
+
ctx,
|
|
4834
|
+
signal,
|
|
4835
|
+
);
|
|
4836
|
+
},
|
|
4837
|
+
});
|
|
4838
|
+
return worker;
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4841
|
+
/** Own leader/follower journal lifecycle construction and generation fencing. */
|
|
4842
|
+
export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
4843
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4844
|
+
TContext,
|
|
4845
|
+
>(
|
|
4846
|
+
deps: TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
|
|
4847
|
+
): TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4848
|
+
const leader = createTelegramUpdateAdmissionLifecycleRuntime({
|
|
4849
|
+
resolveBinding: deps.leader.resolveBinding,
|
|
4850
|
+
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
4851
|
+
createWorker(journal, binding) {
|
|
4852
|
+
return createTelegramUpdateAdmissionWorkerRuntime({
|
|
4853
|
+
...deps.worker,
|
|
4854
|
+
journal,
|
|
4855
|
+
getJournalBindingKey: () => binding.recoveryKey,
|
|
4856
|
+
hasAuthority: deps.leader.hasAuthority,
|
|
4857
|
+
});
|
|
4858
|
+
},
|
|
4859
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4860
|
+
});
|
|
4861
|
+
const follower = createTelegramUpdateAdmissionLifecycleRuntime({
|
|
4862
|
+
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
4863
|
+
resolveBinding() {
|
|
4864
|
+
const generation = deps.follower.getGeneration();
|
|
4865
|
+
if (!deps.follower.isRegistered() || !generation) return undefined;
|
|
4866
|
+
const binding = deps.follower.resolveBinding();
|
|
4867
|
+
if (!binding) return undefined;
|
|
4868
|
+
return {
|
|
4869
|
+
...binding,
|
|
4870
|
+
runtimeKey: `${binding.runtimeKey}\u0000${generation}`,
|
|
4871
|
+
hasAuthority() {
|
|
4872
|
+
return (
|
|
4873
|
+
deps.follower.isRegistered() &&
|
|
4874
|
+
deps.follower.getGeneration() === generation
|
|
4875
|
+
);
|
|
4876
|
+
},
|
|
4877
|
+
};
|
|
4878
|
+
},
|
|
4879
|
+
createWorker(journal, binding) {
|
|
4880
|
+
return createTelegramUpdateAdmissionWorkerRuntime({
|
|
4881
|
+
...deps.worker,
|
|
4882
|
+
journal,
|
|
4883
|
+
getJournalBindingKey: () => binding.recoveryKey,
|
|
4884
|
+
hasAuthority: () => binding.hasAuthority?.() ?? false,
|
|
4885
|
+
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
4886
|
+
});
|
|
4887
|
+
},
|
|
4888
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4889
|
+
});
|
|
4890
|
+
deps.runtimeBinding.bind({ leader, follower });
|
|
4891
|
+
return { leader, follower };
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
export interface TelegramUpdateAdmissionRuntimeAssembly<TContext>
|
|
4895
|
+
extends TelegramUpdateAdmissionLifecycleAssembly<TContext> {
|
|
4896
|
+
owner: TelegramUpdateWorkerOwnerRuntime<TContext>;
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
export type TelegramUpdateAdmissionRuntimeAssemblyDeps<
|
|
4900
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4901
|
+
TContext,
|
|
4902
|
+
> = Omit<
|
|
4903
|
+
TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
|
|
4904
|
+
"worker" | "recordRuntimeEvent"
|
|
4905
|
+
> & {
|
|
4906
|
+
owner: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>;
|
|
4907
|
+
worker: Omit<
|
|
4908
|
+
TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>["worker"],
|
|
4909
|
+
| keyof TelegramUpdateWorkerOwnerRuntime<TContext>
|
|
4910
|
+
| "isContextCurrent"
|
|
4911
|
+
| "recordRuntimeEvent"
|
|
4912
|
+
>;
|
|
4913
|
+
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
4914
|
+
};
|
|
4915
|
+
|
|
4916
|
+
/** Own queue-owner projection and shared leader/follower worker composition. */
|
|
4917
|
+
export function createTelegramUpdateAdmissionRuntimeAssembly<
|
|
4918
|
+
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4919
|
+
TContext,
|
|
4920
|
+
>(
|
|
4921
|
+
deps: TelegramUpdateAdmissionRuntimeAssemblyDeps<TUpdate, TContext>,
|
|
4922
|
+
): TelegramUpdateAdmissionRuntimeAssembly<TContext> {
|
|
4923
|
+
const owner = createTelegramUpdateWorkerOwnerRuntime(deps.owner);
|
|
4924
|
+
const lifecycle = createTelegramUpdateAdmissionLifecycleAssembly({
|
|
4925
|
+
runtimeBinding: deps.runtimeBinding,
|
|
4926
|
+
worker: {
|
|
4927
|
+
...deps.worker,
|
|
4928
|
+
...owner,
|
|
4929
|
+
isContextCurrent: deps.owner.isContextCurrent,
|
|
4930
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4931
|
+
},
|
|
4932
|
+
leader: deps.leader,
|
|
4933
|
+
follower: deps.follower,
|
|
4934
|
+
recordRuntimeEvent: deps.recordRuntimeEvent,
|
|
4935
|
+
});
|
|
4936
|
+
return { owner, ...lifecycle };
|
|
4937
|
+
}
|
|
4938
|
+
|
|
4939
|
+
/**
|
|
4940
|
+
* Register a handler that runs before pi-telegram routes a Telegram update
|
|
4941
|
+
* through its built-in handlers.
|
|
4942
|
+
*
|
|
4943
|
+
* This is the low-level public surface for extensions that share the same bot
|
|
4944
|
+
* and Pi process with pi-telegram.
|
|
4945
|
+
*/
|
|
4946
|
+
export function registerTelegramUpdateHandler(
|
|
4947
|
+
handler: TelegramUpdateHandler,
|
|
4948
|
+
): () => void {
|
|
4949
|
+
return getOrCreateUpdateHandlerRegistry().add(handler);
|
|
4950
|
+
}
|