@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,3183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram thread binding helpers
|
|
3
|
+
* Zones: multi-instance bus, Telegram UI threads, volatile extension state
|
|
4
|
+
* Owns current live instance-binding to Telegram UI thread mappings backed by Bot API ForumTopic/message_thread_id transport
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
chmodSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
chmod,
|
|
17
|
+
mkdir,
|
|
18
|
+
readFile,
|
|
19
|
+
rename,
|
|
20
|
+
unlink,
|
|
21
|
+
writeFile,
|
|
22
|
+
} from "node:fs/promises";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
isTelegramApiCommitUnknownError,
|
|
27
|
+
TelegramApiCommitUnknownError,
|
|
28
|
+
type TelegramApiCallOptions,
|
|
29
|
+
} from "./telegram-api.ts";
|
|
30
|
+
import type { TelegramTarget } from "./target.ts";
|
|
31
|
+
import { withTelegramFileTransaction } from "./locks.ts";
|
|
32
|
+
import * as ThreadReconciler from "./thread-reconciler.ts";
|
|
33
|
+
import {
|
|
34
|
+
resolveAgentDir,
|
|
35
|
+
resolveTelegramProfileTempFilePath,
|
|
36
|
+
} from "./paths.ts";
|
|
37
|
+
|
|
38
|
+
export interface TelegramThreadNameInput {
|
|
39
|
+
seed: string;
|
|
40
|
+
cwd?: string;
|
|
41
|
+
role?: "leader" | "follower";
|
|
42
|
+
peers?: readonly string[];
|
|
43
|
+
slot?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TelegramTopicTargetStatus =
|
|
47
|
+
| "active"
|
|
48
|
+
| "offline"
|
|
49
|
+
| "stale"
|
|
50
|
+
| "pending"
|
|
51
|
+
| "starting"
|
|
52
|
+
| "probe-required"
|
|
53
|
+
| "failed";
|
|
54
|
+
|
|
55
|
+
export type TelegramTopicSyncStatus = "open" | "closed" | "deleted" | "unknown";
|
|
56
|
+
|
|
57
|
+
export type TelegramThreadOwner =
|
|
58
|
+
| {
|
|
59
|
+
kind: "leader";
|
|
60
|
+
cwd?: string;
|
|
61
|
+
instanceId?: string;
|
|
62
|
+
telegramProfile?: string;
|
|
63
|
+
}
|
|
64
|
+
| { kind: "manual-follower"; instanceId: string; telegramProfile?: string }
|
|
65
|
+
| { kind: "pending-topic"; chatId: number; threadId: number }
|
|
66
|
+
| { kind: "legacy"; key: string };
|
|
67
|
+
|
|
68
|
+
const TELEGRAM_THREAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
export interface TelegramThreadReservation {
|
|
71
|
+
target: TelegramTarget & { threadId: number };
|
|
72
|
+
slot: string;
|
|
73
|
+
reason: string;
|
|
74
|
+
createdAtMs: number;
|
|
75
|
+
updatedAtMs: number;
|
|
76
|
+
expiresAtMs?: number;
|
|
77
|
+
instanceId?: string;
|
|
78
|
+
lastReconcileAction?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TelegramThreadPendingProvision {
|
|
82
|
+
id: string;
|
|
83
|
+
owner: "leader" | "manual-follower";
|
|
84
|
+
instanceId: string;
|
|
85
|
+
profileKey?: string;
|
|
86
|
+
status?: "in-flight" | "ambiguous";
|
|
87
|
+
threadName?: string;
|
|
88
|
+
slot?: string;
|
|
89
|
+
target?: TelegramTarget & { threadId: number };
|
|
90
|
+
startedAtMs: number;
|
|
91
|
+
expiresAtMs?: number;
|
|
92
|
+
leaderEpoch?: number | string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type TelegramThreadCleanupIntent =
|
|
96
|
+
ThreadReconciler.TelegramThreadCleanupIntent;
|
|
97
|
+
|
|
98
|
+
type TelegramProvisionRecoveryFile = Record<
|
|
99
|
+
string,
|
|
100
|
+
{
|
|
101
|
+
instanceId: string;
|
|
102
|
+
profileKey?: string;
|
|
103
|
+
leaderEpoch?: number | string;
|
|
104
|
+
target: TelegramTarget & { threadId: number };
|
|
105
|
+
}
|
|
106
|
+
>;
|
|
107
|
+
|
|
108
|
+
export interface TelegramTopicSyncObservation {
|
|
109
|
+
target: TelegramTarget & { threadId: number };
|
|
110
|
+
syncStatus: TelegramTopicSyncStatus;
|
|
111
|
+
observedAtMs: number;
|
|
112
|
+
instanceId?: string;
|
|
113
|
+
slot?: string;
|
|
114
|
+
lastSyncError?: string;
|
|
115
|
+
lastReconcileAction?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TelegramTopicTargetRecord {
|
|
119
|
+
/** Legacy string key derived from `owner`; always present in memory, never persisted. */
|
|
120
|
+
profileKey: string;
|
|
121
|
+
owner?: TelegramThreadOwner;
|
|
122
|
+
target: TelegramTarget & { threadId: number };
|
|
123
|
+
status: TelegramTopicTargetStatus;
|
|
124
|
+
createdAtMs: number;
|
|
125
|
+
updatedAtMs: number;
|
|
126
|
+
threadName?: string;
|
|
127
|
+
instanceId?: string;
|
|
128
|
+
slot?: string;
|
|
129
|
+
lastError?: string;
|
|
130
|
+
syncStatus?: TelegramTopicSyncStatus;
|
|
131
|
+
lastSyncObservedAtMs?: number;
|
|
132
|
+
lastSyncProbeAtMs?: number;
|
|
133
|
+
lastSyncError?: string;
|
|
134
|
+
lastReconcileAction?: string;
|
|
135
|
+
rerouteConfirmedAtMs?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TelegramThreadIdentityRecord {
|
|
139
|
+
profileKey: string;
|
|
140
|
+
threadName?: string;
|
|
141
|
+
slot?: string;
|
|
142
|
+
updatedAtMs: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type TelegramBotThreadMode = "unknown" | "enabled" | "disabled";
|
|
146
|
+
|
|
147
|
+
export interface TelegramBotStateSnapshot {
|
|
148
|
+
threadMode: TelegramBotThreadMode;
|
|
149
|
+
updatedAtMs?: number;
|
|
150
|
+
lastSlot?: string;
|
|
151
|
+
lastReconcileAction?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface TelegramTopicTargetFile {
|
|
155
|
+
version: 1;
|
|
156
|
+
source: "snapshot";
|
|
157
|
+
writtenAtMs: number;
|
|
158
|
+
bot: TelegramBotStateSnapshot;
|
|
159
|
+
runtime?: Record<string, unknown>;
|
|
160
|
+
liveRoster?: Record<string, unknown>;
|
|
161
|
+
diagnostics?: Record<string, unknown>;
|
|
162
|
+
threads: TelegramTopicTargetRecord[];
|
|
163
|
+
identities?: TelegramThreadIdentityRecord[];
|
|
164
|
+
reservations?: TelegramThreadReservation[];
|
|
165
|
+
pendingProvisions?: TelegramThreadPendingProvision[];
|
|
166
|
+
pendingCleanups?: TelegramThreadCleanupIntent[];
|
|
167
|
+
syncObservations?: TelegramTopicSyncObservation[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getNextMonotonicSlot(
|
|
171
|
+
records: Map<string, TelegramTopicTargetRecord>,
|
|
172
|
+
reservations: readonly TelegramThreadReservation[],
|
|
173
|
+
pendingProvisions: readonly TelegramThreadPendingProvision[],
|
|
174
|
+
nowMs: number,
|
|
175
|
+
lastSlot?: string,
|
|
176
|
+
): string | undefined {
|
|
177
|
+
let cursorCode: number | undefined;
|
|
178
|
+
if (lastSlot && /^[A-Z]$/.test(lastSlot)) {
|
|
179
|
+
cursorCode = lastSlot.charCodeAt(0);
|
|
180
|
+
} else {
|
|
181
|
+
cursorCode = "A".charCodeAt(0) - 1;
|
|
182
|
+
for (const record of records.values()) {
|
|
183
|
+
if (!record.slot || !isCurrentThreadRecord(record)) continue;
|
|
184
|
+
cursorCode = Math.max(cursorCode, record.slot.charCodeAt(0));
|
|
185
|
+
}
|
|
186
|
+
for (const reservation of reservations) {
|
|
187
|
+
if (
|
|
188
|
+
reservation.expiresAtMs !== undefined &&
|
|
189
|
+
reservation.expiresAtMs <= nowMs
|
|
190
|
+
)
|
|
191
|
+
continue;
|
|
192
|
+
if (!reservation.slot) continue;
|
|
193
|
+
cursorCode = Math.max(cursorCode, reservation.slot.charCodeAt(0));
|
|
194
|
+
}
|
|
195
|
+
for (const provision of pendingProvisions) {
|
|
196
|
+
if (
|
|
197
|
+
provision.status !== "ambiguous" &&
|
|
198
|
+
provision.expiresAtMs !== undefined &&
|
|
199
|
+
provision.expiresAtMs <= nowMs
|
|
200
|
+
)
|
|
201
|
+
continue;
|
|
202
|
+
if (!provision.slot) continue;
|
|
203
|
+
cursorCode = Math.max(cursorCode, provision.slot.charCodeAt(0));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let code = cursorCode + 1;
|
|
207
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
208
|
+
for (let attempt = 0; attempt < 26; attempt++) {
|
|
209
|
+
const candidate = String.fromCharCode(code);
|
|
210
|
+
if (
|
|
211
|
+
!isTelegramTopicTargetSlotOccupied(
|
|
212
|
+
candidate,
|
|
213
|
+
records,
|
|
214
|
+
reservations,
|
|
215
|
+
pendingProvisions,
|
|
216
|
+
nowMs,
|
|
217
|
+
)
|
|
218
|
+
) {
|
|
219
|
+
return candidate;
|
|
220
|
+
}
|
|
221
|
+
code += 1;
|
|
222
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface TelegramTopicTargetStore {
|
|
228
|
+
load: () => Promise<void>;
|
|
229
|
+
/** Discard process-local projections and reload owner-published state. */
|
|
230
|
+
refresh?: () => Promise<void>;
|
|
231
|
+
persist: () => Promise<void>;
|
|
232
|
+
list: () => TelegramTopicTargetRecord[];
|
|
233
|
+
getFollowerRecoveryHintByTarget?: (
|
|
234
|
+
target: TelegramTarget,
|
|
235
|
+
) => { slot?: string; threadName?: string } | undefined;
|
|
236
|
+
listReservations: () => TelegramThreadReservation[];
|
|
237
|
+
listPendingProvisions: () => TelegramThreadPendingProvision[];
|
|
238
|
+
listPendingCleanups: () => TelegramThreadCleanupIntent[];
|
|
239
|
+
listSyncObservations: () => TelegramTopicSyncObservation[];
|
|
240
|
+
reserveThread: (reservation: TelegramThreadReservation) => void;
|
|
241
|
+
upsertPendingProvision: (provision: TelegramThreadPendingProvision) => void;
|
|
242
|
+
recordPendingProvisionTargetRecovery: (
|
|
243
|
+
provision: TelegramThreadPendingProvision,
|
|
244
|
+
target: TelegramTarget & { threadId: number },
|
|
245
|
+
) => Promise<boolean>;
|
|
246
|
+
removePendingProvision: (id: string) => boolean;
|
|
247
|
+
upsertPendingCleanup: (intent: TelegramThreadCleanupIntent) => void;
|
|
248
|
+
removePendingCleanup: (id: string) => boolean;
|
|
249
|
+
getBotState: () => TelegramBotStateSnapshot;
|
|
250
|
+
setBotState: (state: Partial<TelegramBotStateSnapshot>) => void;
|
|
251
|
+
setStatusSnapshot: (snapshot: {
|
|
252
|
+
runtime?: Record<string, unknown>;
|
|
253
|
+
liveRoster?: Record<string, unknown>;
|
|
254
|
+
diagnostics?: Record<string, unknown>;
|
|
255
|
+
}) => void;
|
|
256
|
+
getByProfileKey: (
|
|
257
|
+
profileKey: string,
|
|
258
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
259
|
+
getActiveByInstanceId: (
|
|
260
|
+
instanceId: string,
|
|
261
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
262
|
+
getIdentityByProfileKey: (
|
|
263
|
+
profileKey: string,
|
|
264
|
+
) => TelegramThreadIdentityRecord | undefined;
|
|
265
|
+
forgetIdentityByProfileKey: (profileKey: string) => boolean;
|
|
266
|
+
upsert: (record: TelegramTopicTargetRecord) => TelegramTopicTargetRecord;
|
|
267
|
+
markOfflineByInstanceId: (instanceId: string) => number;
|
|
268
|
+
markStaleByTarget: (
|
|
269
|
+
target: TelegramTarget,
|
|
270
|
+
syncStatus?: TelegramTopicSyncStatus,
|
|
271
|
+
lastSyncError?: string,
|
|
272
|
+
) => boolean;
|
|
273
|
+
markActiveByTarget: (target: TelegramTarget) => boolean;
|
|
274
|
+
renameByTarget: (
|
|
275
|
+
target: TelegramTarget,
|
|
276
|
+
threadName: string,
|
|
277
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
278
|
+
allocateSlot: (
|
|
279
|
+
profileKey: string,
|
|
280
|
+
preferredSlot?: string,
|
|
281
|
+
) => string | undefined;
|
|
282
|
+
/** Claim the first reusable inactive thread for an instance, linking it to instanceId. */
|
|
283
|
+
claimReusableTarget: (
|
|
284
|
+
instanceId: string,
|
|
285
|
+
threadName?: string,
|
|
286
|
+
) => TelegramTopicTargetRecord | undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function reconcileTelegramFreshAllocationCursor(
|
|
290
|
+
store: Pick<TelegramTopicTargetStore, "getBotState" | "list" | "setBotState">,
|
|
291
|
+
nowMs = Date.now(),
|
|
292
|
+
): boolean {
|
|
293
|
+
const currentCursor = store.getBotState().lastSlot;
|
|
294
|
+
const slottedRecords = store
|
|
295
|
+
.list()
|
|
296
|
+
.filter((record) => !!record.slot && /^[A-Z]$/.test(record.slot));
|
|
297
|
+
if (slottedRecords.some((record) => record.slot === currentCursor)) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
const latestLiveRecord = slottedRecords.reduce<
|
|
301
|
+
TelegramTopicTargetRecord | undefined
|
|
302
|
+
>((latest, record) => {
|
|
303
|
+
if (!latest) return record;
|
|
304
|
+
if (record.createdAtMs !== latest.createdAtMs) {
|
|
305
|
+
return record.createdAtMs > latest.createdAtMs ? record : latest;
|
|
306
|
+
}
|
|
307
|
+
return record.updatedAtMs > latest.updatedAtMs ? record : latest;
|
|
308
|
+
}, undefined);
|
|
309
|
+
const nextCursor = latestLiveRecord?.slot;
|
|
310
|
+
if (nextCursor === currentCursor) return false;
|
|
311
|
+
store.setBotState({
|
|
312
|
+
lastSlot: nextCursor,
|
|
313
|
+
updatedAtMs: nowMs,
|
|
314
|
+
lastReconcileAction: "live-cursor-realignment",
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface TelegramTopicTargetStoreOptions {
|
|
320
|
+
path: string | (() => string);
|
|
321
|
+
getNowMs?: () => number;
|
|
322
|
+
canPersist?: () => boolean;
|
|
323
|
+
commitPersist?: (commit: () => void) => boolean;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface TelegramTopicTargetProvisionerDeps {
|
|
327
|
+
topicChatId: number;
|
|
328
|
+
store: Pick<
|
|
329
|
+
TelegramTopicTargetStore,
|
|
330
|
+
| "list"
|
|
331
|
+
| "getByProfileKey"
|
|
332
|
+
| "getActiveByInstanceId"
|
|
333
|
+
| "getIdentityByProfileKey"
|
|
334
|
+
| "forgetIdentityByProfileKey"
|
|
335
|
+
| "upsert"
|
|
336
|
+
| "markStaleByTarget"
|
|
337
|
+
| "allocateSlot"
|
|
338
|
+
| "claimReusableTarget"
|
|
339
|
+
| "listPendingProvisions"
|
|
340
|
+
| "upsertPendingProvision"
|
|
341
|
+
| "recordPendingProvisionTargetRecovery"
|
|
342
|
+
| "removePendingProvision"
|
|
343
|
+
| "persist"
|
|
344
|
+
>;
|
|
345
|
+
callApi: <TResponse>(
|
|
346
|
+
method: string,
|
|
347
|
+
body: Record<string, unknown>,
|
|
348
|
+
options?: TelegramApiCallOptions,
|
|
349
|
+
) => Promise<TResponse>;
|
|
350
|
+
topicNameTemplate?: string;
|
|
351
|
+
getNowMs?: () => number;
|
|
352
|
+
getRandom?: () => number;
|
|
353
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
354
|
+
claimPendingTargets?: boolean;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface TelegramTopicTargetRenamerDeps {
|
|
358
|
+
store: Pick<TelegramTopicTargetStore, "renameByTarget">;
|
|
359
|
+
callApi: <TResponse>(
|
|
360
|
+
method: string,
|
|
361
|
+
body: Record<string, unknown>,
|
|
362
|
+
) => Promise<TResponse>;
|
|
363
|
+
topicNameTemplate?: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface TelegramTopicTargetProvisionRequest {
|
|
367
|
+
instanceId: string;
|
|
368
|
+
owner?: TelegramThreadOwner;
|
|
369
|
+
/** Legacy string key derived from `owner`; always present in memory. */
|
|
370
|
+
profileKey: string;
|
|
371
|
+
threadName?: string;
|
|
372
|
+
preferredSlot?: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface TelegramTopicTargetRenameRequest {
|
|
376
|
+
target: TelegramTarget & { threadId: number };
|
|
377
|
+
threadName: string;
|
|
378
|
+
slot?: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface TelegramTopicTargetProvisionResult {
|
|
382
|
+
target: TelegramTarget & { threadId: number };
|
|
383
|
+
reused: boolean;
|
|
384
|
+
record: TelegramTopicTargetRecord;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface TelegramTopicResult {
|
|
388
|
+
message_thread_id?: number;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function hashString(value: string): number {
|
|
392
|
+
let hash = 2166136261;
|
|
393
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
394
|
+
hash ^= value.charCodeAt(index);
|
|
395
|
+
hash = Math.imul(hash, 16777619);
|
|
396
|
+
}
|
|
397
|
+
return hash >>> 0;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getWorkspaceHint(cwd: string | undefined): string | undefined {
|
|
401
|
+
if (!cwd) return undefined;
|
|
402
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
403
|
+
const last = parts.at(-1)?.trim();
|
|
404
|
+
if (!last) return undefined;
|
|
405
|
+
return (
|
|
406
|
+
last
|
|
407
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, " ")
|
|
408
|
+
.trim()
|
|
409
|
+
.slice(0, 32) || undefined
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function createTelegramThreadName(
|
|
414
|
+
input: TelegramThreadNameInput,
|
|
415
|
+
): string {
|
|
416
|
+
const workspace = getWorkspaceHint(input.cwd);
|
|
417
|
+
const roleMark =
|
|
418
|
+
input.role === "leader"
|
|
419
|
+
? "Leader"
|
|
420
|
+
: input.role === "follower"
|
|
421
|
+
? "Follower"
|
|
422
|
+
: undefined;
|
|
423
|
+
const slot = input.slot ? `Thread ${input.slot}` : undefined;
|
|
424
|
+
const peerSalt = input.peers?.slice().sort().join("|") ?? "";
|
|
425
|
+
const fallback = `Instance ${hashString(
|
|
426
|
+
`${input.seed}|${input.cwd ?? ""}|${input.role ?? ""}|${peerSalt}|${input.slot ?? ""}`,
|
|
427
|
+
)
|
|
428
|
+
.toString(36)
|
|
429
|
+
.slice(0, 4)}`;
|
|
430
|
+
return (
|
|
431
|
+
[slot, workspace, roleMark].filter(Boolean).join(" ").slice(0, 96) ||
|
|
432
|
+
fallback
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function getTelegramStatePath(
|
|
437
|
+
agentDir = resolveAgentDir(),
|
|
438
|
+
profileName?: string,
|
|
439
|
+
): string {
|
|
440
|
+
return resolveTelegramProfileTempFilePath(
|
|
441
|
+
"state",
|
|
442
|
+
"json",
|
|
443
|
+
agentDir,
|
|
444
|
+
profileName,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function getTelegramTopicTargetsPath(
|
|
449
|
+
agentDir = resolveAgentDir(),
|
|
450
|
+
profileName?: string,
|
|
451
|
+
): string {
|
|
452
|
+
return getTelegramStatePath(agentDir, profileName);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const TELEGRAM_LEADER_SESSION_HANDOFF_KEY = "__piTelegramLeaderSessionHandoff";
|
|
456
|
+
export const TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
457
|
+
|
|
458
|
+
export interface TelegramLeaderSessionHandoff {
|
|
459
|
+
pid: number;
|
|
460
|
+
instanceId: string;
|
|
461
|
+
createdAtMs: number;
|
|
462
|
+
profileKey: string;
|
|
463
|
+
target: TelegramTarget & { threadId: number };
|
|
464
|
+
slot?: string;
|
|
465
|
+
threadName?: string;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function getTelegramLeaderSessionHandoff():
|
|
469
|
+
TelegramLeaderSessionHandoff | undefined {
|
|
470
|
+
const value = (globalThis as Record<string, unknown>)[
|
|
471
|
+
TELEGRAM_LEADER_SESSION_HANDOFF_KEY
|
|
472
|
+
];
|
|
473
|
+
if (!value || typeof value !== "object") return undefined;
|
|
474
|
+
const handoff = value as Partial<TelegramLeaderSessionHandoff>;
|
|
475
|
+
if (
|
|
476
|
+
typeof handoff.pid !== "number" ||
|
|
477
|
+
typeof handoff.instanceId !== "string" ||
|
|
478
|
+
typeof handoff.createdAtMs !== "number" ||
|
|
479
|
+
typeof handoff.profileKey !== "string" ||
|
|
480
|
+
typeof handoff.target?.chatId !== "number" ||
|
|
481
|
+
typeof handoff.target.threadId !== "number"
|
|
482
|
+
) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
return handoff as TelegramLeaderSessionHandoff;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function setTelegramLeaderSessionHandoff(
|
|
489
|
+
handoff: TelegramLeaderSessionHandoff | undefined,
|
|
490
|
+
): void {
|
|
491
|
+
const store = globalThis as Record<string, unknown>;
|
|
492
|
+
if (!handoff) delete store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY];
|
|
493
|
+
else store[TELEGRAM_LEADER_SESSION_HANDOFF_KEY] = handoff;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function isTelegramLeaderSessionHandoffFresh(
|
|
497
|
+
handoff: TelegramLeaderSessionHandoff | undefined,
|
|
498
|
+
options: { pid?: number; nowMs?: number; ttlMs?: number } = {},
|
|
499
|
+
): handoff is TelegramLeaderSessionHandoff {
|
|
500
|
+
if (!handoff) return false;
|
|
501
|
+
const pid = options.pid ?? process.pid;
|
|
502
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
503
|
+
const ttlMs = options.ttlMs ?? TELEGRAM_LEADER_SESSION_HANDOFF_TTL_MS;
|
|
504
|
+
return handoff.pid === pid && nowMs - handoff.createdAtMs <= ttlMs;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function getTelegramThreadOwnerKey(owner: TelegramThreadOwner): string {
|
|
508
|
+
switch (owner.kind) {
|
|
509
|
+
case "leader": {
|
|
510
|
+
const base = owner.cwd
|
|
511
|
+
? `cwd:${owner.cwd}`
|
|
512
|
+
: `leader:${owner.instanceId ?? "default"}`;
|
|
513
|
+
return owner.telegramProfile
|
|
514
|
+
? `profile:${owner.telegramProfile}:${base}`
|
|
515
|
+
: base;
|
|
516
|
+
}
|
|
517
|
+
case "manual-follower":
|
|
518
|
+
return owner.telegramProfile
|
|
519
|
+
? `profile:${owner.telegramProfile}:manual:${owner.instanceId}`
|
|
520
|
+
: `manual:${owner.instanceId}`;
|
|
521
|
+
case "pending-topic":
|
|
522
|
+
return `topic:${owner.chatId}:${owner.threadId}`;
|
|
523
|
+
case "legacy":
|
|
524
|
+
return `legacy:${owner.key}`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function getTelegramThreadOwnerFromProfileKey(
|
|
529
|
+
profileKey: string,
|
|
530
|
+
): TelegramThreadOwner {
|
|
531
|
+
if (profileKey.startsWith("profile:")) {
|
|
532
|
+
const [, telegramProfile, ownerKind, ...rest] = profileKey.split(":");
|
|
533
|
+
const value = rest.join(":");
|
|
534
|
+
if (ownerKind === "cwd")
|
|
535
|
+
return { kind: "leader", cwd: value, telegramProfile };
|
|
536
|
+
if (ownerKind === "leader")
|
|
537
|
+
return { kind: "leader", instanceId: value, telegramProfile };
|
|
538
|
+
if (ownerKind === "manual")
|
|
539
|
+
return { kind: "manual-follower", instanceId: value, telegramProfile };
|
|
540
|
+
}
|
|
541
|
+
if (profileKey.startsWith("cwd:"))
|
|
542
|
+
return { kind: "leader", cwd: profileKey.slice(4) };
|
|
543
|
+
if (profileKey.startsWith("manual:")) {
|
|
544
|
+
return { kind: "manual-follower", instanceId: profileKey.slice(7) };
|
|
545
|
+
}
|
|
546
|
+
if (profileKey.startsWith("topic:")) {
|
|
547
|
+
const [, chatIdText, threadIdText] = profileKey.split(":");
|
|
548
|
+
const chatId = Number(chatIdText);
|
|
549
|
+
const threadId = Number(threadIdText);
|
|
550
|
+
if (Number.isInteger(chatId) && Number.isInteger(threadId)) {
|
|
551
|
+
return { kind: "pending-topic", chatId, threadId };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (profileKey.startsWith("leader:")) {
|
|
555
|
+
return { kind: "leader", instanceId: profileKey.slice(7) };
|
|
556
|
+
}
|
|
557
|
+
return { kind: "legacy", key: profileKey };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function parseThreadOwner(value: unknown): TelegramThreadOwner | undefined {
|
|
561
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
562
|
+
return undefined;
|
|
563
|
+
const record = value as Record<string, unknown>;
|
|
564
|
+
if (record.kind === "leader") {
|
|
565
|
+
return {
|
|
566
|
+
kind: "leader",
|
|
567
|
+
cwd: typeof record.cwd === "string" ? record.cwd : undefined,
|
|
568
|
+
instanceId:
|
|
569
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
if (
|
|
573
|
+
record.kind === "manual-follower" &&
|
|
574
|
+
typeof record.instanceId === "string"
|
|
575
|
+
) {
|
|
576
|
+
return { kind: "manual-follower", instanceId: record.instanceId };
|
|
577
|
+
}
|
|
578
|
+
if (
|
|
579
|
+
record.kind === "pending-topic" &&
|
|
580
|
+
typeof record.chatId === "number" &&
|
|
581
|
+
typeof record.threadId === "number" &&
|
|
582
|
+
Number.isInteger(record.threadId)
|
|
583
|
+
) {
|
|
584
|
+
return {
|
|
585
|
+
kind: "pending-topic",
|
|
586
|
+
chatId: record.chatId,
|
|
587
|
+
threadId: record.threadId,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (record.kind === "legacy" && typeof record.key === "string") {
|
|
591
|
+
return { kind: "legacy", key: record.key };
|
|
592
|
+
}
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getRecordOwner(
|
|
597
|
+
record: TelegramTopicTargetRecord,
|
|
598
|
+
): TelegramThreadOwner {
|
|
599
|
+
return (
|
|
600
|
+
record.owner ?? getTelegramThreadOwnerFromProfileKey(record.profileKey)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function getRecordOwnerKey(record: TelegramTopicTargetRecord): string {
|
|
605
|
+
return getTelegramThreadOwnerKey(getRecordOwner(record));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function cloneRecord(
|
|
609
|
+
record: TelegramTopicTargetRecord,
|
|
610
|
+
): TelegramTopicTargetRecord {
|
|
611
|
+
const owner = getRecordOwner(record);
|
|
612
|
+
return {
|
|
613
|
+
...record,
|
|
614
|
+
owner: { ...owner },
|
|
615
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
616
|
+
target: { ...record.target },
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function getPersistedThreadName(
|
|
621
|
+
record: Record<string, unknown>,
|
|
622
|
+
): string | undefined {
|
|
623
|
+
const value =
|
|
624
|
+
typeof record.threadName === "string"
|
|
625
|
+
? record.threadName
|
|
626
|
+
: typeof record.displayName === "string"
|
|
627
|
+
? record.displayName
|
|
628
|
+
: undefined;
|
|
629
|
+
return value ? normalizeTelegramTopicTargetThreadName(value) : undefined;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function normalizeRecord(
|
|
633
|
+
value: unknown,
|
|
634
|
+
): TelegramTopicTargetRecord | undefined {
|
|
635
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
636
|
+
return undefined;
|
|
637
|
+
const record = value as Record<string, unknown>;
|
|
638
|
+
const target = record.target;
|
|
639
|
+
const owner =
|
|
640
|
+
parseThreadOwner(record.owner) ??
|
|
641
|
+
(typeof record.profileKey === "string" && record.profileKey.length > 0
|
|
642
|
+
? getTelegramThreadOwnerFromProfileKey(record.profileKey)
|
|
643
|
+
: undefined);
|
|
644
|
+
if (!owner) return undefined;
|
|
645
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
646
|
+
return undefined;
|
|
647
|
+
const targetRecord = target as Record<string, unknown>;
|
|
648
|
+
if (
|
|
649
|
+
typeof targetRecord.chatId !== "number" ||
|
|
650
|
+
typeof targetRecord.threadId !== "number" ||
|
|
651
|
+
!Number.isInteger(targetRecord.threadId)
|
|
652
|
+
) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
const status = record.status;
|
|
656
|
+
if (
|
|
657
|
+
status !== "active" &&
|
|
658
|
+
status !== "offline" &&
|
|
659
|
+
status !== "stale" &&
|
|
660
|
+
status !== "pending" &&
|
|
661
|
+
status !== "starting" &&
|
|
662
|
+
status !== "probe-required" &&
|
|
663
|
+
status !== "failed"
|
|
664
|
+
)
|
|
665
|
+
return undefined;
|
|
666
|
+
if (
|
|
667
|
+
typeof record.createdAtMs !== "number" ||
|
|
668
|
+
typeof record.updatedAtMs !== "number"
|
|
669
|
+
)
|
|
670
|
+
return undefined;
|
|
671
|
+
const normalized: TelegramTopicTargetRecord = {
|
|
672
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
673
|
+
owner,
|
|
674
|
+
target: { chatId: targetRecord.chatId, threadId: targetRecord.threadId },
|
|
675
|
+
status,
|
|
676
|
+
createdAtMs: record.createdAtMs,
|
|
677
|
+
updatedAtMs: record.updatedAtMs,
|
|
678
|
+
threadName: getPersistedThreadName(record),
|
|
679
|
+
instanceId:
|
|
680
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
681
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
682
|
+
};
|
|
683
|
+
const syncStatus = record.syncStatus ?? record.twinStatus;
|
|
684
|
+
if (
|
|
685
|
+
syncStatus === "open" ||
|
|
686
|
+
syncStatus === "closed" ||
|
|
687
|
+
syncStatus === "deleted" ||
|
|
688
|
+
syncStatus === "unknown"
|
|
689
|
+
) {
|
|
690
|
+
normalized.syncStatus = syncStatus;
|
|
691
|
+
}
|
|
692
|
+
if (typeof record.lastError === "string")
|
|
693
|
+
normalized.lastError = record.lastError;
|
|
694
|
+
const lastSyncObservedAtMs =
|
|
695
|
+
record.lastSyncObservedAtMs ?? record.lastTwinObservedAtMs;
|
|
696
|
+
if (typeof lastSyncObservedAtMs === "number") {
|
|
697
|
+
normalized.lastSyncObservedAtMs = lastSyncObservedAtMs;
|
|
698
|
+
}
|
|
699
|
+
const lastSyncProbeAtMs =
|
|
700
|
+
record.lastSyncProbeAtMs ?? record.lastTwinProbeAtMs;
|
|
701
|
+
if (typeof lastSyncProbeAtMs === "number") {
|
|
702
|
+
normalized.lastSyncProbeAtMs = lastSyncProbeAtMs;
|
|
703
|
+
}
|
|
704
|
+
const lastSyncError = record.lastSyncError ?? record.lastTwinError;
|
|
705
|
+
if (typeof lastSyncError === "string") {
|
|
706
|
+
normalized.lastSyncError = lastSyncError;
|
|
707
|
+
}
|
|
708
|
+
if (typeof record.lastReconcileAction === "string") {
|
|
709
|
+
normalized.lastReconcileAction = record.lastReconcileAction;
|
|
710
|
+
}
|
|
711
|
+
if (typeof record.rerouteConfirmedAtMs === "number") {
|
|
712
|
+
normalized.rerouteConfirmedAtMs = record.rerouteConfirmedAtMs;
|
|
713
|
+
}
|
|
714
|
+
return normalized;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function isCurrentThreadRecord(record: TelegramTopicTargetRecord): boolean {
|
|
718
|
+
return (
|
|
719
|
+
record.status === "active" ||
|
|
720
|
+
record.status === "starting" ||
|
|
721
|
+
record.status === "pending"
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function isPersistedThreadRecord(record: TelegramTopicTargetRecord): boolean {
|
|
726
|
+
return isCurrentThreadRecord(record) || record.status === "probe-required";
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function normalizeIdentityRecord(
|
|
730
|
+
value: unknown,
|
|
731
|
+
): TelegramThreadIdentityRecord | undefined {
|
|
732
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
733
|
+
return undefined;
|
|
734
|
+
const record = value as Record<string, unknown>;
|
|
735
|
+
if (typeof record.profileKey !== "string" || record.profileKey.length === 0)
|
|
736
|
+
return undefined;
|
|
737
|
+
if (typeof record.updatedAtMs !== "number") return undefined;
|
|
738
|
+
const identity: TelegramThreadIdentityRecord = {
|
|
739
|
+
profileKey: record.profileKey,
|
|
740
|
+
updatedAtMs: record.updatedAtMs,
|
|
741
|
+
};
|
|
742
|
+
const persistedThreadName = getPersistedThreadName(record);
|
|
743
|
+
if (persistedThreadName) {
|
|
744
|
+
const threadName =
|
|
745
|
+
normalizeTelegramTopicTargetThreadName(persistedThreadName);
|
|
746
|
+
if (threadName) identity.threadName = threadName;
|
|
747
|
+
}
|
|
748
|
+
if (typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)) {
|
|
749
|
+
identity.slot = record.slot;
|
|
750
|
+
}
|
|
751
|
+
return identity.threadName || identity.slot ? identity : undefined;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function cloneIdentityRecord(
|
|
755
|
+
identity: TelegramThreadIdentityRecord,
|
|
756
|
+
): TelegramThreadIdentityRecord {
|
|
757
|
+
return { ...identity };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function normalizeBotStateSnapshot(value: unknown): TelegramBotStateSnapshot {
|
|
761
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
762
|
+
return { threadMode: "unknown" };
|
|
763
|
+
}
|
|
764
|
+
const record = value as Record<string, unknown>;
|
|
765
|
+
const threadMode =
|
|
766
|
+
record.threadMode === "enabled" || record.threadMode === "disabled"
|
|
767
|
+
? record.threadMode
|
|
768
|
+
: "unknown";
|
|
769
|
+
return {
|
|
770
|
+
threadMode,
|
|
771
|
+
updatedAtMs:
|
|
772
|
+
typeof record.updatedAtMs === "number" ? record.updatedAtMs : undefined,
|
|
773
|
+
lastSlot:
|
|
774
|
+
typeof record.lastSlot === "string" && /^[A-Z]$/.test(record.lastSlot)
|
|
775
|
+
? record.lastSlot
|
|
776
|
+
: undefined,
|
|
777
|
+
lastReconcileAction:
|
|
778
|
+
typeof record.lastReconcileAction === "string"
|
|
779
|
+
? record.lastReconcileAction
|
|
780
|
+
: undefined,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function normalizeSyncObservation(
|
|
785
|
+
value: unknown,
|
|
786
|
+
): TelegramTopicSyncObservation | undefined {
|
|
787
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
788
|
+
return undefined;
|
|
789
|
+
const record = value as Record<string, unknown>;
|
|
790
|
+
const targetValue = record.target;
|
|
791
|
+
if (
|
|
792
|
+
!targetValue ||
|
|
793
|
+
typeof targetValue !== "object" ||
|
|
794
|
+
Array.isArray(targetValue)
|
|
795
|
+
) {
|
|
796
|
+
return undefined;
|
|
797
|
+
}
|
|
798
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
799
|
+
const target =
|
|
800
|
+
typeof targetRecord.chatId === "number" &&
|
|
801
|
+
typeof targetRecord.threadId === "number" &&
|
|
802
|
+
Number.isInteger(targetRecord.threadId)
|
|
803
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
804
|
+
: undefined;
|
|
805
|
+
const syncStatus = record.syncStatus;
|
|
806
|
+
if (
|
|
807
|
+
!target ||
|
|
808
|
+
(syncStatus !== "open" &&
|
|
809
|
+
syncStatus !== "closed" &&
|
|
810
|
+
syncStatus !== "deleted" &&
|
|
811
|
+
syncStatus !== "unknown")
|
|
812
|
+
) {
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
target,
|
|
817
|
+
syncStatus,
|
|
818
|
+
observedAtMs:
|
|
819
|
+
typeof record.observedAtMs === "number" ? record.observedAtMs : 0,
|
|
820
|
+
instanceId:
|
|
821
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
822
|
+
slot: typeof record.slot === "string" ? record.slot : undefined,
|
|
823
|
+
lastSyncError:
|
|
824
|
+
typeof record.lastSyncError === "string"
|
|
825
|
+
? record.lastSyncError
|
|
826
|
+
: undefined,
|
|
827
|
+
lastReconcileAction:
|
|
828
|
+
typeof record.lastReconcileAction === "string"
|
|
829
|
+
? record.lastReconcileAction
|
|
830
|
+
: undefined,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function normalizePendingProvision(
|
|
835
|
+
value: unknown,
|
|
836
|
+
): TelegramThreadPendingProvision | undefined {
|
|
837
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
838
|
+
return undefined;
|
|
839
|
+
const record = value as Record<string, unknown>;
|
|
840
|
+
const owner = record.owner;
|
|
841
|
+
if (owner !== "leader" && owner !== "manual-follower") return undefined;
|
|
842
|
+
if (typeof record.id !== "string" || record.id.length === 0) return undefined;
|
|
843
|
+
if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
|
|
844
|
+
return undefined;
|
|
845
|
+
if (typeof record.startedAtMs !== "number") return undefined;
|
|
846
|
+
let target: (TelegramTarget & { threadId: number }) | undefined;
|
|
847
|
+
const targetValue = record.target;
|
|
848
|
+
if (
|
|
849
|
+
targetValue &&
|
|
850
|
+
typeof targetValue === "object" &&
|
|
851
|
+
!Array.isArray(targetValue)
|
|
852
|
+
) {
|
|
853
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
854
|
+
if (
|
|
855
|
+
typeof targetRecord.chatId === "number" &&
|
|
856
|
+
typeof targetRecord.threadId === "number" &&
|
|
857
|
+
Number.isInteger(targetRecord.threadId)
|
|
858
|
+
) {
|
|
859
|
+
target = { chatId: targetRecord.chatId, threadId: targetRecord.threadId };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
id: record.id,
|
|
864
|
+
owner,
|
|
865
|
+
instanceId: record.instanceId,
|
|
866
|
+
...(typeof record.profileKey === "string"
|
|
867
|
+
? { profileKey: record.profileKey }
|
|
868
|
+
: {}),
|
|
869
|
+
...(record.status === "in-flight" || record.status === "ambiguous"
|
|
870
|
+
? { status: record.status }
|
|
871
|
+
: {}),
|
|
872
|
+
...(typeof record.threadName === "string"
|
|
873
|
+
? { threadName: record.threadName }
|
|
874
|
+
: {}),
|
|
875
|
+
...(typeof record.slot === "string" ? { slot: record.slot } : {}),
|
|
876
|
+
...(target ? { target } : {}),
|
|
877
|
+
startedAtMs: record.startedAtMs,
|
|
878
|
+
...(typeof record.expiresAtMs === "number"
|
|
879
|
+
? { expiresAtMs: record.expiresAtMs }
|
|
880
|
+
: {}),
|
|
881
|
+
...(typeof record.leaderEpoch === "number" ||
|
|
882
|
+
typeof record.leaderEpoch === "string"
|
|
883
|
+
? { leaderEpoch: record.leaderEpoch }
|
|
884
|
+
: {}),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function normalizePendingCleanup(
|
|
889
|
+
value: unknown,
|
|
890
|
+
): TelegramThreadCleanupIntent | undefined {
|
|
891
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
892
|
+
return undefined;
|
|
893
|
+
const record = value as Record<string, unknown>;
|
|
894
|
+
const targetValue = record.target;
|
|
895
|
+
if (
|
|
896
|
+
!targetValue ||
|
|
897
|
+
typeof targetValue !== "object" ||
|
|
898
|
+
Array.isArray(targetValue)
|
|
899
|
+
) {
|
|
900
|
+
return undefined;
|
|
901
|
+
}
|
|
902
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
903
|
+
const owner = record.owner;
|
|
904
|
+
if (owner !== "leader" && owner !== "manual-follower") return undefined;
|
|
905
|
+
if (typeof record.id !== "string" || record.id.length === 0) return undefined;
|
|
906
|
+
if (typeof record.instanceId !== "string" || record.instanceId.length === 0)
|
|
907
|
+
return undefined;
|
|
908
|
+
if (
|
|
909
|
+
typeof record.runtimeGeneration !== "string" ||
|
|
910
|
+
record.runtimeGeneration.length === 0
|
|
911
|
+
) {
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
if (
|
|
915
|
+
typeof targetRecord.chatId !== "number" ||
|
|
916
|
+
typeof targetRecord.threadId !== "number" ||
|
|
917
|
+
!Number.isInteger(targetRecord.threadId) ||
|
|
918
|
+
typeof record.requestedAtMs !== "number"
|
|
919
|
+
) {
|
|
920
|
+
return undefined;
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
id: record.id,
|
|
924
|
+
owner,
|
|
925
|
+
instanceId: record.instanceId,
|
|
926
|
+
runtimeGeneration: record.runtimeGeneration,
|
|
927
|
+
...(typeof record.profileKey === "string"
|
|
928
|
+
? { profileKey: record.profileKey }
|
|
929
|
+
: {}),
|
|
930
|
+
target: {
|
|
931
|
+
chatId: targetRecord.chatId,
|
|
932
|
+
threadId: targetRecord.threadId,
|
|
933
|
+
},
|
|
934
|
+
requestedAtMs: record.requestedAtMs,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function normalizeReservation(
|
|
939
|
+
value: unknown,
|
|
940
|
+
): TelegramThreadReservation | undefined {
|
|
941
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
942
|
+
return undefined;
|
|
943
|
+
const record = value as Record<string, unknown>;
|
|
944
|
+
const targetValue = record.target;
|
|
945
|
+
if (
|
|
946
|
+
!targetValue ||
|
|
947
|
+
typeof targetValue !== "object" ||
|
|
948
|
+
Array.isArray(targetValue)
|
|
949
|
+
) {
|
|
950
|
+
return undefined;
|
|
951
|
+
}
|
|
952
|
+
const targetRecord = targetValue as Record<string, unknown>;
|
|
953
|
+
const target =
|
|
954
|
+
typeof targetRecord.chatId === "number" &&
|
|
955
|
+
typeof targetRecord.threadId === "number" &&
|
|
956
|
+
Number.isInteger(targetRecord.threadId)
|
|
957
|
+
? { chatId: targetRecord.chatId, threadId: targetRecord.threadId }
|
|
958
|
+
: undefined;
|
|
959
|
+
const slot = typeof record.slot === "string" ? record.slot : undefined;
|
|
960
|
+
const reason = typeof record.reason === "string" ? record.reason : undefined;
|
|
961
|
+
if (!target || !slot || !reason) return undefined;
|
|
962
|
+
return {
|
|
963
|
+
target,
|
|
964
|
+
slot,
|
|
965
|
+
reason,
|
|
966
|
+
createdAtMs:
|
|
967
|
+
typeof record.createdAtMs === "number" ? record.createdAtMs : 0,
|
|
968
|
+
updatedAtMs:
|
|
969
|
+
typeof record.updatedAtMs === "number" ? record.updatedAtMs : 0,
|
|
970
|
+
expiresAtMs:
|
|
971
|
+
typeof record.expiresAtMs === "number" ? record.expiresAtMs : undefined,
|
|
972
|
+
instanceId:
|
|
973
|
+
typeof record.instanceId === "string" ? record.instanceId : undefined,
|
|
974
|
+
lastReconcileAction:
|
|
975
|
+
typeof record.lastReconcileAction === "string"
|
|
976
|
+
? record.lastReconcileAction
|
|
977
|
+
: undefined,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function parseTopicTargetFile(value: unknown): TelegramTopicTargetFile {
|
|
982
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
983
|
+
return {
|
|
984
|
+
version: 1,
|
|
985
|
+
source: "snapshot",
|
|
986
|
+
writtenAtMs: 0,
|
|
987
|
+
bot: { threadMode: "unknown" },
|
|
988
|
+
threads: [],
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const file = value as Record<string, unknown>;
|
|
992
|
+
if (file.version !== 1) {
|
|
993
|
+
return {
|
|
994
|
+
version: 1,
|
|
995
|
+
source: "snapshot",
|
|
996
|
+
writtenAtMs: 0,
|
|
997
|
+
bot: { threadMode: "unknown" },
|
|
998
|
+
threads: [],
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const rawThreads = Array.isArray(file.threads) ? file.threads : [];
|
|
1002
|
+
const threads = rawThreads
|
|
1003
|
+
.map((record) => normalizeRecord(record))
|
|
1004
|
+
.filter(
|
|
1005
|
+
(record): record is TelegramTopicTargetRecord =>
|
|
1006
|
+
!!record && isPersistedThreadRecord(record),
|
|
1007
|
+
);
|
|
1008
|
+
return {
|
|
1009
|
+
version: 1,
|
|
1010
|
+
source: "snapshot",
|
|
1011
|
+
writtenAtMs: typeof file.writtenAtMs === "number" ? file.writtenAtMs : 0,
|
|
1012
|
+
bot: normalizeBotStateSnapshot(file.bot),
|
|
1013
|
+
threads,
|
|
1014
|
+
identities: Array.isArray(file.identities)
|
|
1015
|
+
? file.identities.flatMap((identity) => {
|
|
1016
|
+
const normalized = normalizeIdentityRecord(identity);
|
|
1017
|
+
return normalized ? [normalized] : [];
|
|
1018
|
+
})
|
|
1019
|
+
: [],
|
|
1020
|
+
reservations: Array.isArray(file.reservations)
|
|
1021
|
+
? file.reservations.flatMap((reservation) => {
|
|
1022
|
+
const normalized = normalizeReservation(reservation);
|
|
1023
|
+
return normalized ? [normalized] : [];
|
|
1024
|
+
})
|
|
1025
|
+
: [],
|
|
1026
|
+
pendingProvisions: Array.isArray(file.pendingProvisions)
|
|
1027
|
+
? file.pendingProvisions.flatMap((provision) => {
|
|
1028
|
+
const normalized = normalizePendingProvision(provision);
|
|
1029
|
+
return normalized ? [normalized] : [];
|
|
1030
|
+
})
|
|
1031
|
+
: [],
|
|
1032
|
+
pendingCleanups: Array.isArray(file.pendingCleanups)
|
|
1033
|
+
? file.pendingCleanups.flatMap((intent) => {
|
|
1034
|
+
const normalized = normalizePendingCleanup(intent);
|
|
1035
|
+
return normalized ? [normalized] : [];
|
|
1036
|
+
})
|
|
1037
|
+
: [],
|
|
1038
|
+
syncObservations: Array.isArray(file.syncObservations)
|
|
1039
|
+
? file.syncObservations.flatMap((observation) => {
|
|
1040
|
+
const normalized = normalizeSyncObservation(observation);
|
|
1041
|
+
return normalized ? [normalized] : [];
|
|
1042
|
+
})
|
|
1043
|
+
: [],
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function serializeTelegramStateSemanticSnapshot(
|
|
1048
|
+
value: unknown,
|
|
1049
|
+
): string | undefined {
|
|
1050
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1051
|
+
return undefined;
|
|
1052
|
+
const { writtenAtMs: _writtenAtMs, ...semantic } = value as Record<
|
|
1053
|
+
string,
|
|
1054
|
+
unknown
|
|
1055
|
+
>;
|
|
1056
|
+
return JSON.stringify(semantic);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function targetMatches(left: TelegramTarget, right: TelegramTarget): boolean {
|
|
1060
|
+
return left.chatId === right.chatId && left.threadId === right.threadId;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function getTargetRecoveryHintKey(target: TelegramTarget): string {
|
|
1064
|
+
return `${target.chatId}:${target.threadId ?? "private"}`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function parseFollowerRecoveryHints(
|
|
1068
|
+
value: unknown,
|
|
1069
|
+
): Map<string, { slot?: string; threadName?: string }> {
|
|
1070
|
+
const hints = new Map<string, { slot?: string; threadName?: string }>();
|
|
1071
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return hints;
|
|
1072
|
+
const liveRoster = (value as Record<string, unknown>).liveRoster;
|
|
1073
|
+
if (
|
|
1074
|
+
!liveRoster ||
|
|
1075
|
+
typeof liveRoster !== "object" ||
|
|
1076
|
+
Array.isArray(liveRoster)
|
|
1077
|
+
)
|
|
1078
|
+
return hints;
|
|
1079
|
+
const followers = (liveRoster as Record<string, unknown>).busFollowers;
|
|
1080
|
+
if (!Array.isArray(followers)) return hints;
|
|
1081
|
+
for (const follower of followers) {
|
|
1082
|
+
if (!follower || typeof follower !== "object" || Array.isArray(follower))
|
|
1083
|
+
continue;
|
|
1084
|
+
const record = follower as Record<string, unknown>;
|
|
1085
|
+
const target = record.target;
|
|
1086
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
1087
|
+
continue;
|
|
1088
|
+
const targetRecord = target as Record<string, unknown>;
|
|
1089
|
+
if (typeof targetRecord.chatId !== "number") continue;
|
|
1090
|
+
const normalizedTarget: TelegramTarget = {
|
|
1091
|
+
chatId: targetRecord.chatId,
|
|
1092
|
+
...(typeof targetRecord.threadId === "number"
|
|
1093
|
+
? { threadId: targetRecord.threadId }
|
|
1094
|
+
: {}),
|
|
1095
|
+
};
|
|
1096
|
+
const slot =
|
|
1097
|
+
typeof targetRecord.slot === "string" && /^[A-Z]$/.test(targetRecord.slot)
|
|
1098
|
+
? targetRecord.slot
|
|
1099
|
+
: typeof record.slot === "string" && /^[A-Z]$/.test(record.slot)
|
|
1100
|
+
? record.slot
|
|
1101
|
+
: undefined;
|
|
1102
|
+
const threadName =
|
|
1103
|
+
typeof targetRecord.threadName === "string"
|
|
1104
|
+
? targetRecord.threadName
|
|
1105
|
+
: typeof record.threadName === "string"
|
|
1106
|
+
? record.threadName
|
|
1107
|
+
: undefined;
|
|
1108
|
+
hints.set(getTargetRecoveryHintKey(normalizedTarget), {
|
|
1109
|
+
...(slot ? { slot } : {}),
|
|
1110
|
+
...(threadName ? { threadName } : {}),
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
return hints;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function getInstanceProcessKey(
|
|
1117
|
+
instanceId: string | undefined,
|
|
1118
|
+
): string | undefined {
|
|
1119
|
+
if (!instanceId) return undefined;
|
|
1120
|
+
const [pid] = instanceId.split(":", 1);
|
|
1121
|
+
return pid && /^\d+$/.test(pid) ? pid : undefined;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function isSameProcessInstance(
|
|
1125
|
+
left: string | undefined,
|
|
1126
|
+
right: string | undefined,
|
|
1127
|
+
): boolean {
|
|
1128
|
+
const leftProcess = getInstanceProcessKey(left);
|
|
1129
|
+
return !!leftProcess && leftProcess === getInstanceProcessKey(right);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function isPendingProvisionLiveOrTargeted(
|
|
1133
|
+
provision: TelegramThreadPendingProvision,
|
|
1134
|
+
nowMs: number,
|
|
1135
|
+
): boolean {
|
|
1136
|
+
if (provision.status === "ambiguous") return true;
|
|
1137
|
+
if (provision.expiresAtMs === undefined || provision.expiresAtMs > nowMs) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
return !!provision.target;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function createTelegramTopicTargetStore(
|
|
1144
|
+
options: TelegramTopicTargetStoreOptions,
|
|
1145
|
+
): TelegramTopicTargetStore {
|
|
1146
|
+
const getNowMs = options.getNowMs ?? Date.now;
|
|
1147
|
+
let botState: TelegramBotStateSnapshot = { threadMode: "unknown" };
|
|
1148
|
+
let records = new Map<string, TelegramTopicTargetRecord>();
|
|
1149
|
+
let identities = new Map<string, TelegramThreadIdentityRecord>();
|
|
1150
|
+
let reservations: TelegramThreadReservation[] = [];
|
|
1151
|
+
let pendingProvisions: TelegramThreadPendingProvision[] = [];
|
|
1152
|
+
let pendingCleanups: TelegramThreadCleanupIntent[] = [];
|
|
1153
|
+
let syncObservations: TelegramTopicSyncObservation[] = [];
|
|
1154
|
+
let followerRecoveryHints = new Map<
|
|
1155
|
+
string,
|
|
1156
|
+
{ slot?: string; threadName?: string }
|
|
1157
|
+
>();
|
|
1158
|
+
let loaded = false;
|
|
1159
|
+
let loadedPath: string | undefined;
|
|
1160
|
+
let dirty = false;
|
|
1161
|
+
let mutationRevision = 0;
|
|
1162
|
+
let statusRevision = 0;
|
|
1163
|
+
let persistQueue: Promise<void> = Promise.resolve();
|
|
1164
|
+
let statusSnapshot: {
|
|
1165
|
+
runtime?: Record<string, unknown>;
|
|
1166
|
+
liveRoster?: Record<string, unknown>;
|
|
1167
|
+
diagnostics?: Record<string, unknown>;
|
|
1168
|
+
} = {};
|
|
1169
|
+
|
|
1170
|
+
const rememberSlot = (slot: string | undefined, nowMs = getNowMs()) => {
|
|
1171
|
+
if (!slot || !/^[A-Z]$/.test(slot)) return;
|
|
1172
|
+
botState = { ...botState, lastSlot: slot, updatedAtMs: nowMs };
|
|
1173
|
+
};
|
|
1174
|
+
const rememberIdentity = (record: TelegramTopicTargetRecord) => {
|
|
1175
|
+
const profileKey = getRecordOwnerKey(record);
|
|
1176
|
+
if (!record.threadName && !record.slot) return;
|
|
1177
|
+
identities.set(profileKey, {
|
|
1178
|
+
profileKey,
|
|
1179
|
+
...(record.threadName ? { threadName: record.threadName } : {}),
|
|
1180
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1181
|
+
updatedAtMs: record.updatedAtMs,
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
const getPath = () =>
|
|
1186
|
+
typeof options.path === "function" ? options.path() : options.path;
|
|
1187
|
+
const getRecoveryPath = (path: string) => `${path}.provision-recovery.json`;
|
|
1188
|
+
const readProvisionRecoveries = (
|
|
1189
|
+
path: string,
|
|
1190
|
+
): TelegramProvisionRecoveryFile => {
|
|
1191
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1192
|
+
if (!existsSync(recoveryPath)) return {};
|
|
1193
|
+
try {
|
|
1194
|
+
const value = JSON.parse(readFileSync(recoveryPath, "utf8")) as unknown;
|
|
1195
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1196
|
+
? (value as TelegramProvisionRecoveryFile)
|
|
1197
|
+
: {};
|
|
1198
|
+
} catch {
|
|
1199
|
+
return {};
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
const resetForPath = (path: string) => {
|
|
1203
|
+
if (loadedPath === path) return;
|
|
1204
|
+
botState = { threadMode: "unknown" };
|
|
1205
|
+
records = new Map();
|
|
1206
|
+
identities = new Map();
|
|
1207
|
+
reservations = [];
|
|
1208
|
+
pendingProvisions = [];
|
|
1209
|
+
pendingCleanups = [];
|
|
1210
|
+
syncObservations = [];
|
|
1211
|
+
followerRecoveryHints = new Map();
|
|
1212
|
+
statusSnapshot = {};
|
|
1213
|
+
loaded = false;
|
|
1214
|
+
dirty = false;
|
|
1215
|
+
loadedPath = path;
|
|
1216
|
+
};
|
|
1217
|
+
|
|
1218
|
+
const loadFromDisk = async () => {
|
|
1219
|
+
const path = getPath();
|
|
1220
|
+
resetForPath(path);
|
|
1221
|
+
if (!existsSync(path)) {
|
|
1222
|
+
botState = { threadMode: "unknown" };
|
|
1223
|
+
records = new Map();
|
|
1224
|
+
identities = new Map();
|
|
1225
|
+
reservations = [];
|
|
1226
|
+
pendingProvisions = [];
|
|
1227
|
+
pendingCleanups = [];
|
|
1228
|
+
syncObservations = [];
|
|
1229
|
+
followerRecoveryHints = new Map();
|
|
1230
|
+
loaded = true;
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const content = await readFile(path, "utf8");
|
|
1234
|
+
const rawFile: unknown = JSON.parse(content);
|
|
1235
|
+
const file = parseTopicTargetFile(rawFile);
|
|
1236
|
+
followerRecoveryHints = parseFollowerRecoveryHints(rawFile);
|
|
1237
|
+
botState = file.bot;
|
|
1238
|
+
records = new Map(
|
|
1239
|
+
file.threads.map((record) => [
|
|
1240
|
+
getRecordOwnerKey(record),
|
|
1241
|
+
cloneRecord(record),
|
|
1242
|
+
]),
|
|
1243
|
+
);
|
|
1244
|
+
identities = new Map(
|
|
1245
|
+
(file.identities ?? []).map((identity) => [
|
|
1246
|
+
identity.profileKey,
|
|
1247
|
+
cloneIdentityRecord(identity),
|
|
1248
|
+
]),
|
|
1249
|
+
);
|
|
1250
|
+
for (const record of records.values()) rememberIdentity(record);
|
|
1251
|
+
const nowMs = getNowMs();
|
|
1252
|
+
reservations = (file.reservations ?? [])
|
|
1253
|
+
.filter(
|
|
1254
|
+
(reservation) =>
|
|
1255
|
+
reservation.expiresAtMs === undefined ||
|
|
1256
|
+
reservation.expiresAtMs > nowMs,
|
|
1257
|
+
)
|
|
1258
|
+
.map((reservation) => ({ ...reservation }));
|
|
1259
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1260
|
+
pendingProvisions = (file.pendingProvisions ?? [])
|
|
1261
|
+
.filter((provision) => isPendingProvisionLiveOrTargeted(provision, nowMs))
|
|
1262
|
+
.map((provision) => {
|
|
1263
|
+
const recovery = recoveries[provision.id];
|
|
1264
|
+
const recoveryMatches =
|
|
1265
|
+
recovery?.instanceId === provision.instanceId &&
|
|
1266
|
+
recovery.profileKey === provision.profileKey &&
|
|
1267
|
+
recovery.leaderEpoch === provision.leaderEpoch &&
|
|
1268
|
+
Number.isInteger(recovery.target?.threadId);
|
|
1269
|
+
return {
|
|
1270
|
+
...provision,
|
|
1271
|
+
...(recoveryMatches
|
|
1272
|
+
? { target: { ...recovery.target }, status: "ambiguous" as const }
|
|
1273
|
+
: provision.target
|
|
1274
|
+
? { target: { ...provision.target } }
|
|
1275
|
+
: {}),
|
|
1276
|
+
};
|
|
1277
|
+
});
|
|
1278
|
+
pendingCleanups = (file.pendingCleanups ?? []).map((intent) => ({
|
|
1279
|
+
...intent,
|
|
1280
|
+
target: { ...intent.target },
|
|
1281
|
+
}));
|
|
1282
|
+
syncObservations = (file.syncObservations ?? []).map((observation) => ({
|
|
1283
|
+
...observation,
|
|
1284
|
+
target: { ...observation.target },
|
|
1285
|
+
}));
|
|
1286
|
+
loaded = true;
|
|
1287
|
+
dirty = false;
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
const markDirty = (): void => {
|
|
1291
|
+
loaded = true;
|
|
1292
|
+
dirty = true;
|
|
1293
|
+
mutationRevision += 1;
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
return {
|
|
1297
|
+
async load() {
|
|
1298
|
+
if (dirty) return;
|
|
1299
|
+
await loadFromDisk();
|
|
1300
|
+
},
|
|
1301
|
+
refresh() {
|
|
1302
|
+
const refresh = persistQueue.then(loadFromDisk);
|
|
1303
|
+
persistQueue = refresh.catch(() => undefined);
|
|
1304
|
+
return refresh;
|
|
1305
|
+
},
|
|
1306
|
+
persist() {
|
|
1307
|
+
const persist = persistQueue.then(async () => {
|
|
1308
|
+
const path = getPath();
|
|
1309
|
+
if (loadedPath !== path && !dirty) resetForPath(path);
|
|
1310
|
+
if (options.canPersist && !options.canPersist()) {
|
|
1311
|
+
await loadFromDisk();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (!dirty || !loaded) await loadFromDisk();
|
|
1315
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1316
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
|
1317
|
+
const nowMs = getNowMs();
|
|
1318
|
+
reservations = reservations.filter(
|
|
1319
|
+
(reservation) =>
|
|
1320
|
+
reservation.expiresAtMs === undefined ||
|
|
1321
|
+
reservation.expiresAtMs > nowMs,
|
|
1322
|
+
);
|
|
1323
|
+
pendingProvisions = pendingProvisions.filter((provision) =>
|
|
1324
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
1325
|
+
);
|
|
1326
|
+
const currentRecords = Array.from(records.values())
|
|
1327
|
+
.filter(isPersistedThreadRecord)
|
|
1328
|
+
.map(cloneRecord);
|
|
1329
|
+
records = new Map(
|
|
1330
|
+
currentRecords.map((record) => [
|
|
1331
|
+
getRecordOwnerKey(record),
|
|
1332
|
+
cloneRecord(record),
|
|
1333
|
+
]),
|
|
1334
|
+
);
|
|
1335
|
+
const persistedRevision = mutationRevision;
|
|
1336
|
+
const persistedStatusRevision = statusRevision;
|
|
1337
|
+
const file = {
|
|
1338
|
+
version: 1,
|
|
1339
|
+
source: "snapshot",
|
|
1340
|
+
writtenAtMs: nowMs,
|
|
1341
|
+
bot: botState,
|
|
1342
|
+
...statusSnapshot,
|
|
1343
|
+
identities: Array.from(identities.values()).map(cloneIdentityRecord),
|
|
1344
|
+
reservations: reservations.map((reservation) => ({ ...reservation })),
|
|
1345
|
+
pendingProvisions: pendingProvisions.map((provision) => ({
|
|
1346
|
+
...provision,
|
|
1347
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1348
|
+
})),
|
|
1349
|
+
pendingCleanups: pendingCleanups.map((intent) => ({
|
|
1350
|
+
...intent,
|
|
1351
|
+
target: { ...intent.target },
|
|
1352
|
+
})),
|
|
1353
|
+
syncObservations: syncObservations.map((observation) => ({
|
|
1354
|
+
...observation,
|
|
1355
|
+
target: { ...observation.target },
|
|
1356
|
+
})),
|
|
1357
|
+
threads: currentRecords.map((record) => {
|
|
1358
|
+
const { profileKey: _profileKey, ...serialized } = record;
|
|
1359
|
+
return serialized;
|
|
1360
|
+
}),
|
|
1361
|
+
};
|
|
1362
|
+
let persistedSemanticSnapshot: string | undefined;
|
|
1363
|
+
try {
|
|
1364
|
+
persistedSemanticSnapshot = serializeTelegramStateSemanticSnapshot(
|
|
1365
|
+
JSON.parse(await readFile(path, "utf8")),
|
|
1366
|
+
);
|
|
1367
|
+
} catch {
|
|
1368
|
+
/* missing or invalid snapshots must be replaced */
|
|
1369
|
+
}
|
|
1370
|
+
if (
|
|
1371
|
+
persistedSemanticSnapshot ===
|
|
1372
|
+
serializeTelegramStateSemanticSnapshot(file) &&
|
|
1373
|
+
mutationRevision === persistedRevision &&
|
|
1374
|
+
statusRevision === persistedStatusRevision
|
|
1375
|
+
) {
|
|
1376
|
+
loaded = true;
|
|
1377
|
+
dirty = false;
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
await writeFile(tempPath, `${JSON.stringify(file, null, 2)}\n`, {
|
|
1381
|
+
encoding: "utf8",
|
|
1382
|
+
mode: 0o600,
|
|
1383
|
+
});
|
|
1384
|
+
await chmod(tempPath, 0o600);
|
|
1385
|
+
try {
|
|
1386
|
+
if (options.commitPersist) {
|
|
1387
|
+
const committed = options.commitPersist(() => {
|
|
1388
|
+
renameSync(tempPath, path);
|
|
1389
|
+
chmodSync(path, 0o600);
|
|
1390
|
+
});
|
|
1391
|
+
if (!committed) {
|
|
1392
|
+
await loadFromDisk();
|
|
1393
|
+
throw new Error(
|
|
1394
|
+
"Telegram thread snapshot lost exact transport ownership before commit.",
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
} else {
|
|
1398
|
+
await rename(tempPath, path);
|
|
1399
|
+
await chmod(path, 0o600);
|
|
1400
|
+
}
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
await unlink(tempPath).catch(() => undefined);
|
|
1403
|
+
throw error;
|
|
1404
|
+
}
|
|
1405
|
+
loaded = true;
|
|
1406
|
+
if (mutationRevision === persistedRevision) dirty = false;
|
|
1407
|
+
});
|
|
1408
|
+
persistQueue = persist.catch(() => undefined);
|
|
1409
|
+
return persist;
|
|
1410
|
+
},
|
|
1411
|
+
list() {
|
|
1412
|
+
return Array.from(records.values()).map(cloneRecord);
|
|
1413
|
+
},
|
|
1414
|
+
getFollowerRecoveryHintByTarget(target) {
|
|
1415
|
+
const hint = followerRecoveryHints.get(getTargetRecoveryHintKey(target));
|
|
1416
|
+
return hint ? { ...hint } : undefined;
|
|
1417
|
+
},
|
|
1418
|
+
listReservations() {
|
|
1419
|
+
const nowMs = getNowMs();
|
|
1420
|
+
return reservations
|
|
1421
|
+
.filter(
|
|
1422
|
+
(reservation) =>
|
|
1423
|
+
reservation.expiresAtMs === undefined ||
|
|
1424
|
+
reservation.expiresAtMs > nowMs,
|
|
1425
|
+
)
|
|
1426
|
+
.map((reservation) => ({ ...reservation }));
|
|
1427
|
+
},
|
|
1428
|
+
listPendingProvisions() {
|
|
1429
|
+
const nowMs = getNowMs();
|
|
1430
|
+
return pendingProvisions
|
|
1431
|
+
.filter((provision) =>
|
|
1432
|
+
isPendingProvisionLiveOrTargeted(provision, nowMs),
|
|
1433
|
+
)
|
|
1434
|
+
.map((provision) => ({
|
|
1435
|
+
...provision,
|
|
1436
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1437
|
+
}));
|
|
1438
|
+
},
|
|
1439
|
+
listPendingCleanups() {
|
|
1440
|
+
return pendingCleanups.map((intent) => ({
|
|
1441
|
+
...intent,
|
|
1442
|
+
target: { ...intent.target },
|
|
1443
|
+
}));
|
|
1444
|
+
},
|
|
1445
|
+
listSyncObservations() {
|
|
1446
|
+
return syncObservations.map((observation) => ({
|
|
1447
|
+
...observation,
|
|
1448
|
+
target: { ...observation.target },
|
|
1449
|
+
}));
|
|
1450
|
+
},
|
|
1451
|
+
reserveThread(reservation) {
|
|
1452
|
+
const next = { ...reservation };
|
|
1453
|
+
reservations = reservations.filter(
|
|
1454
|
+
(existing) =>
|
|
1455
|
+
existing.slot !== next.slot &&
|
|
1456
|
+
!targetMatches(existing.target, next.target),
|
|
1457
|
+
);
|
|
1458
|
+
reservations.push(next);
|
|
1459
|
+
markDirty();
|
|
1460
|
+
},
|
|
1461
|
+
upsertPendingProvision(provision) {
|
|
1462
|
+
const next = {
|
|
1463
|
+
...provision,
|
|
1464
|
+
...(provision.target ? { target: { ...provision.target } } : {}),
|
|
1465
|
+
};
|
|
1466
|
+
pendingProvisions = pendingProvisions.filter(
|
|
1467
|
+
(existing) => existing.id !== next.id,
|
|
1468
|
+
);
|
|
1469
|
+
pendingProvisions.push(next);
|
|
1470
|
+
markDirty();
|
|
1471
|
+
},
|
|
1472
|
+
async recordPendingProvisionTargetRecovery(provision, target) {
|
|
1473
|
+
const path = getPath();
|
|
1474
|
+
const recoveryPath = getRecoveryPath(path);
|
|
1475
|
+
await mkdir(dirname(recoveryPath), { recursive: true });
|
|
1476
|
+
withTelegramFileTransaction(`${recoveryPath}.transaction`, () => {
|
|
1477
|
+
const recoveries = readProvisionRecoveries(path);
|
|
1478
|
+
recoveries[provision.id] = {
|
|
1479
|
+
instanceId: provision.instanceId,
|
|
1480
|
+
...(provision.profileKey ? { profileKey: provision.profileKey } : {}),
|
|
1481
|
+
...(provision.leaderEpoch !== undefined
|
|
1482
|
+
? { leaderEpoch: provision.leaderEpoch }
|
|
1483
|
+
: {}),
|
|
1484
|
+
target: { ...target },
|
|
1485
|
+
};
|
|
1486
|
+
const tempPath = `${recoveryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1487
|
+
writeFileSync(tempPath, `${JSON.stringify(recoveries, null, 2)}\n`, {
|
|
1488
|
+
encoding: "utf8",
|
|
1489
|
+
mode: 0o600,
|
|
1490
|
+
});
|
|
1491
|
+
renameSync(tempPath, recoveryPath);
|
|
1492
|
+
chmodSync(recoveryPath, 0o600);
|
|
1493
|
+
});
|
|
1494
|
+
const current = pendingProvisions.find(
|
|
1495
|
+
(entry) =>
|
|
1496
|
+
entry.id === provision.id &&
|
|
1497
|
+
entry.instanceId === provision.instanceId &&
|
|
1498
|
+
entry.profileKey === provision.profileKey &&
|
|
1499
|
+
entry.leaderEpoch === provision.leaderEpoch,
|
|
1500
|
+
);
|
|
1501
|
+
if (!current) return false;
|
|
1502
|
+
current.target = { ...target };
|
|
1503
|
+
current.status = "ambiguous";
|
|
1504
|
+
return true;
|
|
1505
|
+
},
|
|
1506
|
+
removePendingProvision(id) {
|
|
1507
|
+
const before = pendingProvisions.length;
|
|
1508
|
+
pendingProvisions = pendingProvisions.filter(
|
|
1509
|
+
(provision) => provision.id !== id,
|
|
1510
|
+
);
|
|
1511
|
+
const changed = pendingProvisions.length !== before;
|
|
1512
|
+
if (changed) markDirty();
|
|
1513
|
+
return changed;
|
|
1514
|
+
},
|
|
1515
|
+
upsertPendingCleanup(intent) {
|
|
1516
|
+
const next = { ...intent, target: { ...intent.target } };
|
|
1517
|
+
pendingCleanups = pendingCleanups.filter(
|
|
1518
|
+
(existing) => existing.id !== next.id,
|
|
1519
|
+
);
|
|
1520
|
+
pendingCleanups.push(next);
|
|
1521
|
+
markDirty();
|
|
1522
|
+
},
|
|
1523
|
+
removePendingCleanup(id) {
|
|
1524
|
+
const before = pendingCleanups.length;
|
|
1525
|
+
pendingCleanups = pendingCleanups.filter((intent) => intent.id !== id);
|
|
1526
|
+
const changed = pendingCleanups.length !== before;
|
|
1527
|
+
if (changed) markDirty();
|
|
1528
|
+
return changed;
|
|
1529
|
+
},
|
|
1530
|
+
getBotState() {
|
|
1531
|
+
return Object.fromEntries(
|
|
1532
|
+
Object.entries(botState).filter(([, value]) => value !== undefined),
|
|
1533
|
+
) as TelegramBotStateSnapshot;
|
|
1534
|
+
},
|
|
1535
|
+
setBotState(state) {
|
|
1536
|
+
botState = { ...botState, ...state };
|
|
1537
|
+
markDirty();
|
|
1538
|
+
},
|
|
1539
|
+
setStatusSnapshot(snapshot) {
|
|
1540
|
+
if (!loadedPath) loadedPath = getPath();
|
|
1541
|
+
statusSnapshot = { ...snapshot };
|
|
1542
|
+
statusRevision += 1;
|
|
1543
|
+
},
|
|
1544
|
+
getByProfileKey(profileKey) {
|
|
1545
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1546
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1547
|
+
);
|
|
1548
|
+
const record = records.get(ownerKey) ?? records.get(profileKey);
|
|
1549
|
+
return record ? cloneRecord(record) : undefined;
|
|
1550
|
+
},
|
|
1551
|
+
getActiveByInstanceId(instanceId) {
|
|
1552
|
+
for (const record of records.values()) {
|
|
1553
|
+
if (record.instanceId !== instanceId) continue;
|
|
1554
|
+
if (record.status !== "active" && record.status !== "starting")
|
|
1555
|
+
continue;
|
|
1556
|
+
return cloneRecord(record);
|
|
1557
|
+
}
|
|
1558
|
+
return undefined;
|
|
1559
|
+
},
|
|
1560
|
+
getIdentityByProfileKey(profileKey) {
|
|
1561
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1562
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1563
|
+
);
|
|
1564
|
+
const identity = identities.get(ownerKey) ?? identities.get(profileKey);
|
|
1565
|
+
return identity ? cloneIdentityRecord(identity) : undefined;
|
|
1566
|
+
},
|
|
1567
|
+
forgetIdentityByProfileKey(profileKey) {
|
|
1568
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1569
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1570
|
+
);
|
|
1571
|
+
const removedOwner = identities.delete(ownerKey);
|
|
1572
|
+
const removedProfile = identities.delete(profileKey);
|
|
1573
|
+
if (!removedOwner && !removedProfile) return false;
|
|
1574
|
+
markDirty();
|
|
1575
|
+
return true;
|
|
1576
|
+
},
|
|
1577
|
+
upsert(record) {
|
|
1578
|
+
const next = cloneRecord(record);
|
|
1579
|
+
const nextOwnerKey = getRecordOwnerKey(next);
|
|
1580
|
+
const previousRecord = records.get(nextOwnerKey);
|
|
1581
|
+
if (isCurrentThreadRecord(next)) {
|
|
1582
|
+
for (const existing of Array.from(records.values())) {
|
|
1583
|
+
const existingOwnerKey = getRecordOwnerKey(existing);
|
|
1584
|
+
if (existingOwnerKey === nextOwnerKey) continue;
|
|
1585
|
+
if (!targetMatches(existing.target, next.target)) continue;
|
|
1586
|
+
records.delete(existingOwnerKey);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
if (
|
|
1590
|
+
next.instanceId &&
|
|
1591
|
+
(next.status === "active" || next.status === "starting")
|
|
1592
|
+
) {
|
|
1593
|
+
for (const existing of records.values()) {
|
|
1594
|
+
if (existing.instanceId !== next.instanceId) continue;
|
|
1595
|
+
if (getRecordOwnerKey(existing) === nextOwnerKey) continue;
|
|
1596
|
+
if (targetMatches(existing.target, next.target)) continue;
|
|
1597
|
+
if (existing.status !== "active" && existing.status !== "starting")
|
|
1598
|
+
continue;
|
|
1599
|
+
records.delete(getRecordOwnerKey(existing));
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
if (!isPersistedThreadRecord(next)) {
|
|
1603
|
+
rememberIdentity(next);
|
|
1604
|
+
records.delete(nextOwnerKey);
|
|
1605
|
+
markDirty();
|
|
1606
|
+
return cloneRecord(next);
|
|
1607
|
+
}
|
|
1608
|
+
records.set(nextOwnerKey, next);
|
|
1609
|
+
if (
|
|
1610
|
+
!previousRecord ||
|
|
1611
|
+
!targetMatches(previousRecord.target, next.target)
|
|
1612
|
+
) {
|
|
1613
|
+
rememberSlot(next.slot, next.updatedAtMs);
|
|
1614
|
+
}
|
|
1615
|
+
rememberIdentity(next);
|
|
1616
|
+
markDirty();
|
|
1617
|
+
return cloneRecord(next);
|
|
1618
|
+
},
|
|
1619
|
+
markOfflineByInstanceId(instanceId) {
|
|
1620
|
+
let count = 0;
|
|
1621
|
+
for (const record of Array.from(records.values())) {
|
|
1622
|
+
if (
|
|
1623
|
+
record.instanceId !== instanceId ||
|
|
1624
|
+
(record.status !== "active" && record.status !== "starting")
|
|
1625
|
+
)
|
|
1626
|
+
continue;
|
|
1627
|
+
records.delete(getRecordOwnerKey(record));
|
|
1628
|
+
count += 1;
|
|
1629
|
+
}
|
|
1630
|
+
if (count > 0) markDirty();
|
|
1631
|
+
return count;
|
|
1632
|
+
},
|
|
1633
|
+
markStaleByTarget(target, syncStatus = "unknown", lastSyncError) {
|
|
1634
|
+
for (const record of Array.from(records.values())) {
|
|
1635
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1636
|
+
const nowMs = getNowMs();
|
|
1637
|
+
syncObservations = syncObservations.filter(
|
|
1638
|
+
(observation) => !targetMatches(observation.target, record.target),
|
|
1639
|
+
);
|
|
1640
|
+
syncObservations.push({
|
|
1641
|
+
target: record.target,
|
|
1642
|
+
syncStatus,
|
|
1643
|
+
observedAtMs: nowMs,
|
|
1644
|
+
...(record.instanceId ? { instanceId: record.instanceId } : {}),
|
|
1645
|
+
...(record.slot ? { slot: record.slot } : {}),
|
|
1646
|
+
...(lastSyncError ? { lastSyncError } : {}),
|
|
1647
|
+
lastReconcileAction: "mark-stale",
|
|
1648
|
+
});
|
|
1649
|
+
rememberIdentity(record);
|
|
1650
|
+
records.delete(getRecordOwnerKey(record));
|
|
1651
|
+
markDirty();
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
return false;
|
|
1655
|
+
},
|
|
1656
|
+
markActiveByTarget(target) {
|
|
1657
|
+
const nowMs = getNowMs();
|
|
1658
|
+
for (const record of records.values()) {
|
|
1659
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1660
|
+
record.status = "active";
|
|
1661
|
+
record.updatedAtMs = nowMs;
|
|
1662
|
+
record.syncStatus = "open";
|
|
1663
|
+
record.lastSyncObservedAtMs = nowMs;
|
|
1664
|
+
record.lastReconcileAction = "mark-active";
|
|
1665
|
+
delete record.lastError;
|
|
1666
|
+
delete record.lastSyncError;
|
|
1667
|
+
markDirty();
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
return false;
|
|
1671
|
+
},
|
|
1672
|
+
renameByTarget(target, threadName) {
|
|
1673
|
+
const nowMs = getNowMs();
|
|
1674
|
+
const normalizedThreadName =
|
|
1675
|
+
normalizeTelegramTopicTargetThreadName(threadName);
|
|
1676
|
+
if (!normalizedThreadName) return undefined;
|
|
1677
|
+
for (const record of records.values()) {
|
|
1678
|
+
if (!targetMatches(record.target, target)) continue;
|
|
1679
|
+
record.threadName = normalizedThreadName;
|
|
1680
|
+
record.updatedAtMs = nowMs;
|
|
1681
|
+
rememberIdentity(record);
|
|
1682
|
+
markDirty();
|
|
1683
|
+
return cloneRecord(record);
|
|
1684
|
+
}
|
|
1685
|
+
return undefined;
|
|
1686
|
+
},
|
|
1687
|
+
claimReusableTarget(instanceId, threadName) {
|
|
1688
|
+
const nowMs = getNowMs();
|
|
1689
|
+
const candidates = Array.from(records.values())
|
|
1690
|
+
.filter((record) => {
|
|
1691
|
+
if (record.instanceId) return false;
|
|
1692
|
+
if (record.slot === "A") return false;
|
|
1693
|
+
if (record.status !== "pending") return false;
|
|
1694
|
+
if (!record.slot) return true;
|
|
1695
|
+
return !Array.from(records.values()).some(
|
|
1696
|
+
(other) =>
|
|
1697
|
+
other !== record &&
|
|
1698
|
+
other.slot === record.slot &&
|
|
1699
|
+
(other.status === "active" || other.status === "starting"),
|
|
1700
|
+
);
|
|
1701
|
+
})
|
|
1702
|
+
.sort((left, right) => {
|
|
1703
|
+
const leftSlot = left.slot ?? "Z";
|
|
1704
|
+
const rightSlot = right.slot ?? "Z";
|
|
1705
|
+
if (leftSlot !== rightSlot) return leftSlot.localeCompare(rightSlot);
|
|
1706
|
+
return left.createdAtMs - right.createdAtMs;
|
|
1707
|
+
});
|
|
1708
|
+
const record = candidates[0];
|
|
1709
|
+
if (!record) return undefined;
|
|
1710
|
+
record.status = "active";
|
|
1711
|
+
record.instanceId = instanceId;
|
|
1712
|
+
record.updatedAtMs = nowMs;
|
|
1713
|
+
if (
|
|
1714
|
+
!record.threadName &&
|
|
1715
|
+
threadName &&
|
|
1716
|
+
isTelegramTopicThreadNameValidForSlot(threadName, record.slot)
|
|
1717
|
+
)
|
|
1718
|
+
record.threadName = threadName;
|
|
1719
|
+
delete record.lastError;
|
|
1720
|
+
rememberIdentity(record);
|
|
1721
|
+
markDirty();
|
|
1722
|
+
return cloneRecord(record);
|
|
1723
|
+
},
|
|
1724
|
+
allocateSlot(profileKey, preferredSlot) {
|
|
1725
|
+
const ownerKey = getTelegramThreadOwnerKey(
|
|
1726
|
+
getTelegramThreadOwnerFromProfileKey(profileKey),
|
|
1727
|
+
);
|
|
1728
|
+
const existing = records.get(ownerKey) ?? records.get(profileKey);
|
|
1729
|
+
if (existing?.slot && isCurrentThreadRecord(existing)) {
|
|
1730
|
+
return existing.slot;
|
|
1731
|
+
}
|
|
1732
|
+
const nowMs = getNowMs();
|
|
1733
|
+
if (
|
|
1734
|
+
preferredSlot &&
|
|
1735
|
+
!isTelegramTopicTargetSlotOccupied(
|
|
1736
|
+
preferredSlot,
|
|
1737
|
+
records,
|
|
1738
|
+
reservations,
|
|
1739
|
+
pendingProvisions,
|
|
1740
|
+
nowMs,
|
|
1741
|
+
)
|
|
1742
|
+
) {
|
|
1743
|
+
return preferredSlot;
|
|
1744
|
+
}
|
|
1745
|
+
return getNextMonotonicSlot(
|
|
1746
|
+
records,
|
|
1747
|
+
reservations,
|
|
1748
|
+
pendingProvisions,
|
|
1749
|
+
nowMs,
|
|
1750
|
+
botState.lastSlot,
|
|
1751
|
+
);
|
|
1752
|
+
},
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function isTelegramTopicTargetSlotOccupied(
|
|
1757
|
+
slot: string,
|
|
1758
|
+
records: Map<string, TelegramTopicTargetRecord>,
|
|
1759
|
+
reservations: readonly TelegramThreadReservation[] = [],
|
|
1760
|
+
pendingProvisions: readonly TelegramThreadPendingProvision[] = [],
|
|
1761
|
+
nowMs = Date.now(),
|
|
1762
|
+
): boolean {
|
|
1763
|
+
for (const record of records.values()) {
|
|
1764
|
+
if (record.slot === slot && isCurrentThreadRecord(record)) return true;
|
|
1765
|
+
}
|
|
1766
|
+
for (const reservation of reservations) {
|
|
1767
|
+
if (
|
|
1768
|
+
reservation.expiresAtMs !== undefined &&
|
|
1769
|
+
reservation.expiresAtMs <= nowMs
|
|
1770
|
+
)
|
|
1771
|
+
continue;
|
|
1772
|
+
if (reservation.slot === slot) return true;
|
|
1773
|
+
}
|
|
1774
|
+
for (const provision of pendingProvisions) {
|
|
1775
|
+
if (
|
|
1776
|
+
provision.status !== "ambiguous" &&
|
|
1777
|
+
provision.expiresAtMs !== undefined &&
|
|
1778
|
+
provision.expiresAtMs <= nowMs
|
|
1779
|
+
)
|
|
1780
|
+
continue;
|
|
1781
|
+
if (provision.slot === slot) return true;
|
|
1782
|
+
}
|
|
1783
|
+
return false;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
export function normalizeTelegramTopicTargetThreadName(
|
|
1787
|
+
threadName: string,
|
|
1788
|
+
): string {
|
|
1789
|
+
return threadName.replace(/\s+/g, " ").trim().slice(0, 96);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function getGraphemeSegments(value: string): string[] {
|
|
1793
|
+
const segmenter = (
|
|
1794
|
+
Intl as unknown as {
|
|
1795
|
+
Segmenter?: new (
|
|
1796
|
+
locale?: string,
|
|
1797
|
+
options?: { granularity: "grapheme" },
|
|
1798
|
+
) => { segment(input: string): Iterable<{ segment: string }> };
|
|
1799
|
+
}
|
|
1800
|
+
).Segmenter;
|
|
1801
|
+
if (!segmenter) return Array.from(value);
|
|
1802
|
+
return Array.from(
|
|
1803
|
+
new segmenter(undefined, { granularity: "grapheme" }).segment(value),
|
|
1804
|
+
(part) => part.segment,
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
export function getTelegramTopicIdentityName(threadName: string): string {
|
|
1809
|
+
return getGraphemeSegments(normalizeTelegramTopicTargetThreadName(threadName))
|
|
1810
|
+
.join("")
|
|
1811
|
+
.trim();
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const TELEGRAM_THREAD_NAME_PALETTE: Record<string, readonly string[]> = {
|
|
1815
|
+
A: ["Atlas", "Aster", "Aurora", "Anchor", "Ashen"],
|
|
1816
|
+
B: ["Beacon", "Briar", "Boreal", "Birch", "Bison"],
|
|
1817
|
+
C: ["Cedar", "Comet", "Cipher", "Coral", "Cinder"],
|
|
1818
|
+
D: ["Delta", "Dawn", "Drift", "Dune", "Dagger"],
|
|
1819
|
+
E: ["Ember", "Echo", "Eagle", "Eden", "Elder"],
|
|
1820
|
+
F: ["Falcon", "Fjord", "Flint", "Forest", "Fable"],
|
|
1821
|
+
G: ["Grove", "Glade", "Glyph", "Garnet", "Gale"],
|
|
1822
|
+
H: ["Harbor", "Hawk", "Hazel", "Helix", "Haven"],
|
|
1823
|
+
I: ["Iris", "Ivory", "Iron", "Isle", "Idea"],
|
|
1824
|
+
J: ["Jade", "Juno", "Jolt", "Jewel", "Jasper"],
|
|
1825
|
+
K: ["Kite", "Karma", "Kernel", "Kodiak", "Kelp"],
|
|
1826
|
+
L: ["Lumen", "Laurel", "Lynx", "Lotus", "Lagoon"],
|
|
1827
|
+
M: ["Maple", "Meteor", "Meadow", "Marble", "Moss"],
|
|
1828
|
+
N: ["Nimbus", "Nova", "Nectar", "North", "Noble"],
|
|
1829
|
+
O: ["Orion", "Onyx", "Opal", "Orbit", "Olive"],
|
|
1830
|
+
P: ["Pine", "Pulse", "Praxis", "Pebble", "Prism"],
|
|
1831
|
+
Q: ["Quartz", "Quill", "Quasar", "Quest", "Quiver"],
|
|
1832
|
+
R: ["River", "Raven", "Rune", "Reef", "Ridge"],
|
|
1833
|
+
S: ["Spruce", "Solar", "Signal", "Stone", "Sable"],
|
|
1834
|
+
T: ["Timber", "Talon", "Terra", "Torch", "Tide"],
|
|
1835
|
+
U: ["Umber", "Unity", "Ursa", "Uplink", "Ulmus"],
|
|
1836
|
+
V: ["Violet", "Vector", "Vista", "Vale", "Vortex"],
|
|
1837
|
+
W: ["Willow", "Warden", "Wave", "Winter", "Wisp"],
|
|
1838
|
+
X: ["Xenon", "Xylem", "Xavier", "Xylo", "Xerus"],
|
|
1839
|
+
Y: ["Yarrow", "Yonder", "Yukon", "Yale", "Yogi"],
|
|
1840
|
+
Z: ["Zenith", "Zephyr", "Zircon", "Zebra", "Zion"],
|
|
1841
|
+
};
|
|
1842
|
+
|
|
1843
|
+
export function chooseTelegramThreadName(input: {
|
|
1844
|
+
slot: string | undefined;
|
|
1845
|
+
entropy?: number | string;
|
|
1846
|
+
getRandom?: () => number;
|
|
1847
|
+
}): string | undefined {
|
|
1848
|
+
if (!input.slot || !/^[A-Z]$/.test(input.slot)) return undefined;
|
|
1849
|
+
const names = TELEGRAM_THREAD_NAME_PALETTE[input.slot];
|
|
1850
|
+
if (!names || names.length === 0) return undefined;
|
|
1851
|
+
const index = input.getRandom
|
|
1852
|
+
? Math.max(
|
|
1853
|
+
0,
|
|
1854
|
+
Math.min(
|
|
1855
|
+
names.length - 1,
|
|
1856
|
+
Math.floor(input.getRandom() * names.length),
|
|
1857
|
+
),
|
|
1858
|
+
)
|
|
1859
|
+
: getTelegramThreadNameEntropyIndex(input.entropy, names.length);
|
|
1860
|
+
return names[index];
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function getTelegramThreadNameLeadingSlot(
|
|
1864
|
+
threadName: string | undefined,
|
|
1865
|
+
): string | undefined {
|
|
1866
|
+
if (!threadName) return undefined;
|
|
1867
|
+
const first = getTelegramTopicIdentityName(threadName)[0];
|
|
1868
|
+
return first && /^[A-Z]$/.test(first) ? first : undefined;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function isTelegramSlotOccupiedByOtherCurrentRecord(
|
|
1872
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
1873
|
+
slot: string,
|
|
1874
|
+
currentRecord: TelegramTopicTargetRecord,
|
|
1875
|
+
): boolean {
|
|
1876
|
+
return records.some(
|
|
1877
|
+
(record) =>
|
|
1878
|
+
!targetMatches(record.target, currentRecord.target) &&
|
|
1879
|
+
isCurrentThreadRecord(record) &&
|
|
1880
|
+
record.slot === slot,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function normalizeCurrentThreadNameSlots(
|
|
1885
|
+
store: Pick<TelegramTopicTargetStore, "list" | "upsert">,
|
|
1886
|
+
): void {
|
|
1887
|
+
for (const record of store.list()) {
|
|
1888
|
+
if (!isCurrentThreadRecord(record)) continue;
|
|
1889
|
+
const slot = getTelegramThreadNameLeadingSlot(record.threadName);
|
|
1890
|
+
if (!slot || record.slot === slot) continue;
|
|
1891
|
+
if (isTelegramSlotOccupiedByOtherCurrentRecord(store.list(), slot, record))
|
|
1892
|
+
continue;
|
|
1893
|
+
store.upsert({ ...record, slot });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function getNextTelegramThreadNamePaletteSlot(
|
|
1898
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
1899
|
+
fallbackSlot: string | undefined,
|
|
1900
|
+
): string | undefined {
|
|
1901
|
+
let maxCode = "A".charCodeAt(0) - 1;
|
|
1902
|
+
for (const record of records) {
|
|
1903
|
+
if (!isCurrentThreadRecord(record) || !record.threadName) continue;
|
|
1904
|
+
const identity = getTelegramTopicIdentityName(record.threadName);
|
|
1905
|
+
const first = identity[0];
|
|
1906
|
+
if (!first || !/^[A-Z]$/.test(first)) continue;
|
|
1907
|
+
maxCode = Math.max(maxCode, first.charCodeAt(0));
|
|
1908
|
+
}
|
|
1909
|
+
if (maxCode < "A".charCodeAt(0)) return fallbackSlot;
|
|
1910
|
+
let code = maxCode + 1;
|
|
1911
|
+
if (code > "Z".charCodeAt(0)) code = "A".charCodeAt(0);
|
|
1912
|
+
return String.fromCharCode(code);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function getTelegramThreadNameEntropyIndex(
|
|
1916
|
+
entropy: number | string | undefined,
|
|
1917
|
+
length: number,
|
|
1918
|
+
): number {
|
|
1919
|
+
if (length <= 1) return 0;
|
|
1920
|
+
if (typeof entropy === "number" && entropy < 1_000_000_000_000) return 0;
|
|
1921
|
+
const value = entropy === undefined ? "0" : String(entropy);
|
|
1922
|
+
let hash = 2166136261;
|
|
1923
|
+
for (const char of value) {
|
|
1924
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
1925
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
1926
|
+
}
|
|
1927
|
+
return hash % length;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
export function getTelegramTopicThreadNameValidationError(
|
|
1931
|
+
threadName: string,
|
|
1932
|
+
_slot: string | undefined,
|
|
1933
|
+
): string | undefined {
|
|
1934
|
+
const identity = getTelegramTopicIdentityName(threadName);
|
|
1935
|
+
const reasons: string[] = [];
|
|
1936
|
+
if (!identity) reasons.push("it is empty after trimming");
|
|
1937
|
+
if (/\s/.test(identity)) reasons.push("it contains spaces");
|
|
1938
|
+
if (/[^A-Za-z]/.test(identity)) {
|
|
1939
|
+
reasons.push("it contains characters outside Latin A-Z letters");
|
|
1940
|
+
}
|
|
1941
|
+
if (!/^[A-Z]/.test(identity)) {
|
|
1942
|
+
reasons.push("it does not start with an uppercase Latin letter");
|
|
1943
|
+
}
|
|
1944
|
+
const genericLabels = new Set(["telegram", "leader", "follower"]);
|
|
1945
|
+
if (genericLabels.has(identity.toLowerCase())) {
|
|
1946
|
+
reasons.push("it is a generic role label");
|
|
1947
|
+
}
|
|
1948
|
+
if (/^[A-Z]$/.test(identity)) {
|
|
1949
|
+
reasons.push("it is only a bare slot letter");
|
|
1950
|
+
}
|
|
1951
|
+
if (reasons.length === 0) return undefined;
|
|
1952
|
+
return `Invalid Telegram instance name: ${reasons.join("; ")}. Use exactly one capitalized Latin word with no spaces, punctuation, emoji, non-Latin letters, or digits; it must not be a generic role label or only a bare slot letter.`;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
export function isTelegramTopicThreadNameValidForSlot(
|
|
1956
|
+
threadName: string,
|
|
1957
|
+
slot: string | undefined,
|
|
1958
|
+
): boolean {
|
|
1959
|
+
return !getTelegramTopicThreadNameValidationError(threadName, slot);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function applyTopicNameTemplate(
|
|
1963
|
+
template: string,
|
|
1964
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
1965
|
+
slot?: string,
|
|
1966
|
+
): string {
|
|
1967
|
+
const threadName =
|
|
1968
|
+
request.threadName?.replace(/\s+/g, " ").trim() || request.profileKey;
|
|
1969
|
+
let result = template
|
|
1970
|
+
.replaceAll("{threadName}", threadName)
|
|
1971
|
+
.replaceAll("{profileKey}", request.profileKey)
|
|
1972
|
+
.replaceAll("{instanceId}", request.instanceId);
|
|
1973
|
+
if (slot) result = result.replaceAll("{slot}", slot);
|
|
1974
|
+
return result;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
export function getTelegramTopicName(
|
|
1978
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
1979
|
+
template = "{slot}",
|
|
1980
|
+
slot?: string,
|
|
1981
|
+
): string {
|
|
1982
|
+
const name = applyTopicNameTemplate(template, request, slot)
|
|
1983
|
+
.replace(/\s+/g, " ")
|
|
1984
|
+
.trim();
|
|
1985
|
+
return (name || slot || "Pi").slice(0, 128);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function asInteger(value: unknown): number | undefined {
|
|
1989
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
1990
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
1991
|
+
const parsed = Number(value);
|
|
1992
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
export interface TelegramPromoteFollowerBindingToLeaderDeps {
|
|
1996
|
+
store: TelegramTopicTargetStore;
|
|
1997
|
+
instanceId: string;
|
|
1998
|
+
cwd?: string;
|
|
1999
|
+
telegramProfile?: string;
|
|
2000
|
+
target?: TelegramTarget;
|
|
2001
|
+
slot?: string;
|
|
2002
|
+
threadName?: string;
|
|
2003
|
+
nowMs?: number;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
export async function promoteTelegramFollowerBindingToLeader(
|
|
2007
|
+
deps: TelegramPromoteFollowerBindingToLeaderDeps,
|
|
2008
|
+
): Promise<TelegramTopicTargetRecord | undefined> {
|
|
2009
|
+
const target = deps.target;
|
|
2010
|
+
if (typeof target?.threadId !== "number") return undefined;
|
|
2011
|
+
await deps.store.load();
|
|
2012
|
+
const nowMs = deps.nowMs ?? Date.now();
|
|
2013
|
+
const existing = deps.store
|
|
2014
|
+
.list()
|
|
2015
|
+
.find(
|
|
2016
|
+
(record) =>
|
|
2017
|
+
record.target.chatId === target.chatId &&
|
|
2018
|
+
record.target.threadId === target.threadId,
|
|
2019
|
+
);
|
|
2020
|
+
const owner: TelegramThreadOwner = {
|
|
2021
|
+
kind: "leader",
|
|
2022
|
+
cwd: deps.cwd,
|
|
2023
|
+
instanceId: deps.instanceId,
|
|
2024
|
+
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
|
|
2025
|
+
};
|
|
2026
|
+
const record = deps.store.upsert({
|
|
2027
|
+
profileKey: getTelegramThreadOwnerKey(owner),
|
|
2028
|
+
owner,
|
|
2029
|
+
target: { chatId: target.chatId, threadId: target.threadId },
|
|
2030
|
+
status: "active",
|
|
2031
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
2032
|
+
updatedAtMs: nowMs,
|
|
2033
|
+
...((existing?.threadName ?? deps.threadName)
|
|
2034
|
+
? { threadName: existing?.threadName ?? deps.threadName }
|
|
2035
|
+
: {}),
|
|
2036
|
+
instanceId: deps.instanceId,
|
|
2037
|
+
...((existing?.slot ?? deps.slot)
|
|
2038
|
+
? { slot: existing?.slot ?? deps.slot }
|
|
2039
|
+
: {}),
|
|
2040
|
+
...(existing?.syncStatus ? { syncStatus: existing.syncStatus } : {}),
|
|
2041
|
+
...(existing?.lastSyncObservedAtMs !== undefined
|
|
2042
|
+
? { lastSyncObservedAtMs: existing.lastSyncObservedAtMs }
|
|
2043
|
+
: {}),
|
|
2044
|
+
lastReconcileAction: "follower-promoted-to-leader",
|
|
2045
|
+
...(existing?.rerouteConfirmedAtMs !== undefined
|
|
2046
|
+
? { rerouteConfirmedAtMs: existing.rerouteConfirmedAtMs }
|
|
2047
|
+
: {}),
|
|
2048
|
+
});
|
|
2049
|
+
await deps.store.persist();
|
|
2050
|
+
return record;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
export interface TelegramOwnTopicProvisionDeps {
|
|
2054
|
+
getAllowedUserId: () => number | undefined;
|
|
2055
|
+
instanceId: string;
|
|
2056
|
+
cwd?: string;
|
|
2057
|
+
telegramProfile?: string;
|
|
2058
|
+
getNowMs?: () => number;
|
|
2059
|
+
getRandom?: () => number;
|
|
2060
|
+
getCurrentLeaderEpoch?: () => number | string | undefined;
|
|
2061
|
+
getThreadReconciliationMachineState?: () =>
|
|
2062
|
+
ThreadReconciler.ThreadReconciliationMachineState | undefined;
|
|
2063
|
+
recordThreadReconciliationPlan?: (
|
|
2064
|
+
plan: ThreadReconciler.ThreadReconciliationPlan,
|
|
2065
|
+
) => void;
|
|
2066
|
+
store: TelegramTopicTargetStore;
|
|
2067
|
+
callApi: <TResponse>(
|
|
2068
|
+
method: string,
|
|
2069
|
+
body: Record<string, unknown>,
|
|
2070
|
+
) => Promise<TResponse>;
|
|
2071
|
+
recordEvent: (
|
|
2072
|
+
category: string,
|
|
2073
|
+
message: string,
|
|
2074
|
+
details?: Record<string, unknown>,
|
|
2075
|
+
) => void;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
export interface TelegramOwnTopicProvisionResult {
|
|
2079
|
+
target: TelegramTarget & { threadId: number };
|
|
2080
|
+
slot: string;
|
|
2081
|
+
threadName?: string;
|
|
2082
|
+
reused: boolean;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/**
|
|
2086
|
+
* Provision a topic for the bus leader's own use (slot A).
|
|
2087
|
+
* This is a thread-binding primitive; sync policy decides when startup/connect
|
|
2088
|
+
* should call it to ensure the leader has a visible working thread.
|
|
2089
|
+
*/
|
|
2090
|
+
export async function provisionOwnBusTopic(
|
|
2091
|
+
deps: TelegramOwnTopicProvisionDeps,
|
|
2092
|
+
): Promise<TelegramOwnTopicProvisionResult | undefined> {
|
|
2093
|
+
const chatId = deps.getAllowedUserId();
|
|
2094
|
+
let profileKey = getTelegramThreadOwnerKey({
|
|
2095
|
+
kind: "leader",
|
|
2096
|
+
cwd: deps.cwd,
|
|
2097
|
+
instanceId: deps.instanceId,
|
|
2098
|
+
telegramProfile: deps.telegramProfile,
|
|
2099
|
+
});
|
|
2100
|
+
if (typeof chatId !== "number") return undefined;
|
|
2101
|
+
await deps.store.load();
|
|
2102
|
+
const reservationCleanupPorts = {
|
|
2103
|
+
callApi: deps.callApi,
|
|
2104
|
+
markStaleByTarget: (
|
|
2105
|
+
target: TelegramTarget & { threadId: number },
|
|
2106
|
+
syncStatus?: "closed" | "deleted",
|
|
2107
|
+
lastSyncError?: string,
|
|
2108
|
+
) => deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
2109
|
+
removePendingProvisionById: (id: string) =>
|
|
2110
|
+
deps.store.removePendingProvision(id),
|
|
2111
|
+
persist: () => deps.store.persist(),
|
|
2112
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2113
|
+
recordRuntimeEvent(
|
|
2114
|
+
category: string,
|
|
2115
|
+
error: unknown,
|
|
2116
|
+
details?: Record<string, unknown>,
|
|
2117
|
+
) {
|
|
2118
|
+
deps.recordEvent(
|
|
2119
|
+
category,
|
|
2120
|
+
error instanceof Error ? error.message : String(error),
|
|
2121
|
+
details,
|
|
2122
|
+
);
|
|
2123
|
+
},
|
|
2124
|
+
};
|
|
2125
|
+
const reservationCleanupNowMs = Date.now();
|
|
2126
|
+
const reservationsBeforeCleanup = deps.store.listReservations();
|
|
2127
|
+
const reservationCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
2128
|
+
nowMs: reservationCleanupNowMs,
|
|
2129
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
2130
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
2131
|
+
records: deps.store.list(),
|
|
2132
|
+
reservations: reservationsBeforeCleanup,
|
|
2133
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
2134
|
+
proactiveReservationCleanup: true,
|
|
2135
|
+
});
|
|
2136
|
+
deps.recordThreadReconciliationPlan?.(reservationCleanupPlan);
|
|
2137
|
+
const reservationCleanupApplyStartedAtMs = Date.now();
|
|
2138
|
+
await ThreadReconciler.applyThreadReconciliationPlan(
|
|
2139
|
+
reservationCleanupPlan,
|
|
2140
|
+
reservationCleanupPorts,
|
|
2141
|
+
);
|
|
2142
|
+
deps.recordEvent("bus", "Bus leader reservation cleanup reconciled", {
|
|
2143
|
+
phase: "leader-topic-reservation-cleanup-duration",
|
|
2144
|
+
durationMs: Date.now() - reservationCleanupApplyStartedAtMs,
|
|
2145
|
+
actions: reservationCleanupPlan.actions.length,
|
|
2146
|
+
});
|
|
2147
|
+
const nowMs = Date.now();
|
|
2148
|
+
const currentLeaderOwner: TelegramThreadOwner = {
|
|
2149
|
+
kind: "leader",
|
|
2150
|
+
cwd: deps.cwd,
|
|
2151
|
+
instanceId: deps.instanceId,
|
|
2152
|
+
...(deps.telegramProfile ? { telegramProfile: deps.telegramProfile } : {}),
|
|
2153
|
+
};
|
|
2154
|
+
const leaderSessionHandoff = getTelegramLeaderSessionHandoff();
|
|
2155
|
+
if (
|
|
2156
|
+
isTelegramLeaderSessionHandoffFresh(leaderSessionHandoff) &&
|
|
2157
|
+
leaderSessionHandoff.profileKey === profileKey
|
|
2158
|
+
) {
|
|
2159
|
+
const existingHandoffRecord = deps.store
|
|
2160
|
+
.list()
|
|
2161
|
+
.find((record) =>
|
|
2162
|
+
targetMatches(record.target, leaderSessionHandoff.target),
|
|
2163
|
+
);
|
|
2164
|
+
deps.store.upsert({
|
|
2165
|
+
profileKey,
|
|
2166
|
+
owner: currentLeaderOwner,
|
|
2167
|
+
target: { ...leaderSessionHandoff.target },
|
|
2168
|
+
status: "active",
|
|
2169
|
+
createdAtMs:
|
|
2170
|
+
existingHandoffRecord?.createdAtMs ?? leaderSessionHandoff.createdAtMs,
|
|
2171
|
+
updatedAtMs: nowMs,
|
|
2172
|
+
threadName:
|
|
2173
|
+
existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
|
|
2174
|
+
instanceId: deps.instanceId,
|
|
2175
|
+
slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
|
|
2176
|
+
...(existingHandoffRecord?.syncStatus
|
|
2177
|
+
? { syncStatus: existingHandoffRecord.syncStatus }
|
|
2178
|
+
: {}),
|
|
2179
|
+
...(existingHandoffRecord?.lastSyncObservedAtMs !== undefined
|
|
2180
|
+
? {
|
|
2181
|
+
lastSyncObservedAtMs: existingHandoffRecord.lastSyncObservedAtMs,
|
|
2182
|
+
}
|
|
2183
|
+
: {}),
|
|
2184
|
+
lastReconcileAction: "leader-session-handoff-restored",
|
|
2185
|
+
});
|
|
2186
|
+
await deps.store.persist();
|
|
2187
|
+
setTelegramLeaderSessionHandoff(undefined);
|
|
2188
|
+
deps.recordEvent("bus", "Bus leader session handoff restored", {
|
|
2189
|
+
phase: "leader-session-handoff-restore",
|
|
2190
|
+
chatId: leaderSessionHandoff.target.chatId,
|
|
2191
|
+
threadId: leaderSessionHandoff.target.threadId,
|
|
2192
|
+
slot: existingHandoffRecord?.slot ?? leaderSessionHandoff.slot,
|
|
2193
|
+
threadName:
|
|
2194
|
+
existingHandoffRecord?.threadName ?? leaderSessionHandoff.threadName,
|
|
2195
|
+
previousInstanceId: leaderSessionHandoff.instanceId,
|
|
2196
|
+
instanceId: deps.instanceId,
|
|
2197
|
+
});
|
|
2198
|
+
} else if (leaderSessionHandoff) {
|
|
2199
|
+
setTelegramLeaderSessionHandoff(undefined);
|
|
2200
|
+
}
|
|
2201
|
+
const recordsBeforePreviousLeaderCleanup = deps.store.list();
|
|
2202
|
+
const previousLeaderCleanupPlan = ThreadReconciler.planThreadReconciliation({
|
|
2203
|
+
nowMs,
|
|
2204
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
|
|
2205
|
+
previousState: deps.getThreadReconciliationMachineState?.(),
|
|
2206
|
+
records: recordsBeforePreviousLeaderCleanup.map((record) => ({
|
|
2207
|
+
...record,
|
|
2208
|
+
ownerKind: record.owner?.kind,
|
|
2209
|
+
})),
|
|
2210
|
+
pendingProvisions: deps.store.listPendingProvisions(),
|
|
2211
|
+
previousLeaderCleanup: { currentInstanceId: deps.instanceId },
|
|
2212
|
+
});
|
|
2213
|
+
deps.recordThreadReconciliationPlan?.(previousLeaderCleanupPlan);
|
|
2214
|
+
for (const action of previousLeaderCleanupPlan.actions) {
|
|
2215
|
+
if (action.kind !== "close-delete-previous-leader-topic") continue;
|
|
2216
|
+
const record = recordsBeforePreviousLeaderCleanup.find((candidate) =>
|
|
2217
|
+
targetMatches(candidate.target, action.target),
|
|
2218
|
+
);
|
|
2219
|
+
if (!record) continue;
|
|
2220
|
+
const isSameProfile = record.profileKey === profileKey;
|
|
2221
|
+
if (isSameProfile) {
|
|
2222
|
+
deps.recordEvent("bus", "Bus leader same-profile topic preserved", {
|
|
2223
|
+
phase: "leader-topic-same-profile-preserve",
|
|
2224
|
+
chatId: record.target.chatId,
|
|
2225
|
+
threadId: record.target.threadId,
|
|
2226
|
+
slot: record.slot,
|
|
2227
|
+
previousInstanceId: record.instanceId,
|
|
2228
|
+
instanceId: deps.instanceId,
|
|
2229
|
+
profileKey,
|
|
2230
|
+
});
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
if (isSameProcessInstance(record.instanceId, deps.instanceId)) {
|
|
2234
|
+
deps.store.upsert({
|
|
2235
|
+
...record,
|
|
2236
|
+
profileKey,
|
|
2237
|
+
owner: currentLeaderOwner,
|
|
2238
|
+
status: "active",
|
|
2239
|
+
instanceId: deps.instanceId,
|
|
2240
|
+
updatedAtMs: nowMs,
|
|
2241
|
+
lastError: undefined,
|
|
2242
|
+
lastReconcileAction: "leader-topic-same-process-preserve",
|
|
2243
|
+
});
|
|
2244
|
+
deps.recordEvent("bus", "Bus leader same-process topic preserved", {
|
|
2245
|
+
phase: "leader-topic-same-process-preserve",
|
|
2246
|
+
chatId: record.target.chatId,
|
|
2247
|
+
threadId: record.target.threadId,
|
|
2248
|
+
slot: record.slot,
|
|
2249
|
+
previousInstanceId: record.instanceId,
|
|
2250
|
+
instanceId: deps.instanceId,
|
|
2251
|
+
profileKey,
|
|
2252
|
+
});
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
const previousLeaderCleanupStartedAtMs = Date.now();
|
|
2256
|
+
const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
|
|
2257
|
+
{ actions: [action] },
|
|
2258
|
+
{
|
|
2259
|
+
callApi: deps.callApi,
|
|
2260
|
+
markStaleByTarget: (target, syncStatus, lastSyncError) =>
|
|
2261
|
+
deps.store.markStaleByTarget(target, syncStatus, lastSyncError),
|
|
2262
|
+
persist: () => deps.store.persist(),
|
|
2263
|
+
removePendingProvisionById: (id) =>
|
|
2264
|
+
deps.store.removePendingProvision(id),
|
|
2265
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2266
|
+
recordRuntimeEvent(category, error, details) {
|
|
2267
|
+
deps.recordEvent(
|
|
2268
|
+
category,
|
|
2269
|
+
error instanceof Error ? error.message : String(error),
|
|
2270
|
+
details,
|
|
2271
|
+
);
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
);
|
|
2275
|
+
deps.recordEvent("bus", "Bus leader previous-topic cleanup applied", {
|
|
2276
|
+
phase: "leader-topic-previous-cleanup-duration",
|
|
2277
|
+
durationMs: Date.now() - previousLeaderCleanupStartedAtMs,
|
|
2278
|
+
chatId: record.target.chatId,
|
|
2279
|
+
threadId: record.target.threadId,
|
|
2280
|
+
slot: record.slot,
|
|
2281
|
+
});
|
|
2282
|
+
if (
|
|
2283
|
+
deps.getCurrentLeaderEpoch &&
|
|
2284
|
+
(action.leaderEpoch === undefined ||
|
|
2285
|
+
deps.getCurrentLeaderEpoch() !== action.leaderEpoch)
|
|
2286
|
+
) {
|
|
2287
|
+
deps.recordEvent(
|
|
2288
|
+
"bus",
|
|
2289
|
+
"Skipped previous-topic local cleanup after leader epoch loss",
|
|
2290
|
+
{
|
|
2291
|
+
phase: "leader-topic-previous-cleanup-stale-epoch-skip",
|
|
2292
|
+
actionLeaderEpoch: action.leaderEpoch,
|
|
2293
|
+
currentLeaderEpoch: deps.getCurrentLeaderEpoch(),
|
|
2294
|
+
chatId: record.target.chatId,
|
|
2295
|
+
threadId: record.target.threadId,
|
|
2296
|
+
},
|
|
2297
|
+
);
|
|
2298
|
+
throw new Error(
|
|
2299
|
+
"Telegram leader ownership changed during topic reconciliation.",
|
|
2300
|
+
);
|
|
2301
|
+
}
|
|
2302
|
+
if (cleanup.incompleteActions?.length) {
|
|
2303
|
+
throw new Error(
|
|
2304
|
+
"Previous Telegram leader topic deletion was not confirmed.",
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
deps.store.markStaleByTarget(record.target);
|
|
2308
|
+
deps.store.reserveThread({
|
|
2309
|
+
target: record.target,
|
|
2310
|
+
slot: record.slot ?? "A",
|
|
2311
|
+
reason: "previous-process-cleaned-without-visible-probe",
|
|
2312
|
+
createdAtMs: nowMs,
|
|
2313
|
+
updatedAtMs: nowMs,
|
|
2314
|
+
expiresAtMs: nowMs + TELEGRAM_THREAD_RESERVATION_TTL_MS,
|
|
2315
|
+
instanceId: record.instanceId,
|
|
2316
|
+
lastReconcileAction: "leader-topic-previous-instance-cleaned-no-probe",
|
|
2317
|
+
});
|
|
2318
|
+
deps.store.setBotState({
|
|
2319
|
+
threadMode: "enabled",
|
|
2320
|
+
updatedAtMs: nowMs,
|
|
2321
|
+
lastReconcileAction: "leader-topic-next-slot-after-unprobed-previous",
|
|
2322
|
+
});
|
|
2323
|
+
deps.recordEvent(
|
|
2324
|
+
"bus",
|
|
2325
|
+
"Bus leader previous-process topic reserved after cleanup without visible probe",
|
|
2326
|
+
{
|
|
2327
|
+
phase: "leader-topic-previous-instance-reserve-no-probe",
|
|
2328
|
+
chatId: record.target.chatId,
|
|
2329
|
+
threadId: record.target.threadId,
|
|
2330
|
+
slot: record.slot,
|
|
2331
|
+
previousInstanceId: record.instanceId,
|
|
2332
|
+
instanceId: deps.instanceId,
|
|
2333
|
+
},
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
const provision = createTelegramTopicTargetProvisioner({
|
|
2337
|
+
topicChatId: chatId,
|
|
2338
|
+
store: deps.store,
|
|
2339
|
+
callApi: deps.callApi,
|
|
2340
|
+
getNowMs: deps.getNowMs,
|
|
2341
|
+
getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
|
|
2342
|
+
getRandom: deps.getRandom,
|
|
2343
|
+
claimPendingTargets: false,
|
|
2344
|
+
});
|
|
2345
|
+
let result = await provision({
|
|
2346
|
+
instanceId: deps.instanceId,
|
|
2347
|
+
owner: currentLeaderOwner,
|
|
2348
|
+
profileKey,
|
|
2349
|
+
});
|
|
2350
|
+
if (result.reused) {
|
|
2351
|
+
// Reused topics may already have a human-chosen Telegram title. Do not edit
|
|
2352
|
+
// them during leader startup: startup reconciliation must not reset a named
|
|
2353
|
+
// topic back to its bare slot or create redundant "renamed the thread" service
|
|
2354
|
+
// messages. Also do not probe with Bot API chat actions: every chat action is
|
|
2355
|
+
// user-visible as native typing/activity, so reload would falsely signal that
|
|
2356
|
+
// the agent is working. Treat the reused binding as optimistically open;
|
|
2357
|
+
// ordinary target-scoped sends still detect stale topics and trigger the
|
|
2358
|
+
// stale-api-error reconciliation path when real delivery happens.
|
|
2359
|
+
const nowMs = Date.now();
|
|
2360
|
+
deps.store.upsert({
|
|
2361
|
+
...result.record,
|
|
2362
|
+
syncStatus: "open",
|
|
2363
|
+
lastSyncObservedAtMs: nowMs,
|
|
2364
|
+
lastReconcileAction: "leader-startup-skip-probe",
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
deps.store.setBotState({
|
|
2368
|
+
threadMode: "enabled",
|
|
2369
|
+
updatedAtMs: Date.now(),
|
|
2370
|
+
lastReconcileAction: result.reused
|
|
2371
|
+
? "leader-startup-skip-probe"
|
|
2372
|
+
: "leader-topic-created",
|
|
2373
|
+
});
|
|
2374
|
+
await deps.store.persist();
|
|
2375
|
+
deps.recordEvent("bus", "Bus leader own topic assigned", {
|
|
2376
|
+
phase: "leader-topic",
|
|
2377
|
+
chatId: result.target.chatId,
|
|
2378
|
+
threadId: result.target.threadId,
|
|
2379
|
+
slot: result.record.slot,
|
|
2380
|
+
threadName: result.record.threadName,
|
|
2381
|
+
reused: result.reused,
|
|
2382
|
+
});
|
|
2383
|
+
return {
|
|
2384
|
+
target: result.target,
|
|
2385
|
+
slot: result.record.slot ?? "A",
|
|
2386
|
+
...(result.record.threadName
|
|
2387
|
+
? { threadName: result.record.threadName }
|
|
2388
|
+
: {}),
|
|
2389
|
+
reused: result.reused,
|
|
2390
|
+
};
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
export interface TelegramInstanceThreadIdentityCandidate {
|
|
2394
|
+
target?: TelegramTarget;
|
|
2395
|
+
slot?: string;
|
|
2396
|
+
threadName?: string;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
export function resolveTelegramInstanceThreadIdentity(options: {
|
|
2400
|
+
target?: TelegramTarget;
|
|
2401
|
+
follower?: TelegramInstanceThreadIdentityCandidate;
|
|
2402
|
+
leader?: TelegramInstanceThreadIdentityCandidate;
|
|
2403
|
+
record?: TelegramTopicTargetRecord;
|
|
2404
|
+
}): TelegramInstanceThreadIdentityCandidate {
|
|
2405
|
+
const targetMatchesCandidate = (
|
|
2406
|
+
candidate: TelegramInstanceThreadIdentityCandidate | undefined,
|
|
2407
|
+
) => {
|
|
2408
|
+
if (!candidate) return false;
|
|
2409
|
+
if (!options.target) return true;
|
|
2410
|
+
return (
|
|
2411
|
+
!!candidate.target && targetMatches(candidate.target, options.target)
|
|
2412
|
+
);
|
|
2413
|
+
};
|
|
2414
|
+
const local = targetMatchesCandidate(options.follower)
|
|
2415
|
+
? options.follower
|
|
2416
|
+
: targetMatchesCandidate(options.leader)
|
|
2417
|
+
? options.leader
|
|
2418
|
+
: undefined;
|
|
2419
|
+
const record =
|
|
2420
|
+
options.record &&
|
|
2421
|
+
(!options.target || targetMatches(options.record.target, options.target))
|
|
2422
|
+
? options.record
|
|
2423
|
+
: undefined;
|
|
2424
|
+
return {
|
|
2425
|
+
...((local?.target ?? record?.target)
|
|
2426
|
+
? { target: local?.target ?? record?.target }
|
|
2427
|
+
: {}),
|
|
2428
|
+
...((local?.slot ?? record?.slot)
|
|
2429
|
+
? { slot: local?.slot ?? record?.slot }
|
|
2430
|
+
: {}),
|
|
2431
|
+
...((local?.threadName ?? record?.threadName)
|
|
2432
|
+
? { threadName: local?.threadName ?? record?.threadName }
|
|
2433
|
+
: {}),
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
export interface TelegramLeaderThreadStateRuntime {
|
|
2438
|
+
getTarget(): TelegramTarget | undefined;
|
|
2439
|
+
getIdentity(): TelegramInstanceThreadIdentityCandidate | undefined;
|
|
2440
|
+
set(
|
|
2441
|
+
input: TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget },
|
|
2442
|
+
): void;
|
|
2443
|
+
clear(): void;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
export function createTelegramLeaderThreadStateRuntime(): TelegramLeaderThreadStateRuntime {
|
|
2447
|
+
let identity:
|
|
2448
|
+
| (TelegramInstanceThreadIdentityCandidate & { target: TelegramTarget })
|
|
2449
|
+
| undefined;
|
|
2450
|
+
return {
|
|
2451
|
+
getTarget: () => identity?.target,
|
|
2452
|
+
getIdentity: () => identity,
|
|
2453
|
+
set(input) {
|
|
2454
|
+
identity = { ...input, target: { ...input.target } };
|
|
2455
|
+
},
|
|
2456
|
+
clear() {
|
|
2457
|
+
identity = undefined;
|
|
2458
|
+
},
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
export interface TelegramCurrentInstanceThreadRuntime {
|
|
2463
|
+
findRecord(): TelegramTopicTargetRecord | undefined;
|
|
2464
|
+
getRecord(): TelegramTopicTargetRecord | undefined;
|
|
2465
|
+
getIdentity(target?: TelegramTarget): TelegramInstanceThreadIdentityCandidate;
|
|
2466
|
+
getRestorationIdentity(): TelegramInstanceThreadIdentityCandidate;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
export interface TelegramCurrentInstanceThreadRuntimeDeps {
|
|
2470
|
+
instanceId: string;
|
|
2471
|
+
listRecords(): readonly TelegramTopicTargetRecord[];
|
|
2472
|
+
getPreferredTarget(): TelegramTarget | undefined;
|
|
2473
|
+
getFollower():
|
|
2474
|
+
| (TelegramInstanceThreadIdentityCandidate & { registered: boolean })
|
|
2475
|
+
| undefined;
|
|
2476
|
+
getLeader(): TelegramInstanceThreadIdentityCandidate | undefined;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
export function createTelegramCurrentInstanceThreadRuntime(
|
|
2480
|
+
deps: TelegramCurrentInstanceThreadRuntimeDeps,
|
|
2481
|
+
): TelegramCurrentInstanceThreadRuntime {
|
|
2482
|
+
const findRecord = function (): TelegramTopicTargetRecord | undefined {
|
|
2483
|
+
return findCurrentTelegramInstanceThreadRecord({
|
|
2484
|
+
records: deps.listRecords(),
|
|
2485
|
+
instanceId: deps.instanceId,
|
|
2486
|
+
preferredTarget: deps.getPreferredTarget(),
|
|
2487
|
+
});
|
|
2488
|
+
};
|
|
2489
|
+
const getRecord = function (): TelegramTopicTargetRecord | undefined {
|
|
2490
|
+
const record = findRecord();
|
|
2491
|
+
const follower = deps.getFollower();
|
|
2492
|
+
if (record?.owner?.kind === "manual-follower" && !follower?.registered) {
|
|
2493
|
+
return undefined;
|
|
2494
|
+
}
|
|
2495
|
+
return record;
|
|
2496
|
+
};
|
|
2497
|
+
return {
|
|
2498
|
+
findRecord,
|
|
2499
|
+
getRecord,
|
|
2500
|
+
getIdentity(target) {
|
|
2501
|
+
const follower = deps.getFollower();
|
|
2502
|
+
const record = target
|
|
2503
|
+
? findCurrentTelegramInstanceThreadRecord({
|
|
2504
|
+
records: deps.listRecords(),
|
|
2505
|
+
instanceId: deps.instanceId,
|
|
2506
|
+
preferredTarget: target,
|
|
2507
|
+
})
|
|
2508
|
+
: getRecord();
|
|
2509
|
+
return resolveTelegramInstanceThreadIdentity({
|
|
2510
|
+
target,
|
|
2511
|
+
follower: follower?.registered ? follower : undefined,
|
|
2512
|
+
leader: deps.getLeader(),
|
|
2513
|
+
record,
|
|
2514
|
+
});
|
|
2515
|
+
},
|
|
2516
|
+
getRestorationIdentity() {
|
|
2517
|
+
const follower = deps.getFollower();
|
|
2518
|
+
return resolveTelegramInstanceThreadIdentity({
|
|
2519
|
+
follower: follower?.registered ? follower : undefined,
|
|
2520
|
+
leader: deps.getLeader(),
|
|
2521
|
+
record: findRecord(),
|
|
2522
|
+
});
|
|
2523
|
+
},
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
export function findCurrentTelegramInstanceThreadRecord(options: {
|
|
2528
|
+
records: readonly TelegramTopicTargetRecord[];
|
|
2529
|
+
instanceId: string;
|
|
2530
|
+
preferredTarget?: TelegramTarget;
|
|
2531
|
+
}): TelegramTopicTargetRecord | undefined {
|
|
2532
|
+
const target = options.preferredTarget;
|
|
2533
|
+
if (typeof target?.threadId === "number") {
|
|
2534
|
+
const targetRecord = options.records.find((record) => {
|
|
2535
|
+
return (
|
|
2536
|
+
record.target.chatId === target.chatId &&
|
|
2537
|
+
record.target.threadId === target.threadId
|
|
2538
|
+
);
|
|
2539
|
+
});
|
|
2540
|
+
if (targetRecord) return targetRecord;
|
|
2541
|
+
}
|
|
2542
|
+
return options.records.find((record) => {
|
|
2543
|
+
return (
|
|
2544
|
+
record.instanceId === options.instanceId && record.status === "active"
|
|
2545
|
+
);
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
export function resolveTelegramInstanceThreadTarget(options: {
|
|
2550
|
+
followerTarget?: TelegramTarget;
|
|
2551
|
+
leaderTarget?: TelegramTarget;
|
|
2552
|
+
currentRecord?: TelegramTopicTargetRecord;
|
|
2553
|
+
}): (TelegramTarget & { threadId: number }) | undefined {
|
|
2554
|
+
const raw =
|
|
2555
|
+
typeof options.followerTarget?.threadId === "number"
|
|
2556
|
+
? options.followerTarget
|
|
2557
|
+
: (options.currentRecord?.target ?? options.leaderTarget);
|
|
2558
|
+
return raw &&
|
|
2559
|
+
typeof raw.chatId === "number" &&
|
|
2560
|
+
typeof raw.threadId === "number"
|
|
2561
|
+
? { chatId: raw.chatId, threadId: raw.threadId }
|
|
2562
|
+
: undefined;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
export interface TelegramThreadStatusProjectionRuntime {
|
|
2566
|
+
getBusRole(): "leader" | "follower" | undefined;
|
|
2567
|
+
getBusFollowers(): ReturnType<typeof listTelegramThreadStatusFollowers>;
|
|
2568
|
+
getLocalBus(): {
|
|
2569
|
+
leaderSocketPath: string;
|
|
2570
|
+
leaderTransport: "socket" | "pipe";
|
|
2571
|
+
followerSocketPath: string;
|
|
2572
|
+
followerTransport: "socket" | "pipe";
|
|
2573
|
+
followerRegistered: boolean;
|
|
2574
|
+
followerTarget?: TelegramTarget;
|
|
2575
|
+
followerSlot?: string;
|
|
2576
|
+
followerThreadName?: string;
|
|
2577
|
+
leaderProtocol?: {
|
|
2578
|
+
protocolVersion: number;
|
|
2579
|
+
runtimeBuild: string;
|
|
2580
|
+
capabilities: string[];
|
|
2581
|
+
};
|
|
2582
|
+
};
|
|
2583
|
+
getTopicTargets(): ReturnType<typeof listTelegramThreadStatusTargets>;
|
|
2584
|
+
getThreadReservations(): ReturnType<
|
|
2585
|
+
typeof listTelegramThreadStatusReservations
|
|
2586
|
+
>;
|
|
2587
|
+
getTopicSyncObservations(): ReturnType<
|
|
2588
|
+
typeof listTelegramThreadStatusObservations
|
|
2589
|
+
>;
|
|
2590
|
+
getInstanceSlot(): string | undefined;
|
|
2591
|
+
getInstanceThreadName(): string | undefined;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
export interface TelegramThreadStatusProjectionRuntimeDeps {
|
|
2595
|
+
getThreadMode(): "unknown" | "enabled" | "disabled";
|
|
2596
|
+
isBusPollingStarted(): boolean;
|
|
2597
|
+
isFollowerRegistered(): boolean;
|
|
2598
|
+
listFollowers(): readonly TelegramThreadStatusFollowerView[];
|
|
2599
|
+
listRecords(): readonly TelegramTopicTargetRecord[];
|
|
2600
|
+
listReservations(): readonly TelegramThreadReservation[];
|
|
2601
|
+
listSyncObservations(): readonly TelegramTopicSyncObservation[];
|
|
2602
|
+
getLeaderSocketPath(): string;
|
|
2603
|
+
getFollowerSocketPath(): string;
|
|
2604
|
+
getTransportKind(path: string): "socket" | "pipe";
|
|
2605
|
+
getFollowerTarget(): TelegramTarget | undefined;
|
|
2606
|
+
getFollowerSlot(): string | undefined;
|
|
2607
|
+
getFollowerThreadName(): string | undefined;
|
|
2608
|
+
getLeaderProtocol?():
|
|
2609
|
+
| {
|
|
2610
|
+
protocolVersion: number;
|
|
2611
|
+
runtimeBuild: string;
|
|
2612
|
+
capabilities: string[];
|
|
2613
|
+
}
|
|
2614
|
+
| undefined;
|
|
2615
|
+
getCurrentIdentity(): TelegramInstanceThreadIdentityCandidate;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
export function createTelegramThreadStatusProjectionRuntime(
|
|
2619
|
+
deps: TelegramThreadStatusProjectionRuntimeDeps,
|
|
2620
|
+
): TelegramThreadStatusProjectionRuntime {
|
|
2621
|
+
return {
|
|
2622
|
+
getBusRole() {
|
|
2623
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2624
|
+
if (deps.isBusPollingStarted()) return "leader";
|
|
2625
|
+
return deps.isFollowerRegistered() ? "follower" : undefined;
|
|
2626
|
+
},
|
|
2627
|
+
getBusFollowers() {
|
|
2628
|
+
return listTelegramThreadStatusFollowers({
|
|
2629
|
+
followers: deps.listFollowers(),
|
|
2630
|
+
records: deps.listRecords(),
|
|
2631
|
+
});
|
|
2632
|
+
},
|
|
2633
|
+
getLocalBus() {
|
|
2634
|
+
const leaderSocketPath = deps.getLeaderSocketPath();
|
|
2635
|
+
const followerSocketPath = deps.getFollowerSocketPath();
|
|
2636
|
+
const leaderProtocol = deps.getLeaderProtocol?.();
|
|
2637
|
+
return {
|
|
2638
|
+
leaderSocketPath,
|
|
2639
|
+
leaderTransport: deps.getTransportKind(leaderSocketPath),
|
|
2640
|
+
followerSocketPath,
|
|
2641
|
+
followerTransport: deps.getTransportKind(followerSocketPath),
|
|
2642
|
+
followerRegistered: deps.isFollowerRegistered(),
|
|
2643
|
+
followerTarget: deps.getFollowerTarget(),
|
|
2644
|
+
followerSlot: deps.getFollowerSlot(),
|
|
2645
|
+
followerThreadName: deps.getFollowerThreadName(),
|
|
2646
|
+
...(leaderProtocol ? { leaderProtocol } : {}),
|
|
2647
|
+
};
|
|
2648
|
+
},
|
|
2649
|
+
getTopicTargets: () => listTelegramThreadStatusTargets(deps.listRecords()),
|
|
2650
|
+
getThreadReservations: () =>
|
|
2651
|
+
listTelegramThreadStatusReservations(deps.listReservations()),
|
|
2652
|
+
getTopicSyncObservations: () =>
|
|
2653
|
+
listTelegramThreadStatusObservations(deps.listSyncObservations()),
|
|
2654
|
+
getInstanceSlot() {
|
|
2655
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2656
|
+
return deps.getCurrentIdentity().slot;
|
|
2657
|
+
},
|
|
2658
|
+
getInstanceThreadName() {
|
|
2659
|
+
if (deps.getThreadMode() === "disabled") return undefined;
|
|
2660
|
+
return deps.getCurrentIdentity().threadName;
|
|
2661
|
+
},
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
export interface TelegramCurrentThreadAssemblyDeps {
|
|
2666
|
+
instanceId: string;
|
|
2667
|
+
listRecords: TelegramCurrentInstanceThreadRuntimeDeps["listRecords"];
|
|
2668
|
+
getActiveTurnTarget(): TelegramTarget | undefined;
|
|
2669
|
+
getFollowerTarget(): TelegramTarget | undefined;
|
|
2670
|
+
isFollowerRegistered(): boolean;
|
|
2671
|
+
getFollowerSlot(): string | undefined;
|
|
2672
|
+
getFollowerThreadName(): string | undefined;
|
|
2673
|
+
getLeaderIdentity(): TelegramInstanceThreadIdentityCandidate | undefined;
|
|
2674
|
+
getLeaderTarget(): TelegramTarget | undefined;
|
|
2675
|
+
getLeaderProtocol?: TelegramThreadStatusProjectionRuntimeDeps["getLeaderProtocol"];
|
|
2676
|
+
status: Pick<
|
|
2677
|
+
TelegramThreadStatusProjectionRuntimeDeps,
|
|
2678
|
+
| "getThreadMode"
|
|
2679
|
+
| "isBusPollingStarted"
|
|
2680
|
+
| "listFollowers"
|
|
2681
|
+
| "listReservations"
|
|
2682
|
+
| "listSyncObservations"
|
|
2683
|
+
| "getLeaderSocketPath"
|
|
2684
|
+
| "getFollowerSocketPath"
|
|
2685
|
+
| "getTransportKind"
|
|
2686
|
+
>;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
export interface TelegramCurrentThreadAssembly {
|
|
2690
|
+
current: TelegramCurrentInstanceThreadRuntime;
|
|
2691
|
+
status: TelegramThreadStatusProjectionRuntime;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
/** Own current-thread preference and its matching status projection. */
|
|
2695
|
+
export function createTelegramCurrentThreadAssembly(
|
|
2696
|
+
deps: TelegramCurrentThreadAssemblyDeps,
|
|
2697
|
+
): TelegramCurrentThreadAssembly {
|
|
2698
|
+
const getFollower = () => {
|
|
2699
|
+
const target = deps.getFollowerTarget();
|
|
2700
|
+
if (!target) return undefined;
|
|
2701
|
+
return {
|
|
2702
|
+
registered: deps.isFollowerRegistered(),
|
|
2703
|
+
target,
|
|
2704
|
+
slot: deps.getFollowerSlot(),
|
|
2705
|
+
threadName: deps.getFollowerThreadName(),
|
|
2706
|
+
};
|
|
2707
|
+
};
|
|
2708
|
+
const current = createTelegramCurrentInstanceThreadRuntime({
|
|
2709
|
+
instanceId: deps.instanceId,
|
|
2710
|
+
listRecords: deps.listRecords,
|
|
2711
|
+
getPreferredTarget: () =>
|
|
2712
|
+
deps.getActiveTurnTarget() ??
|
|
2713
|
+
deps.getFollowerTarget() ??
|
|
2714
|
+
deps.getLeaderTarget(),
|
|
2715
|
+
getFollower,
|
|
2716
|
+
getLeader: deps.getLeaderIdentity,
|
|
2717
|
+
});
|
|
2718
|
+
return {
|
|
2719
|
+
current,
|
|
2720
|
+
status: createTelegramThreadStatusProjectionRuntime({
|
|
2721
|
+
...deps.status,
|
|
2722
|
+
isFollowerRegistered: deps.isFollowerRegistered,
|
|
2723
|
+
listRecords: deps.listRecords,
|
|
2724
|
+
getFollowerTarget: deps.getFollowerTarget,
|
|
2725
|
+
getFollowerSlot: deps.getFollowerSlot,
|
|
2726
|
+
getFollowerThreadName: deps.getFollowerThreadName,
|
|
2727
|
+
getLeaderProtocol: deps.getLeaderProtocol,
|
|
2728
|
+
getCurrentIdentity: current.getRestorationIdentity,
|
|
2729
|
+
}),
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
export interface TelegramThreadStatusFollowerView {
|
|
2734
|
+
instanceId: string;
|
|
2735
|
+
cwd?: string;
|
|
2736
|
+
lastHeartbeatMs: number;
|
|
2737
|
+
target?: TelegramTarget;
|
|
2738
|
+
protocol?: {
|
|
2739
|
+
protocolVersion: number;
|
|
2740
|
+
runtimeBuild: string;
|
|
2741
|
+
capabilities: string[];
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function getTelegramThreadStatusName(
|
|
2746
|
+
record: TelegramTopicTargetRecord | undefined,
|
|
2747
|
+
): string | undefined {
|
|
2748
|
+
if (!record) return undefined;
|
|
2749
|
+
if (
|
|
2750
|
+
record.threadName &&
|
|
2751
|
+
isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
|
|
2752
|
+
)
|
|
2753
|
+
return record.threadName;
|
|
2754
|
+
return chooseTelegramThreadName({ slot: record.slot });
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
export function listTelegramThreadStatusFollowers(options: {
|
|
2758
|
+
followers: readonly TelegramThreadStatusFollowerView[];
|
|
2759
|
+
records: readonly TelegramTopicTargetRecord[];
|
|
2760
|
+
}): Array<{
|
|
2761
|
+
instanceId: string;
|
|
2762
|
+
cwd?: string;
|
|
2763
|
+
lastHeartbeatMs: number;
|
|
2764
|
+
target?: TelegramTarget;
|
|
2765
|
+
protocol?: {
|
|
2766
|
+
protocolVersion: number;
|
|
2767
|
+
runtimeBuild: string;
|
|
2768
|
+
capabilities: string[];
|
|
2769
|
+
};
|
|
2770
|
+
slot?: string;
|
|
2771
|
+
threadName?: string;
|
|
2772
|
+
status?: string;
|
|
2773
|
+
}> {
|
|
2774
|
+
return options.followers.map((follower) => {
|
|
2775
|
+
const record = options.records.find((record) => {
|
|
2776
|
+
return (
|
|
2777
|
+
record.target.chatId === follower.target?.chatId &&
|
|
2778
|
+
record.target.threadId === follower.target?.threadId
|
|
2779
|
+
);
|
|
2780
|
+
});
|
|
2781
|
+
return {
|
|
2782
|
+
instanceId: follower.instanceId,
|
|
2783
|
+
cwd: follower.cwd,
|
|
2784
|
+
lastHeartbeatMs: follower.lastHeartbeatMs,
|
|
2785
|
+
target: follower.target,
|
|
2786
|
+
...(follower.protocol ? { protocol: follower.protocol } : {}),
|
|
2787
|
+
slot: record?.slot,
|
|
2788
|
+
threadName: getTelegramThreadStatusName(record),
|
|
2789
|
+
status: record?.status,
|
|
2790
|
+
};
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
export function listTelegramThreadStatusTargets(
|
|
2795
|
+
records: readonly TelegramTopicTargetRecord[],
|
|
2796
|
+
): Array<{
|
|
2797
|
+
instanceId?: string;
|
|
2798
|
+
status: TelegramTopicTargetStatus;
|
|
2799
|
+
target: TelegramTarget & { threadId: number };
|
|
2800
|
+
slot?: string;
|
|
2801
|
+
threadName?: string;
|
|
2802
|
+
syncStatus?: TelegramTopicSyncStatus;
|
|
2803
|
+
lastSyncObservedAtMs?: number;
|
|
2804
|
+
lastSyncProbeAtMs?: number;
|
|
2805
|
+
lastSyncError?: string;
|
|
2806
|
+
lastReconcileAction?: string;
|
|
2807
|
+
}> {
|
|
2808
|
+
return records.map((record) => {
|
|
2809
|
+
return {
|
|
2810
|
+
instanceId: record.instanceId,
|
|
2811
|
+
status: record.status,
|
|
2812
|
+
target: record.target,
|
|
2813
|
+
slot: record.slot,
|
|
2814
|
+
threadName: getTelegramThreadStatusName(record),
|
|
2815
|
+
syncStatus: record.syncStatus,
|
|
2816
|
+
lastSyncObservedAtMs: record.lastSyncObservedAtMs,
|
|
2817
|
+
lastSyncProbeAtMs: record.lastSyncProbeAtMs,
|
|
2818
|
+
lastSyncError: record.lastSyncError,
|
|
2819
|
+
lastReconcileAction: record.lastReconcileAction,
|
|
2820
|
+
};
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
export function listTelegramThreadStatusReservations(
|
|
2825
|
+
reservations: readonly TelegramThreadReservation[],
|
|
2826
|
+
): Array<{
|
|
2827
|
+
target: TelegramTarget & { threadId: number };
|
|
2828
|
+
slot: string;
|
|
2829
|
+
reason: string;
|
|
2830
|
+
instanceId?: string;
|
|
2831
|
+
expiresAtMs?: number;
|
|
2832
|
+
lastReconcileAction?: string;
|
|
2833
|
+
}> {
|
|
2834
|
+
return reservations.map((reservation) => {
|
|
2835
|
+
return {
|
|
2836
|
+
target: reservation.target,
|
|
2837
|
+
slot: reservation.slot,
|
|
2838
|
+
reason: reservation.reason,
|
|
2839
|
+
instanceId: reservation.instanceId,
|
|
2840
|
+
expiresAtMs: reservation.expiresAtMs,
|
|
2841
|
+
lastReconcileAction: reservation.lastReconcileAction,
|
|
2842
|
+
};
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
export function listTelegramThreadStatusObservations(
|
|
2847
|
+
observations: readonly TelegramTopicSyncObservation[],
|
|
2848
|
+
): Array<{
|
|
2849
|
+
target: TelegramTarget & { threadId: number };
|
|
2850
|
+
syncStatus: TelegramTopicSyncStatus;
|
|
2851
|
+
observedAtMs: number;
|
|
2852
|
+
instanceId?: string;
|
|
2853
|
+
slot?: string;
|
|
2854
|
+
lastSyncError?: string;
|
|
2855
|
+
lastReconcileAction?: string;
|
|
2856
|
+
}> {
|
|
2857
|
+
return observations.map((observation) => {
|
|
2858
|
+
return {
|
|
2859
|
+
target: observation.target,
|
|
2860
|
+
syncStatus: observation.syncStatus,
|
|
2861
|
+
observedAtMs: observation.observedAtMs,
|
|
2862
|
+
instanceId: observation.instanceId,
|
|
2863
|
+
slot: observation.slot,
|
|
2864
|
+
lastSyncError: observation.lastSyncError,
|
|
2865
|
+
lastReconcileAction: observation.lastReconcileAction,
|
|
2866
|
+
};
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
export function getTelegramTargetFromApiBody(
|
|
2871
|
+
body: unknown,
|
|
2872
|
+
): (TelegramTarget & { threadId: number }) | undefined {
|
|
2873
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
2874
|
+
return undefined;
|
|
2875
|
+
const record = body as Record<string, unknown>;
|
|
2876
|
+
const chatId = asInteger(record.chat_id);
|
|
2877
|
+
const threadId = asInteger(record.message_thread_id);
|
|
2878
|
+
return chatId !== undefined && threadId !== undefined
|
|
2879
|
+
? { chatId, threadId }
|
|
2880
|
+
: undefined;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
export function isTelegramTopicTargetStaleError(error: unknown): boolean {
|
|
2884
|
+
if (!(error instanceof Error)) return false;
|
|
2885
|
+
const message = error.message.toLowerCase();
|
|
2886
|
+
return (
|
|
2887
|
+
message.includes("topic_id_invalid") ||
|
|
2888
|
+
message.includes("message thread not found") ||
|
|
2889
|
+
message.includes("thread not found") ||
|
|
2890
|
+
message.includes("topic not found") ||
|
|
2891
|
+
message.includes("topic deleted") ||
|
|
2892
|
+
message.includes("topic closed") ||
|
|
2893
|
+
message.includes("thread closed") ||
|
|
2894
|
+
message.includes("forum topic closed") ||
|
|
2895
|
+
message.includes("message thread closed")
|
|
2896
|
+
);
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
export function isTelegramTopicModeUnavailableError(error: unknown): boolean {
|
|
2900
|
+
if (!(error instanceof Error)) return false;
|
|
2901
|
+
const message = error.message.toLowerCase();
|
|
2902
|
+
return (
|
|
2903
|
+
message.includes("not a forum") ||
|
|
2904
|
+
message.includes("forum topic") ||
|
|
2905
|
+
message.includes("topics are disabled") ||
|
|
2906
|
+
message.includes("threaded mode") ||
|
|
2907
|
+
message.includes("method is available only for")
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
export function getTelegramTopicTitleForThreadName(
|
|
2912
|
+
threadName: string,
|
|
2913
|
+
slot: string,
|
|
2914
|
+
template = "{threadName}",
|
|
2915
|
+
): string {
|
|
2916
|
+
return getTelegramTopicName(
|
|
2917
|
+
{
|
|
2918
|
+
instanceId: "",
|
|
2919
|
+
profileKey: normalizeTelegramTopicTargetThreadName(threadName) || "Pi",
|
|
2920
|
+
threadName,
|
|
2921
|
+
},
|
|
2922
|
+
template,
|
|
2923
|
+
slot,
|
|
2924
|
+
);
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
export function createTelegramTopicTargetRenamer(
|
|
2928
|
+
deps: TelegramTopicTargetRenamerDeps,
|
|
2929
|
+
): (
|
|
2930
|
+
request: TelegramTopicTargetRenameRequest,
|
|
2931
|
+
) => Promise<TelegramTopicTargetRecord | undefined> {
|
|
2932
|
+
return async (request) => {
|
|
2933
|
+
const threadName = normalizeTelegramTopicTargetThreadName(
|
|
2934
|
+
request.threadName,
|
|
2935
|
+
);
|
|
2936
|
+
if (
|
|
2937
|
+
!threadName ||
|
|
2938
|
+
!isTelegramTopicThreadNameValidForSlot(threadName, request.slot)
|
|
2939
|
+
)
|
|
2940
|
+
return undefined;
|
|
2941
|
+
const name = getTelegramTopicTitleForThreadName(
|
|
2942
|
+
threadName,
|
|
2943
|
+
request.slot ?? "",
|
|
2944
|
+
deps.topicNameTemplate,
|
|
2945
|
+
);
|
|
2946
|
+
await deps.callApi("editForumTopic", {
|
|
2947
|
+
chat_id: request.target.chatId,
|
|
2948
|
+
message_thread_id: request.target.threadId,
|
|
2949
|
+
name,
|
|
2950
|
+
});
|
|
2951
|
+
return deps.store.renameByTarget(request.target, threadName);
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
export function createTelegramTopicTargetProvisioner(
|
|
2956
|
+
deps: TelegramTopicTargetProvisionerDeps,
|
|
2957
|
+
): (
|
|
2958
|
+
request: TelegramTopicTargetProvisionRequest,
|
|
2959
|
+
) => Promise<TelegramTopicTargetProvisionResult> {
|
|
2960
|
+
const getNowMs = deps.getNowMs ?? (() => 0);
|
|
2961
|
+
const getRandom = deps.getRandom;
|
|
2962
|
+
return async (request) => {
|
|
2963
|
+
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
2964
|
+
const assertLeaderEpoch = (phase: string): void => {
|
|
2965
|
+
if (
|
|
2966
|
+
deps.getCurrentLeaderEpoch &&
|
|
2967
|
+
(leaderEpoch === undefined ||
|
|
2968
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch)
|
|
2969
|
+
) {
|
|
2970
|
+
throw new Error(
|
|
2971
|
+
`Telegram topic provisioning lost leader ownership (${phase}).`,
|
|
2972
|
+
);
|
|
2973
|
+
}
|
|
2974
|
+
};
|
|
2975
|
+
assertLeaderEpoch("start");
|
|
2976
|
+
normalizeCurrentThreadNameSlots(deps.store);
|
|
2977
|
+
const existing = deps.store.getByProfileKey(request.profileKey);
|
|
2978
|
+
const isManualFollowerRequest = request.owner?.kind === "manual-follower";
|
|
2979
|
+
const identity = deps.store.getIdentityByProfileKey(request.profileKey);
|
|
2980
|
+
const nowMs = getNowMs();
|
|
2981
|
+
if (existing && isCurrentThreadRecord(existing)) {
|
|
2982
|
+
const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
|
|
2983
|
+
const identityThreadName =
|
|
2984
|
+
identity?.threadName &&
|
|
2985
|
+
isTelegramTopicThreadNameValidForSlot(identity.threadName, slot)
|
|
2986
|
+
? identity.threadName
|
|
2987
|
+
: undefined;
|
|
2988
|
+
const bakedThreadName = chooseTelegramThreadName({
|
|
2989
|
+
slot: getNextTelegramThreadNamePaletteSlot(deps.store.list(), slot),
|
|
2990
|
+
entropy: nowMs,
|
|
2991
|
+
getRandom,
|
|
2992
|
+
});
|
|
2993
|
+
const record = deps.store.upsert({
|
|
2994
|
+
...existing,
|
|
2995
|
+
status: "active",
|
|
2996
|
+
updatedAtMs: nowMs,
|
|
2997
|
+
threadName:
|
|
2998
|
+
existing.threadName ?? identityThreadName ?? bakedThreadName,
|
|
2999
|
+
instanceId: request.instanceId,
|
|
3000
|
+
slot,
|
|
3001
|
+
owner: request.owner ?? existing.owner,
|
|
3002
|
+
lastError: undefined,
|
|
3003
|
+
});
|
|
3004
|
+
return { target: record.target, reused: true, record };
|
|
3005
|
+
}
|
|
3006
|
+
const pendingForRequest = deps.store
|
|
3007
|
+
.listPendingProvisions()
|
|
3008
|
+
.find(
|
|
3009
|
+
(pending) =>
|
|
3010
|
+
pending.profileKey === request.profileKey ||
|
|
3011
|
+
pending.instanceId === request.instanceId,
|
|
3012
|
+
);
|
|
3013
|
+
if (pendingForRequest?.target) {
|
|
3014
|
+
const record = deps.store.upsert({
|
|
3015
|
+
profileKey: request.profileKey,
|
|
3016
|
+
owner: request.owner,
|
|
3017
|
+
target: pendingForRequest.target,
|
|
3018
|
+
status: "active",
|
|
3019
|
+
createdAtMs: pendingForRequest.startedAtMs,
|
|
3020
|
+
updatedAtMs: nowMs,
|
|
3021
|
+
threadName: pendingForRequest.threadName,
|
|
3022
|
+
instanceId: request.instanceId,
|
|
3023
|
+
slot: pendingForRequest.slot,
|
|
3024
|
+
});
|
|
3025
|
+
deps.store.removePendingProvision(pendingForRequest.id);
|
|
3026
|
+
await deps.store.persist();
|
|
3027
|
+
assertLeaderEpoch("after-recovered-binding");
|
|
3028
|
+
return { target: record.target, reused: true, record };
|
|
3029
|
+
}
|
|
3030
|
+
if (pendingForRequest) {
|
|
3031
|
+
throw new Error(
|
|
3032
|
+
`Telegram topic provisioning remains ${pendingForRequest.status ?? "in-flight"} for this instance.`,
|
|
3033
|
+
);
|
|
3034
|
+
}
|
|
3035
|
+
const activeForInstance = deps.store.getActiveByInstanceId(
|
|
3036
|
+
request.instanceId,
|
|
3037
|
+
);
|
|
3038
|
+
if (activeForInstance) {
|
|
3039
|
+
return {
|
|
3040
|
+
target: activeForInstance.target,
|
|
3041
|
+
reused: true,
|
|
3042
|
+
record: activeForInstance,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
// No profileKey match — try to claim an existing inactive thread before creating another Telegram tab.
|
|
3046
|
+
if (deps.claimPendingTargets !== false) {
|
|
3047
|
+
const claimed = deps.store.claimReusableTarget(
|
|
3048
|
+
request.instanceId,
|
|
3049
|
+
identity?.threadName,
|
|
3050
|
+
);
|
|
3051
|
+
if (claimed) {
|
|
3052
|
+
return { target: claimed.target, reused: true, record: claimed };
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
const candidateThreadName = identity?.threadName;
|
|
3056
|
+
const preferredNameSlot =
|
|
3057
|
+
getTelegramThreadNameLeadingSlot(candidateThreadName) ??
|
|
3058
|
+
getNextTelegramThreadNamePaletteSlot(deps.store.list(), undefined) ??
|
|
3059
|
+
request.preferredSlot;
|
|
3060
|
+
const slot =
|
|
3061
|
+
existing?.slot ??
|
|
3062
|
+
(candidateThreadName ? undefined : identity?.slot) ??
|
|
3063
|
+
deps.store.allocateSlot(
|
|
3064
|
+
request.profileKey,
|
|
3065
|
+
isManualFollowerRequest
|
|
3066
|
+
? request.preferredSlot
|
|
3067
|
+
: (request.preferredSlot ?? preferredNameSlot),
|
|
3068
|
+
);
|
|
3069
|
+
const requestThreadName =
|
|
3070
|
+
candidateThreadName &&
|
|
3071
|
+
isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot)
|
|
3072
|
+
? candidateThreadName
|
|
3073
|
+
: chooseTelegramThreadName({ slot, entropy: nowMs, getRandom });
|
|
3074
|
+
const pendingId = `provision:${request.instanceId}:${slot}:${nowMs}`;
|
|
3075
|
+
const pendingOwner =
|
|
3076
|
+
request.owner?.kind === "leader" ? "leader" : "manual-follower";
|
|
3077
|
+
assertLeaderEpoch("before-pending-intent");
|
|
3078
|
+
const pendingBase: TelegramThreadPendingProvision = {
|
|
3079
|
+
id: pendingId,
|
|
3080
|
+
owner: pendingOwner,
|
|
3081
|
+
instanceId: request.instanceId,
|
|
3082
|
+
profileKey: request.profileKey,
|
|
3083
|
+
threadName: requestThreadName,
|
|
3084
|
+
slot,
|
|
3085
|
+
startedAtMs: nowMs,
|
|
3086
|
+
...(leaderEpoch !== undefined ? { leaderEpoch } : {}),
|
|
3087
|
+
};
|
|
3088
|
+
deps.store.upsertPendingProvision(pendingBase);
|
|
3089
|
+
await deps.store.persist();
|
|
3090
|
+
assertLeaderEpoch("after-pending-intent");
|
|
3091
|
+
let threadId: number | undefined;
|
|
3092
|
+
try {
|
|
3093
|
+
assertLeaderEpoch("before-createForumTopic");
|
|
3094
|
+
const topic = await deps.callApi<TelegramTopicResult>(
|
|
3095
|
+
"createForumTopic",
|
|
3096
|
+
{
|
|
3097
|
+
chat_id: deps.topicChatId,
|
|
3098
|
+
name: getTelegramTopicName(
|
|
3099
|
+
{
|
|
3100
|
+
...request,
|
|
3101
|
+
...(requestThreadName ? { threadName: requestThreadName } : {}),
|
|
3102
|
+
},
|
|
3103
|
+
deps.topicNameTemplate ??
|
|
3104
|
+
(requestThreadName ? "{threadName}" : "{slot}"),
|
|
3105
|
+
slot,
|
|
3106
|
+
),
|
|
3107
|
+
},
|
|
3108
|
+
{ maxAttempts: 1 },
|
|
3109
|
+
);
|
|
3110
|
+
threadId = topic.message_thread_id;
|
|
3111
|
+
if (typeof threadId !== "number" || !Number.isInteger(threadId)) {
|
|
3112
|
+
throw new TelegramApiCommitUnknownError(
|
|
3113
|
+
"createForumTopic",
|
|
3114
|
+
new Error("Telegram createForumTopic returned no message_thread_id."),
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
assertLeaderEpoch("after-createForumTopic");
|
|
3118
|
+
const target = { chatId: deps.topicChatId, threadId };
|
|
3119
|
+
deps.store.upsertPendingProvision({ ...pendingBase, target });
|
|
3120
|
+
await deps.store.persist();
|
|
3121
|
+
assertLeaderEpoch("after-pending-target");
|
|
3122
|
+
deps.store.upsert({
|
|
3123
|
+
profileKey: request.profileKey,
|
|
3124
|
+
owner: request.owner,
|
|
3125
|
+
target,
|
|
3126
|
+
status: "starting",
|
|
3127
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
3128
|
+
updatedAtMs: nowMs,
|
|
3129
|
+
threadName: requestThreadName,
|
|
3130
|
+
instanceId: request.instanceId,
|
|
3131
|
+
slot,
|
|
3132
|
+
});
|
|
3133
|
+
await deps.store.persist();
|
|
3134
|
+
assertLeaderEpoch("after-starting-binding");
|
|
3135
|
+
const record = deps.store.upsert({
|
|
3136
|
+
profileKey: request.profileKey,
|
|
3137
|
+
owner: request.owner,
|
|
3138
|
+
target,
|
|
3139
|
+
status: "active",
|
|
3140
|
+
createdAtMs: existing?.createdAtMs ?? nowMs,
|
|
3141
|
+
updatedAtMs: nowMs,
|
|
3142
|
+
threadName: requestThreadName,
|
|
3143
|
+
instanceId: request.instanceId,
|
|
3144
|
+
slot,
|
|
3145
|
+
});
|
|
3146
|
+
deps.store.removePendingProvision(pendingId);
|
|
3147
|
+
await deps.store.persist();
|
|
3148
|
+
assertLeaderEpoch("after-active-binding");
|
|
3149
|
+
return { target: record.target, reused: false, record };
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
if (
|
|
3152
|
+
threadId !== undefined &&
|
|
3153
|
+
deps.getCurrentLeaderEpoch &&
|
|
3154
|
+
deps.getCurrentLeaderEpoch() !== leaderEpoch
|
|
3155
|
+
) {
|
|
3156
|
+
await deps.store.recordPendingProvisionTargetRecovery(pendingBase, {
|
|
3157
|
+
chatId: deps.topicChatId,
|
|
3158
|
+
threadId,
|
|
3159
|
+
});
|
|
3160
|
+
throw error;
|
|
3161
|
+
}
|
|
3162
|
+
assertLeaderEpoch("failure-cleanup");
|
|
3163
|
+
if (threadId === undefined) {
|
|
3164
|
+
if (isTelegramApiCommitUnknownError(error)) {
|
|
3165
|
+
deps.store.upsertPendingProvision({
|
|
3166
|
+
...pendingBase,
|
|
3167
|
+
status: "ambiguous",
|
|
3168
|
+
});
|
|
3169
|
+
} else {
|
|
3170
|
+
deps.store.removePendingProvision(pendingId);
|
|
3171
|
+
}
|
|
3172
|
+
await deps.store.persist();
|
|
3173
|
+
} else {
|
|
3174
|
+
try {
|
|
3175
|
+
await deps.store.persist();
|
|
3176
|
+
} catch {
|
|
3177
|
+
// Keep the original post-create failure visible to the caller.
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
throw error;
|
|
3181
|
+
}
|
|
3182
|
+
};
|
|
3183
|
+
}
|