approval-md 0.0.1 → 0.2.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/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +940 -4
- package/SPEC.md +476 -34
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +1035 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +77 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.d.ts +623 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -0
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/payload-view.js +43 -0
- package/dist/src/channels/payload-view.js.map +1 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/render-queue.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +1832 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.d.ts +341 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +288 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.d.ts +41 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -0
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit-card.js +201 -0
- package/dist/src/cli/audit-card.js.map +1 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/audit.js +460 -0
- package/dist/src/cli/audit.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +806 -0
- package/dist/src/cli/channel-telegram.js +2063 -0
- package/dist/src/cli/channel-telegram.js.map +1 -0
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel-web.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.d.ts +71 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +172 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +2762 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -0
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/exit-codes.js +82 -0
- package/dist/src/cli/exit-codes.js.map +1 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/feedback.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate-window.js +294 -0
- package/dist/src/cli/gate-window.js.map +1 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/gate.js +557 -0
- package/dist/src/cli/gate.js.map +1 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/git-scope.js +295 -0
- package/dist/src/cli/git-scope.js.map +1 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-attach.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex-child.js +149 -0
- package/dist/src/cli/gloss-codex-child.js.map +1 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-codex.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss-options.js +79 -0
- package/dist/src/cli/gloss-options.js.map +1 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/gloss.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.d.ts +103 -0
- package/dist/src/cli/help.js +2339 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/hook-codex.d.ts +78 -0
- package/dist/src/cli/hook-codex.js +167 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +331 -0
- package/dist/src/cli/hook.js +2849 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +840 -0
- package/dist/src/cli/log-advance.js.map +1 -0
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-anchor.js +387 -0
- package/dist/src/cli/log-anchor.js.map +1 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-checkpoint.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-sync.js +849 -0
- package/dist/src/cli/log-sync.js.map +1 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +360 -0
- package/dist/src/cli/log-verbs.js.map +1 -0
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/long-help.js +148 -0
- package/dist/src/cli/long-help.js.map +1 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +1206 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.d.ts +363 -0
- package/dist/src/cli/preflight.js +1175 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +509 -0
- package/dist/src/cli/setup-adapter.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +117 -0
- package/dist/src/cli/setup-channel.js +635 -0
- package/dist/src/cli/setup-channel.js.map +1 -0
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-checkpoint.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -0
- package/dist/src/cli/setup-common.d.ts +275 -0
- package/dist/src/cli/setup-common.js +376 -0
- package/dist/src/cli/setup-common.js.map +1 -0
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-flow.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup-service.js +308 -0
- package/dist/src/cli/setup-service.js.map +1 -0
- package/dist/src/cli/setup.d.ts +202 -0
- package/dist/src/cli/setup.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +849 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +2341 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.js.map +1 -0
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +170 -0
- package/dist/src/core/advance-cycle.js +200 -0
- package/dist/src/core/advance-cycle.js.map +1 -0
- package/dist/src/core/agents-md.d.ts +276 -0
- package/dist/src/core/agents-md.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -0
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +420 -0
- package/dist/src/core/attest.js +589 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.d.ts +492 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/child-env.js +86 -0
- package/dist/src/core/child-env.js.map +1 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/clock.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.d.ts +543 -0
- package/dist/src/core/command-class.js +2356 -0
- package/dist/src/core/command-class.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/adapter.js +71 -0
- package/dist/src/core/coverage-sources/adapter.js.map +1 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/gh.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage-sources/git.js +269 -0
- package/dist/src/core/coverage-sources/git.js.map +1 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/coverage.js +337 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/credential-spec.js +23 -0
- package/dist/src/core/credential-spec.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +331 -0
- package/dist/src/core/dark-session.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -0
- package/dist/src/core/decision-refusal.d.ts +185 -0
- package/dist/src/core/decision-refusal.js +265 -0
- package/dist/src/core/decision-refusal.js.map +1 -0
- package/dist/src/core/env-file.d.ts +450 -0
- package/dist/src/core/env-file.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.d.ts +858 -0
- package/dist/src/core/execute.js +1271 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate-window.js +506 -0
- package/dist/src/core/gate-window.js.map +1 -0
- package/dist/src/core/gate.d.ts +1364 -0
- package/dist/src/core/gate.js +3002 -0
- package/dist/src/core/gate.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/git-run.js +93 -0
- package/dist/src/core/git-run.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +211 -0
- package/dist/src/core/harness-version.js.map +1 -0
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/harness-wait.js +58 -0
- package/dist/src/core/harness-wait.js.map +1 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/head-retry.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.d.ts +253 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -0
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/intake-limits.js +350 -0
- package/dist/src/core/intake-limits.js.map +1 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/jcs.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/live-draw.js +703 -0
- package/dist/src/core/live-draw.js.map +1 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-reconcile.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +278 -0
- package/dist/src/core/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +487 -0
- package/dist/src/core/loop.js.map +1 -0
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/md-fence.js +74 -0
- package/dist/src/core/md-fence.js.map +1 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/money.js +195 -0
- package/dist/src/core/money.js.map +1 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-census.js +146 -0
- package/dist/src/core/payload-census.js.map +1 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload-store.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +588 -0
- package/dist/src/core/policy-diff.js.map +1 -0
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-expectations.js +394 -0
- package/dist/src/core/policy-expectations.js.map +1 -0
- package/dist/src/core/policy-explain.d.ts +150 -0
- package/dist/src/core/policy-explain.js +258 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.d.ts +527 -0
- package/dist/src/core/policy-load.js +536 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.d.ts +281 -0
- package/dist/src/core/policy-match.js +478 -0
- package/dist/src/core/policy-match.js.map +1 -0
- package/dist/src/core/policy-proposal.d.ts +265 -0
- package/dist/src/core/policy-proposal.js +458 -0
- package/dist/src/core/policy-proposal.js.map +1 -0
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/prompt-layout.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.d.ts +453 -0
- package/dist/src/core/protected-path-guard.js +1566 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +290 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/task-file.js +464 -0
- package/dist/src/core/task-file.js.map +1 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/telegram-config.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +561 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.d.ts +137 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verified-snapshot.js +506 -0
- package/dist/src/core/verified-snapshot.js.map +1 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/verify.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance-child.js +78 -0
- package/dist/src/daemon/advance-child.js.map +1 -0
- package/dist/src/daemon/advance.d.ts +466 -0
- package/dist/src/daemon/advance.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -0
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/dark-session.js +119 -0
- package/dist/src/daemon/dark-session.js.map +1 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw-child.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +345 -0
- package/dist/src/daemon/git-evidence.js.map +1 -0
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/projection.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +602 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +5716 -0
- package/docs/codex-enforced-session.md +30 -0
- package/package.json +53 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/codex-instance.schema.json +82 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1811 -0
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
- package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
- package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
- package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
- package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
- package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
- package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
- package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
- package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
- package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
- package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
- package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
- package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
- package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
- package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
- package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
- package/schema/fixtures/envelope/valid/canonical.json +25 -0
- package/schema/fixtures/envelope/valid/minimal.json +7 -0
- package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
- package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
- package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
- package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
- package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
- package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
- package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
- package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
- package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
- package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
- package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
- package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
- package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
- package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
- package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
- package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
- package/schema/fixtures/event/invalid/missing-alg.json +14 -0
- package/schema/fixtures/event/invalid/missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
- package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
- package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
- package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
- package/schema/fixtures/event/invalid/short-hash.json +15 -0
- package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
- package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
- package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
- package/schema/fixtures/event/valid/approval-expired.json +15 -0
- package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
- package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
- package/schema/fixtures/event/valid/approval-granted.json +15 -0
- package/schema/fixtures/event/valid/approval-rejected.json +15 -0
- package/schema/fixtures/event/valid/approval-requested.json +19 -0
- package/schema/fixtures/event/valid/approval-revoked.json +15 -0
- package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
- package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
- package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
- package/schema/fixtures/event/valid/audit-sampled.json +14 -0
- package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
- package/schema/fixtures/event/valid/envelope-drift.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
- package/schema/fixtures/event/valid/execution-completed.json +15 -0
- package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-failed.json +15 -0
- package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
- package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
- package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
- package/schema/fixtures/event/valid/execution-started.json +14 -0
- package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
- package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
- package/schema/fixtures/event/valid/gate-closed.json +14 -0
- package/schema/fixtures/event/valid/gate-opened.json +16 -0
- package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
- package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
- package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
- package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
- package/schema/fixtures/event/valid/payload-pruned.json +17 -0
- package/schema/fixtures/event/valid/policy-declined.json +16 -0
- package/schema/fixtures/event/valid/policy-proposed.json +35 -0
- package/schema/fixtures/event/valid/policy-updated.json +14 -0
- package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
- package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
- package/schema/fixtures/event/valid/route-accepted.json +15 -0
- package/schema/fixtures/event/valid/route-proposed.json +16 -0
- package/schema/fixtures/event/valid/spec-example.json +15 -0
- package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
- package/schema/fixtures/event/valid/task-registered.json +14 -0
- package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
- package/schema/fixtures/hash/known-answer.json +74 -0
- package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
- package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
- package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
- package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
- package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/missing-version.json +8 -0
- package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
- package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
- package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
- package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
- package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
- package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
- package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
- package/schema/fixtures/policy/valid/canonical.json +47 -0
- package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
- package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
- package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
- package/schema/fixtures/policy/valid/global-budgets.json +19 -0
- package/schema/fixtures/policy/valid/human-only.json +9 -0
- package/schema/fixtures/policy/valid/minimal.json +6 -0
- package/schema/fixtures/policy/valid/protected-paths.json +10 -0
- package/schema/fixtures/policy/valid/record-namespace.json +13 -0
- package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
- package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
- package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
- package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
- package/schema/fixtures/policy/valid/wildcards.json +15 -0
- package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
- package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
- package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
- package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
- package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
- package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
- package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/valid/canonical.md +50 -0
- package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
- package/schema/fixtures/policy-md/valid/minimal.md +3 -0
- package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
- package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
- package/schema/fixtures/policy-md/valid/with-values.md +79 -0
- package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
- package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
- package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
- package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
- package/schema/fixtures/sample-record/valid/minimal.json +4 -0
- package/schema/fixtures/sample-record/valid/with-note.json +5 -0
- package/schema/fixtures/values/invalid/class-shaped.json +9 -0
- package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
- package/schema/fixtures/values/invalid/non-string-item.json +4 -0
- package/schema/fixtures/values/invalid/over-cap.json +26 -0
- package/schema/fixtures/values/invalid/unknown-key.json +5 -0
- package/schema/fixtures/values/invalid/version-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +7 -0
- package/schema/fixtures/values/valid/full.json +20 -0
- package/schema/fixtures/values/valid/minimal.json +1 -0
- package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
- package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
- package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
- package/schema/fixtures/values-md/valid/absent.md +50 -0
- package/schema/fixtures/values-md/valid/with-values.md +79 -0
- package/schema/policy.schema.json +501 -0
- package/schema/sample-record.schema.json +26 -0
- package/schema/values.schema.json +55 -0
- package/templates/codex/README.md +9 -0
|
@@ -0,0 +1,3190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Telegram push channel (SPEC.md §10.3, APRV-26).
|
|
3
|
+
*
|
|
4
|
+
* A Telegram bot is the reference *push* channel: the runtime sends the pending
|
|
5
|
+
* request into a chat the approver already reads, and the approver answers with
|
|
6
|
+
* one tap. Everything the contract says about a channel still holds here and is
|
|
7
|
+
* worth restating, because a network channel is where the temptations live:
|
|
8
|
+
*
|
|
9
|
+
* - **It decides nothing.** A `callback_query` becomes a {@link ChannelDecision}
|
|
10
|
+
* and is handed to the handler the runtime registered. That handler calls
|
|
11
|
+
* `recordChannelDecision`, which calls the human-only `decide()`. There is no
|
|
12
|
+
* second path, so TTL lapse, budget re-check, attestation, idempotency and
|
|
13
|
+
* compare-and-append all still apply to a button press.
|
|
14
|
+
* - **It never sees a token.** A grant mints a single-use execution token, and
|
|
15
|
+
* `recordChannelDecision` hands it to *its* caller, not to the channel. See
|
|
16
|
+
* "The token never goes back into the chat" below.
|
|
17
|
+
* - **It holds no decision state.** The only thing kept in memory is the map
|
|
18
|
+
* from a callback nonce to the action key it was issued for, which is
|
|
19
|
+
* delivery bookkeeping, not authorization. It is lost on restart, and a
|
|
20
|
+
* restarted listener re-notifies the pending queue. Since APRV-196 a button
|
|
21
|
+
* also carries a digest of its action key, so a tap on a pre-restart copy
|
|
22
|
+
* resolves to the request the new process is holding open and decides it;
|
|
23
|
+
* what a lost map costs is a duplicate message, not a dead button. The trade
|
|
24
|
+
* is unchanged and is the reason that works at all: an approval that survives
|
|
25
|
+
* a restart lives in the log, never in a channel's memory, so the thing a
|
|
26
|
+
* stale button resolves against is a request the LOG still calls pending.
|
|
27
|
+
*
|
|
28
|
+
* ## Zero dependencies
|
|
29
|
+
*
|
|
30
|
+
* The Bot API is plain HTTPS with JSON bodies, so this module uses `fetch`
|
|
31
|
+
* (global since Node 18) and nothing else. No SDK, no polling library, no
|
|
32
|
+
* webhook framework. `fetch` is injectable ({@link TelegramConfig.fetch}) and
|
|
33
|
+
* `apiBase` is injectable, which is how the test suite runs the whole channel —
|
|
34
|
+
* notify, long-poll, callbacks, failure modes — against a local mock Bot API
|
|
35
|
+
* server and never touches the real network.
|
|
36
|
+
*
|
|
37
|
+
* ## Config-declared identity — SPEC.md §11
|
|
38
|
+
*
|
|
39
|
+
* > Human identity in v0.1 is config-declared (an environment variable or
|
|
40
|
+
* > flag); the trust boundary is the local machine, and anyone who can set that
|
|
41
|
+
* > configuration and write to the log is inside it.
|
|
42
|
+
*
|
|
43
|
+
* This channel does **not** authenticate the person who tapped the button. It
|
|
44
|
+
* checks that the callback arrived from the configured chat id, and the
|
|
45
|
+
* decision is then recorded against the human actor the *runtime* was
|
|
46
|
+
* configured with (`APPROVAL_HUMAN` / `--as`), not against anything the
|
|
47
|
+
* callback carried. So the guarantee is "someone with access to the configured
|
|
48
|
+
* chat, on a runtime configured by someone with local control, tapped Approve"
|
|
49
|
+
* — not "alice tapped Approve". Anyone in that chat can approve as the
|
|
50
|
+
* configured actor. Use a private chat with the bot, and treat the chat's
|
|
51
|
+
* membership as part of the trust boundary. Cryptographic identity is future
|
|
52
|
+
* work and is not a v0.1 claim.
|
|
53
|
+
*
|
|
54
|
+
* ## Formatting: HTML, not MarkdownV2 — a deliberate choice
|
|
55
|
+
*
|
|
56
|
+
* Messages use `parse_mode: "HTML"`. MarkdownV2 requires escaping eighteen
|
|
57
|
+
* characters (`_*[]()~\`>#+-=|{}.!`) in every text position, with different
|
|
58
|
+
* rules inside code spans, and a single missed one is not a cosmetic bug: it is
|
|
59
|
+
* agent-authored text (a summary, a payload body) changing the *structure* of
|
|
60
|
+
* the message a human is about to approve. HTML mode needs exactly three
|
|
61
|
+
* escapes — `&`, `<`, `>` — applied uniformly to every interpolated value by
|
|
62
|
+
* {@link escapeHtml}, and `<pre>` carries the payload bytes without any
|
|
63
|
+
* character being special inside it beyond those three. A narrower escape rule
|
|
64
|
+
* is a narrower injection surface, and the untrusted input here is precisely
|
|
65
|
+
* the claimed fields and the payload.
|
|
66
|
+
*
|
|
67
|
+
* ## The token never goes back into the chat — flagged for human review
|
|
68
|
+
*
|
|
69
|
+
* `recordChannelDecision` returns the raw execution token to the runtime on a
|
|
70
|
+
* grant. The runtime (`cli/channel.ts`) prints it on the **listener's stdout**
|
|
71
|
+
* and nowhere else. It is never sent as a Telegram message, never put in an
|
|
72
|
+
* `answerCallbackQuery` text, and never logged by this module. A chat
|
|
73
|
+
* transcript is stored on someone else's servers, is backed up to phones, and
|
|
74
|
+
* is readable by anyone who is later added to the chat; a single-use execution
|
|
75
|
+
* token in it would be a credential in a place with none of the properties a
|
|
76
|
+
* credential store has. The consequence is real and is the reason this is
|
|
77
|
+
* flagged: the human who approves on their phone does not get the token on
|
|
78
|
+
* their phone — the agent or operator at the terminal running `approval channel
|
|
79
|
+
* telegram listen` does. For v0.1's local-first, single-operator model that is
|
|
80
|
+
* the right side of the trade; a deployment where the approver and the runtime
|
|
81
|
+
* are different people needs a token-delivery design, not a chat message.
|
|
82
|
+
*
|
|
83
|
+
* ## Reject collects no free-text reason — flagged for human review
|
|
84
|
+
*
|
|
85
|
+
* Telegram inline keyboards have no text input: a button press returns only its
|
|
86
|
+
* `callback_data`. Collecting the approver's reason would require a
|
|
87
|
+
* `ForceReply` round trip (send a prompt, wait for the *next* message in the
|
|
88
|
+
* chat, correlate it), which means holding a second piece of per-request state
|
|
89
|
+
* and deciding what to do when the reply never comes. This task records the
|
|
90
|
+
* rejection immediately with the note `rejected via telegram (callback <id>)`,
|
|
91
|
+
* so the audit trail says how the refusal was collected and which callback it
|
|
92
|
+
* came from, and says nothing about why. A follow-up may add the ForceReply
|
|
93
|
+
* flow; until then, a reason belongs in `approval reject --note`.
|
|
94
|
+
*
|
|
95
|
+
* ## Batching (B7): the digest (APRV-115)
|
|
96
|
+
*
|
|
97
|
+
* SPEC.md §10.3 lets a channel collect one gesture over a set, and until
|
|
98
|
+
* APRV-115 this channel took that option **degenerately**: one message per
|
|
99
|
+
* member, each with its own keyboard, all sharing one batch delivery id. The
|
|
100
|
+
* semantics were right and the ergonomics were the incident. A research session
|
|
101
|
+
* once produced forty near-identical `network.call` prompts in twenty minutes,
|
|
102
|
+
* one message each, and a channel that behaves like a notification hose is a
|
|
103
|
+
* channel a human learns to swipe away.
|
|
104
|
+
*
|
|
105
|
+
* A group of similar pending requests (the grouping key is
|
|
106
|
+
* {@link digestKeyOf}, applied by the listener) is now delivered as a
|
|
107
|
+
* **digest**: every member's full prompt and full payload first, in its own
|
|
108
|
+
* messages and with no buttons, then ONE trailing message carrying the
|
|
109
|
+
* headline, one summary line per member, and the keyboard — a per-member
|
|
110
|
+
* Approve/Reject row for each, plus an "all" row.
|
|
111
|
+
*
|
|
112
|
+
* Four properties hold it together:
|
|
113
|
+
*
|
|
114
|
+
* - **The payloads come first.** The buttons are on the LAST message, and
|
|
115
|
+
* every member's `<pre>` payload region has already been sent above it. An
|
|
116
|
+
* approver cannot reach an "Approve all" without the bytes it covers having
|
|
117
|
+
* been put in front of them (SPEC.md §10.4).
|
|
118
|
+
* - **It fails toward more messages.** A group whose digest text would not fit
|
|
119
|
+
* inside {@link TELEGRAM_MAX_MESSAGE_CHARS}, or that has fewer than two
|
|
120
|
+
* members, falls back to the old one-message-per-member delivery, and so
|
|
121
|
+
* does a group `assembleBatch` refuses. The listener caps a digest at
|
|
122
|
+
* {@link TELEGRAM_DIGEST_MAX_MEMBERS} and splits a larger burst into
|
|
123
|
+
* several. Never a grant covering an unseen payload.
|
|
124
|
+
* - **"All" is N decisions, not one.** An all-button hands the runtime's
|
|
125
|
+
* handler one {@link ChannelDecision} per still-armed member, in order, and
|
|
126
|
+
* the handler records each through the gate's compare-and-append on its own.
|
|
127
|
+
* The log never learns the word "batch": it gets N `approval.granted` or
|
|
128
|
+
* `approval.rejected` events, each bound to its own action and payload hash,
|
|
129
|
+
* each carrying the shared batch delivery id (SPEC.md §10.3).
|
|
130
|
+
* - **Annotation is per member.** A decided, expired or withdrawn member marks
|
|
131
|
+
* its own line on the digest and loses its own buttons; the others stay
|
|
132
|
+
* armed. A partially decided digest therefore shows mixed state, which is
|
|
133
|
+
* what {@link TelegramChannel.annotate} redraws it to.
|
|
134
|
+
*
|
|
135
|
+
* The digest bookkeeping is delivery state of exactly the kind the nonce map
|
|
136
|
+
* already was: what was sent where, never what was decided. Every outcome word
|
|
137
|
+
* on it comes from the verified log or from the record the gate appended, and
|
|
138
|
+
* losing the map to a restart degrades to a stale message whose buttons the
|
|
139
|
+
* gate refuses, never to a wrong one.
|
|
140
|
+
*
|
|
141
|
+
* ## Every terminal state edits its message (APRV-113)
|
|
142
|
+
*
|
|
143
|
+
* A decided prompt used to look exactly like a pending one: the tap toasted,
|
|
144
|
+
* and the message kept its text and its live buttons. So did a request answered
|
|
145
|
+
* at the CLI or the web queue while the chat prompt was up, and so did one the
|
|
146
|
+
* daemon expired. The chat transcript — the thing the approver actually scrolls
|
|
147
|
+
* — said "APPROVAL REQUIRED" about a question that had been settled hours ago.
|
|
148
|
+
*
|
|
149
|
+
* Every terminal state this process observes for a message it delivered now
|
|
150
|
+
* edits that message: {@link TelegramChannel.annotate} replaces the text with
|
|
151
|
+
* the outcome and clears the keyboard in ONE `editMessageText`, and forgets the
|
|
152
|
+
* delivery so a tap on a button the edit did not remove refuses rather than
|
|
153
|
+
* decides. {@link TelegramChannel.retract} is the withdrawal case of it.
|
|
154
|
+
*
|
|
155
|
+
* Two properties this keeps, deliberately:
|
|
156
|
+
*
|
|
157
|
+
* - **It is not state.** The map this consults is delivery bookkeeping, and
|
|
158
|
+
* annotating removes from it rather than adding. Losing it (a restart)
|
|
159
|
+
* degrades to a message that is never annotated — stale text in front of a
|
|
160
|
+
* human whose gate still refuses every tap on it — and never to a message
|
|
161
|
+
* annotated with the wrong outcome, because every outcome word comes from the
|
|
162
|
+
* verified log at the moment it is written.
|
|
163
|
+
* - **The token is never in an edit.** An annotation carries the outcome word,
|
|
164
|
+
* the action key, who decided, when, and the record's seq. It never carries
|
|
165
|
+
* the execution token, for the reason spelled out above.
|
|
166
|
+
*
|
|
167
|
+
* ## The bookkeeping is swept (APRV-135)
|
|
168
|
+
*
|
|
169
|
+
* Both maps used to be released only by process exit. Annotating a delivery
|
|
170
|
+
* removes its nonces, but nothing removes a delivery that is never annotated
|
|
171
|
+
* (a request that simply lapsed) or a digest whose members were each settled
|
|
172
|
+
* individually, so a listener left running for weeks held memory proportional
|
|
173
|
+
* to every prompt it had ever sent — and APRV-110's ambient runtime makes
|
|
174
|
+
* week-long listeners the normal case rather than the exception.
|
|
175
|
+
*
|
|
176
|
+
* {@link TelegramChannel.sweep} drops an entry when every member of it is
|
|
177
|
+
* terminal AND the entry is older than the policy's approval TTL. Both halves
|
|
178
|
+
* matter and the pair is what makes the drop safe: past the TTL the gate
|
|
179
|
+
* refuses every decision on the request, so a button referencing a dropped
|
|
180
|
+
* entry could not have been honoured anyway, and it is answered by the
|
|
181
|
+
* stale-callback path that a restarted listener's buttons already take. It is
|
|
182
|
+
* process memory and nothing else: no event is appended, no message is edited,
|
|
183
|
+
* and the log is not opened.
|
|
184
|
+
*/
|
|
185
|
+
import { createHash } from "node:crypto";
|
|
186
|
+
import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
|
|
187
|
+
// APRV-299. The reaction vocabulary and the verdict type, imported for the
|
|
188
|
+
// reason `core/audit.ts` states where it exports them: they are shown here and
|
|
189
|
+
// decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
|
|
190
|
+
// §11.1 invariant 10's guard scans the modules that decide, which this is not.
|
|
191
|
+
import { REACTIONS } from "../core/audit.js";
|
|
192
|
+
import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
193
|
+
import { commandPayloadView, payloadRegionText } from "./payload-view.js";
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Configuration
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// The variable NAMES and their resolvers live in `core/telegram-config.ts`
|
|
198
|
+
// (APRV-72, moved in APRV-73 so `approval env` can read them without a
|
|
199
|
+
// core -> channels import). Re-exported here so channel callers keep one
|
|
200
|
+
// import path. Still true: nothing under `src/channels/` reads `process.env`.
|
|
201
|
+
import { TELEGRAM_CHAT_ENV, TELEGRAM_TOKEN_ENV } from "../core/telegram-config.js";
|
|
202
|
+
export { TELEGRAM_CHAT_ENV, TELEGRAM_TOKEN_ENV, telegramChatEnvFor, telegramTokenEnvFor, } from "../core/telegram-config.js";
|
|
203
|
+
/** The real Bot API. Overridden only by tests, against a local mock. */
|
|
204
|
+
export const TELEGRAM_DEFAULT_API_BASE = "https://api.telegram.org";
|
|
205
|
+
/** Telegram's hard limit on a message's text. */
|
|
206
|
+
export const TELEGRAM_MAX_MESSAGE_CHARS = 4096;
|
|
207
|
+
/** Telegram's hard limit on `callback_data`, in bytes. */
|
|
208
|
+
export const TELEGRAM_MAX_CALLBACK_BYTES = 64;
|
|
209
|
+
/** The note recorded on a rejection collected from a button. */
|
|
210
|
+
export const TELEGRAM_REJECT_NOTE = "rejected via telegram";
|
|
211
|
+
/**
|
|
212
|
+
* The toast a tap gets when no branch produced one of its own (APRV-196).
|
|
213
|
+
*
|
|
214
|
+
* It is deliberately about the tap and not about the request: this text is only
|
|
215
|
+
* ever reached when the handler threw or forgot, which are exactly the states
|
|
216
|
+
* in which this process does not know what became of the request. Saying so is
|
|
217
|
+
* the honest answer, and it is still infinitely better than a button that spins.
|
|
218
|
+
*/
|
|
219
|
+
export const TELEGRAM_ACK_FALLBACK = "Received — this listener could not finish reading your tap. Nothing was recorded by it; check the message above for the outcome.";
|
|
220
|
+
/**
|
|
221
|
+
* The toast a tap gets the instant it is recognized, BEFORE the gate runs
|
|
222
|
+
* (APRV-206).
|
|
223
|
+
*
|
|
224
|
+
* Telegram gives a callback query exactly one answer, and until it arrives the
|
|
225
|
+
* button spins on the approver's phone. Sending it after the decision made the
|
|
226
|
+
* spinner as long as the decision — which grew with the log — and the human,
|
|
227
|
+
* with no way to tell a slow tap from a swallowed one, tapped again.
|
|
228
|
+
*
|
|
229
|
+
* So this is what the single answer says, and its wording is load-bearing: it
|
|
230
|
+
* claims only that the tap ARRIVED. It must never say granted, rejected,
|
|
231
|
+
* approved, recorded, or anything else a reader could take as "the log now says
|
|
232
|
+
* so", because at the moment it is sent nothing has been appended and the gate
|
|
233
|
+
* may still refuse. What became of the request is said by the message edit that
|
|
234
|
+
* follows, which is written from the record the gate actually appended (or from
|
|
235
|
+
* its refusal). The toast vanishes; the message stays.
|
|
236
|
+
*/
|
|
237
|
+
export const TELEGRAM_ACK_HEARD = "Heard — deciding. The message will say what the log recorded.";
|
|
238
|
+
/**
|
|
239
|
+
* The headline on a message whose tap the gate refused (APRV-206).
|
|
240
|
+
*
|
|
241
|
+
* Before the early ack, a refusal was a toast and the message was left alone.
|
|
242
|
+
* Now that the single answer is spent on "heard", the refusal has to reach the
|
|
243
|
+
* approver here or nowhere. The buttons go with it ({@link annotate} disarms),
|
|
244
|
+
* which is the right outcome in both directions: a request the gate calls
|
|
245
|
+
* terminal has no live decision left to collect, and a request that is still
|
|
246
|
+
* pending is re-delivered by the next dispatch cycle as a fresh prompt.
|
|
247
|
+
*/
|
|
248
|
+
export const TELEGRAM_NOT_RECORDED = "✗ NOT RECORDED";
|
|
249
|
+
/**
|
|
250
|
+
* The detail line under {@link TELEGRAM_NOT_RECORDED} when the runtime's
|
|
251
|
+
* decision handler threw (APRV-206).
|
|
252
|
+
*
|
|
253
|
+
* The wording is careful about what it does not know: a handler that threw may
|
|
254
|
+
* have thrown before or after its append, so this says where to look rather
|
|
255
|
+
* than what happened. The log is the thing that knows.
|
|
256
|
+
*/
|
|
257
|
+
export const TELEGRAM_HANDLER_FAILED = "This listener failed while recording your tap. Check `approval queue` — the log is what says whether anything was recorded.";
|
|
258
|
+
/** Prefixed to the toast when the tap arrived on a pre-restart copy (APRV-196). */
|
|
259
|
+
export const TELEGRAM_STALE_COPY_PREFIX = "Earlier copy of this request — ";
|
|
260
|
+
/**
|
|
261
|
+
* The toast for a tap on a copy of an action this process is not holding open,
|
|
262
|
+
* when no verified-log probe is configured to say more (APRV-196).
|
|
263
|
+
*/
|
|
264
|
+
export const TELEGRAM_STALE_UNKNOWN = "This request is not open here — it was already decided, it lapsed, or another listener holds it. Nothing was recorded.";
|
|
265
|
+
/**
|
|
266
|
+
* The headline of an ordinary single-request prompt.
|
|
267
|
+
*
|
|
268
|
+
* Exported because the mock Bot API and several tests key on it, and because a
|
|
269
|
+
* digest member's header deliberately does NOT use it: a member prompt carries
|
|
270
|
+
* no buttons, so calling it "APPROVAL REQUIRED" would point a reader at a
|
|
271
|
+
* message that cannot take their answer.
|
|
272
|
+
*/
|
|
273
|
+
export const TELEGRAM_PROMPT_HEADING = "APPROVAL REQUIRED";
|
|
274
|
+
/**
|
|
275
|
+
* What the label over the payload chunks names (APRV-162).
|
|
276
|
+
*
|
|
277
|
+
* The chunks carry the canonical rendering, which is a deterministic function
|
|
278
|
+
* of the bytes and not the bytes themselves; calling it "the exact bytes" told
|
|
279
|
+
* the reader that a diff view and a JSON file were the same object. The
|
|
280
|
+
* rendering names its own `display_hash`, and the store path inside it is the
|
|
281
|
+
* route back to the bytes.
|
|
282
|
+
*/
|
|
283
|
+
export const PAYLOAD_CHUNK_LABEL_TAIL = "the canonical rendering this approval's display_hash names; raw bytes at the store path inside";
|
|
284
|
+
export const PAYLOAD_CHUNK_LABEL = `PAYLOAD — ${PAYLOAD_CHUNK_LABEL_TAIL}`;
|
|
285
|
+
/**
|
|
286
|
+
* What the claimed block is headed, and what a second claimed message is headed
|
|
287
|
+
* when a rationale overflows one (APRV-165).
|
|
288
|
+
*
|
|
289
|
+
* Both say CLAIMED and both say NOT verified, because a continuation is a
|
|
290
|
+
* message a reader may see first, and a claimed line that arrives under no
|
|
291
|
+
* heading at all reads as the runtime's own.
|
|
292
|
+
*/
|
|
293
|
+
export const TELEGRAM_CLAIMED_HEADING_PREFIX = "WHAT THIS DOES — CLAIMED by";
|
|
294
|
+
export const TELEGRAM_CLAIMED_HEADING_SUFFIX = "NOT verified by the runtime";
|
|
295
|
+
export const TELEGRAM_CLAIMED_CONTINUED_HEADING = `WHAT THIS DOES (continued) — CLAIMED, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}`;
|
|
296
|
+
/**
|
|
297
|
+
* The most members one digest may carry (APRV-115).
|
|
298
|
+
*
|
|
299
|
+
* Not a rendering limit — {@link renderDigest} checks the real one against
|
|
300
|
+
* {@link TELEGRAM_MAX_MESSAGE_CHARS} — but a *reading* one: a keyboard of
|
|
301
|
+
* twenty rows is a wall, and the failure this feature exists to fix is a human
|
|
302
|
+
* who stops reading. A burst larger than this becomes several digests, which is
|
|
303
|
+
* the direction this whole design fails in.
|
|
304
|
+
*/
|
|
305
|
+
export const TELEGRAM_DIGEST_MAX_MEMBERS = 8;
|
|
306
|
+
/**
|
|
307
|
+
* The headline each terminal state puts on the message it settles (APRV-113).
|
|
308
|
+
*
|
|
309
|
+
* Keyed by `core/state.ts`'s `RequestState` names for the terminal states, so
|
|
310
|
+
* the caller that derived the state from the verified log picks a word by
|
|
311
|
+
* indexing rather than by re-deciding what happened.
|
|
312
|
+
*
|
|
313
|
+
* Glyphs, not emoji: `✓`/`✗` are the vocabulary `cli/style.ts` uses for the
|
|
314
|
+
* same ok/fail distinction, and every line of *message text* this channel
|
|
315
|
+
* writes ("APPROVAL REQUIRED", "PAYLOAD", "WITHDRAWN") is emoji-free. The
|
|
316
|
+
* emoji live on the button labels, which are a different surface and stay as
|
|
317
|
+
* they are. `withdrawn` keeps the exact wording APRV-106 shipped.
|
|
318
|
+
*/
|
|
319
|
+
export const TELEGRAM_TERMINAL_HEADLINES = {
|
|
320
|
+
granted: "✓ APPROVED",
|
|
321
|
+
rejected: "✗ REJECTED",
|
|
322
|
+
revoked: "✗ REVOKED — the grant was taken back",
|
|
323
|
+
expired: "✗ EXPIRED — the approval window closed",
|
|
324
|
+
withdrawn: "WITHDRAWN — no decision is needed",
|
|
325
|
+
};
|
|
326
|
+
/** Whether a derived request state is one an annotation can settle a message on. */
|
|
327
|
+
export function isTelegramTerminalState(state) {
|
|
328
|
+
return Object.prototype.hasOwnProperty.call(TELEGRAM_TERMINAL_HEADLINES, state);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `HH:MM UTC`, or the raw instant when it does not parse.
|
|
332
|
+
*
|
|
333
|
+
* UTC and not a local zone: the listener, the approver's phone and the log can
|
|
334
|
+
* all be in different places, and the log's own timestamps are UTC. A clock a
|
|
335
|
+
* reader can line up against `approval log` beats one that matches their wrist.
|
|
336
|
+
*/
|
|
337
|
+
export function utcClock(ts) {
|
|
338
|
+
const ms = Date.parse(ts);
|
|
339
|
+
if (Number.isNaN(ms))
|
|
340
|
+
return ts;
|
|
341
|
+
const at = new Date(ms);
|
|
342
|
+
return `${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")} UTC`;
|
|
343
|
+
}
|
|
344
|
+
/** The "who decided, when, and which record says so" line of an annotation. */
|
|
345
|
+
export function decidedLine(actor, ts, seq) {
|
|
346
|
+
return `by ${actor} at ${utcClock(ts)} (seq ${seq})`;
|
|
347
|
+
}
|
|
348
|
+
/** Room left under {@link TELEGRAM_MAX_MESSAGE_CHARS} for our own markup. */
|
|
349
|
+
const SEGMENT_BUDGET = 3600;
|
|
350
|
+
/** The `getUpdates` long-poll timeout, in seconds, when none is configured. */
|
|
351
|
+
const DEFAULT_POLL_TIMEOUT_SECONDS = 25;
|
|
352
|
+
/** First backoff step after a failed poll. Doubles, capped. */
|
|
353
|
+
const DEFAULT_BACKOFF_MS = 1_000;
|
|
354
|
+
const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
355
|
+
/**
|
|
356
|
+
* How long a settled delivery is remembered when the policy declares no
|
|
357
|
+
* `defaults.approval_ttl` (APRV-135).
|
|
358
|
+
*
|
|
359
|
+
* A policy with no TTL bounds nothing, so "past the approval TTL" can never
|
|
360
|
+
* become true and a sweep keyed on it alone would never fire — which is the
|
|
361
|
+
* unbounded map this task exists to remove. The retention floor takes over
|
|
362
|
+
* there, and it applies only to entries whose every member this process has
|
|
363
|
+
* seen settled: with no TTL an undecided request stays answerable forever, and
|
|
364
|
+
* forgetting its button would take a live decision away from an approver.
|
|
365
|
+
*
|
|
366
|
+
* A day, because the point of remembering a settled delivery at all is that an
|
|
367
|
+
* approver may still tap a button on a message already scrolled past, and the
|
|
368
|
+
* answer they should get is the stale-callback reply either way.
|
|
369
|
+
*/
|
|
370
|
+
export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
371
|
+
/** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
|
|
372
|
+
export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Anomalies
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
/**
|
|
377
|
+
* Why a callback was ignored.
|
|
378
|
+
*
|
|
379
|
+
* Every one of these is counted and complained about on stderr, and **none of
|
|
380
|
+
* them reaches the decision path or the log**. An ignored callback is not an
|
|
381
|
+
* event: writing "someone we do not answer to pressed a button" into an
|
|
382
|
+
* append-only approval log would let any stranger who guessed the bot's handle
|
|
383
|
+
* grow the record a human is asked to trust.
|
|
384
|
+
*/
|
|
385
|
+
export const TELEGRAM_ANOMALY_KINDS = [
|
|
386
|
+
/** The callback came from a chat that is not the configured one. */
|
|
387
|
+
"foreign-chat",
|
|
388
|
+
/** `callback_data` did not parse as one of ours. */
|
|
389
|
+
"malformed-callback",
|
|
390
|
+
/** A well-formed nonce this listener never issued (or issued before a restart). */
|
|
391
|
+
"unknown-callback",
|
|
392
|
+
/** The action key carried in `callback_data` disagrees with the issued nonce. */
|
|
393
|
+
"key-mismatch",
|
|
394
|
+
/**
|
|
395
|
+
* A tap on a copy of a request this process is no longer holding open
|
|
396
|
+
* (APRV-196): the nonce is not one of ours, and the action it names is not
|
|
397
|
+
* pending here either — it was decided, it lapsed, or another process owns
|
|
398
|
+
* it. Distinct from `unknown-callback` because the operator's question is
|
|
399
|
+
* different: nothing is wrong with the button, the question behind it is
|
|
400
|
+
* over. Always answered with a toast that names the state.
|
|
401
|
+
*/
|
|
402
|
+
"stale-copy",
|
|
403
|
+
/**
|
|
404
|
+
* A message in the approver chat that began with `/` and named no command
|
|
405
|
+
* this channel answers (APRV-216). Counted rather than replied to: the chat
|
|
406
|
+
* belongs to a human, other bots and other slash commands live in it, and a
|
|
407
|
+
* channel that answered every unrecognised one would be noise in the one
|
|
408
|
+
* place an approver's attention is supposed to be scarce.
|
|
409
|
+
*/
|
|
410
|
+
"unknown-command",
|
|
411
|
+
];
|
|
412
|
+
export const TELEGRAM_COMMANDS = ["queue", "skip", "next"];
|
|
413
|
+
/**
|
|
414
|
+
* The command a message's text names, or `null` (APRV-216).
|
|
415
|
+
*
|
|
416
|
+
* Pure, and exported so the listener's tests can exercise the grammar without
|
|
417
|
+
* a transport. Telegram delivers a command in a group chat as `/skip@thebot`,
|
|
418
|
+
* so the `@suffix` is stripped; the bot's own username is not checked, because
|
|
419
|
+
* this channel only ever reads ONE chat and a message in it that says `/skip`
|
|
420
|
+
* to some other bot is a message the approver still meant as a skip more often
|
|
421
|
+
* than not. Anything after the command word is ignored: none of these three
|
|
422
|
+
* takes an argument, and silently discarding one is better than refusing a
|
|
423
|
+
* command a human typed with a stray word on the end.
|
|
424
|
+
*/
|
|
425
|
+
export function parseBotCommand(text) {
|
|
426
|
+
if (typeof text !== "string")
|
|
427
|
+
return null;
|
|
428
|
+
const first = text.trim().split(/\s+/u)[0];
|
|
429
|
+
if (first === undefined || !first.startsWith("/"))
|
|
430
|
+
return null;
|
|
431
|
+
const word = first.slice(1).split("@")[0]?.toLowerCase();
|
|
432
|
+
return TELEGRAM_COMMANDS.find((command) => command === word) ?? null;
|
|
433
|
+
}
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Small helpers
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
/** The three characters Telegram's HTML mode treats as markup. */
|
|
438
|
+
export function escapeHtml(text) {
|
|
439
|
+
return text.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
440
|
+
}
|
|
441
|
+
function sleep(ms) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
setTimeout(resolve, ms);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/** A field's `source` / `author` label, for the "(log)" / "(agent:x)" suffix. */
|
|
447
|
+
function originOf(field) {
|
|
448
|
+
return field.kind === "computed" ? field.source : field.author;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The suffix the model-authored line carries, on the line itself (APRV-144).
|
|
452
|
+
*
|
|
453
|
+
* One constant, shared with the terminal channel since APRV-197: this name is
|
|
454
|
+
* kept because the tests and the help text pin it, and it now resolves to
|
|
455
|
+
* {@link GLOSS_UNVERIFIED_SUFFIX} so the two surfaces cannot drift apart.
|
|
456
|
+
*/
|
|
457
|
+
export const TELEGRAM_GLOSS_SUFFIX = GLOSS_UNVERIFIED_SUFFIX;
|
|
458
|
+
/**
|
|
459
|
+
* The prefix a health row carries when it is the reason to look (APRV-163).
|
|
460
|
+
*
|
|
461
|
+
* Only the abnormal state of `autonomy`, `budgets` and the attestation renders
|
|
462
|
+
* at all, so the mark is never routine: a row bearing it is a row the reader
|
|
463
|
+
* has not seen on the last twenty prompts.
|
|
464
|
+
*/
|
|
465
|
+
export const TELEGRAM_ANOMALY_MARK = "⚠ ";
|
|
466
|
+
function line(field, tagged, label, text) {
|
|
467
|
+
return { field, kind: tagged.kind, label, text, origin: originOf(tagged) };
|
|
468
|
+
}
|
|
469
|
+
function budgetSummary(request) {
|
|
470
|
+
const verdicts = request.budgets.value;
|
|
471
|
+
if (verdicts.length === 0)
|
|
472
|
+
return "no limits apply";
|
|
473
|
+
return verdicts
|
|
474
|
+
.map((verdict) => {
|
|
475
|
+
const state = verdict.pass ? "ok" : "EXCEEDED";
|
|
476
|
+
return `${state} ${verdict.scope}.${verdict.limit} (${verdict.window}) consumed ${verdict.consumed} + this ${verdict.requested}, ${verdict.remaining} left`;
|
|
477
|
+
})
|
|
478
|
+
.join("; ");
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The attestation line.
|
|
482
|
+
*
|
|
483
|
+
* Anything but `attested` is shouted, because an unattested or drifted policy
|
|
484
|
+
* means the rule that produced `autonomy: manual` above is not the rule a human
|
|
485
|
+
* signed off on — which is exactly the thing an approver must not have to infer.
|
|
486
|
+
*/
|
|
487
|
+
function attestationSummary(request) {
|
|
488
|
+
const status = request.attestation.value;
|
|
489
|
+
switch (status.status) {
|
|
490
|
+
case "attested":
|
|
491
|
+
return `attested (seq ${status.seq})`;
|
|
492
|
+
case "not-attested":
|
|
493
|
+
return "NOT ATTESTED — no human has signed off on this policy file";
|
|
494
|
+
case "hash-mismatch":
|
|
495
|
+
return `HASH MISMATCH — the policy file changed since attestation seq ${status.seq}`;
|
|
496
|
+
default:
|
|
497
|
+
return `UNREADABLE — ${status.message}`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function formatTelegramTtl(ms) {
|
|
501
|
+
if (ms === null)
|
|
502
|
+
return "no expiry declared";
|
|
503
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
504
|
+
const minutes = Math.floor(seconds / 60);
|
|
505
|
+
const hours = Math.floor(minutes / 60);
|
|
506
|
+
if (hours > 0)
|
|
507
|
+
return `${String(hours)}h ${String(minutes % 60)}m left`;
|
|
508
|
+
if (minutes > 0)
|
|
509
|
+
return `${String(minutes)}m ${String(seconds % 60)}s left`;
|
|
510
|
+
return `${String(seconds)}s left`;
|
|
511
|
+
}
|
|
512
|
+
/** The rows a review card renders, in the order it renders them (APRV-299). */
|
|
513
|
+
export const REVIEW_CARD_ROWS = [
|
|
514
|
+
"class",
|
|
515
|
+
"command_breakdown",
|
|
516
|
+
"task",
|
|
517
|
+
"summary",
|
|
518
|
+
"gloss",
|
|
519
|
+
];
|
|
520
|
+
/**
|
|
521
|
+
* The five rows a prompt and a review card render identically (APRV-299).
|
|
522
|
+
*
|
|
523
|
+
* Extracted from {@link telegramRow} rather than copied, so that a card and a
|
|
524
|
+
* prompt cannot come to describe the same class, the same command or the same
|
|
525
|
+
* claimed summary in two different ways. The gloss keeps every property
|
|
526
|
+
* APRV-144 gave it here too: it renders under the CLAIMED heading because its
|
|
527
|
+
* field says `claimed`, it carries {@link TELEGRAM_GLOSS_SUFFIX} on the line
|
|
528
|
+
* itself, and nothing branches on what it says.
|
|
529
|
+
*/
|
|
530
|
+
function reviewRow(fields, row) {
|
|
531
|
+
const normal = (line_) => ({ line: line_, abnormal: false });
|
|
532
|
+
switch (row) {
|
|
533
|
+
case "task":
|
|
534
|
+
return normal(line("task", fields.task, "task", fields.task.value ?? "(none)"));
|
|
535
|
+
case "class":
|
|
536
|
+
return normal(line("class", fields.class, "class", fields.class.value));
|
|
537
|
+
case "command_breakdown":
|
|
538
|
+
return fields.command_breakdown === undefined
|
|
539
|
+
? null
|
|
540
|
+
: normal(line("command_breakdown", fields.command_breakdown, "commands", fields.command_breakdown.value));
|
|
541
|
+
case "gloss":
|
|
542
|
+
return fields.gloss === undefined
|
|
543
|
+
? null
|
|
544
|
+
: normal(line("gloss", fields.gloss, "gloss", `${fields.gloss.value} ${TELEGRAM_GLOSS_SUFFIX}`));
|
|
545
|
+
case "summary":
|
|
546
|
+
return normal(line("summary", fields.summary, "summary", fields.summary.value ?? "(none given)"));
|
|
547
|
+
default:
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* One row's line, or `null` when this request does not carry it and when the
|
|
553
|
+
* channel renders it structurally rather than as a bullet.
|
|
554
|
+
*
|
|
555
|
+
* Every row in {@link PROMPT_ROWS} has a case, including the eight APRV-143 and
|
|
556
|
+
* APRV-163 dropped from the default layout. Those fields never left the
|
|
557
|
+
* `ChannelRequest` — the tasks slimmed the phone rendering and nothing else —
|
|
558
|
+
* so an operator turning one back on with `always` is asking for a line this
|
|
559
|
+
* channel can already build, not for a new fact about the log.
|
|
560
|
+
*
|
|
561
|
+
* The five rows a review card shares live in {@link reviewRow} since APRV-299
|
|
562
|
+
* and are delegated to here.
|
|
563
|
+
*/
|
|
564
|
+
function telegramRow(request, row) {
|
|
565
|
+
const normal = (line_) => ({ line: line_, abnormal: false });
|
|
566
|
+
switch (row) {
|
|
567
|
+
// Structural, not a bullet: the action key is the message's second line, in
|
|
568
|
+
// its own `<code>` span. It is a required row, so no policy can hide it,
|
|
569
|
+
// and it is not a bullet, so forcing it on adds nothing.
|
|
570
|
+
case "action_key":
|
|
571
|
+
return null;
|
|
572
|
+
case "task":
|
|
573
|
+
case "class":
|
|
574
|
+
case "command_breakdown":
|
|
575
|
+
case "gloss":
|
|
576
|
+
case "summary":
|
|
577
|
+
return reviewRow(request, row);
|
|
578
|
+
case "protected_path":
|
|
579
|
+
return request.protected_path === undefined
|
|
580
|
+
? null
|
|
581
|
+
: normal(line("protected_path", request.protected_path, "protected path", request.protected_path.value));
|
|
582
|
+
case "policy_diff":
|
|
583
|
+
return request.policy_diff === undefined
|
|
584
|
+
? null
|
|
585
|
+
: normal(line("policy_diff", request.policy_diff, "policy diff", request.policy_diff.value));
|
|
586
|
+
case "policy_load":
|
|
587
|
+
return request.policy_load === undefined
|
|
588
|
+
? null
|
|
589
|
+
: normal(line("policy_load", request.policy_load, "policy loads", request.policy_load.value));
|
|
590
|
+
case "autonomy":
|
|
591
|
+
return {
|
|
592
|
+
line: line("autonomy", request.autonomy, "autonomy", request.autonomy.value),
|
|
593
|
+
abnormal: request.autonomy.value !== "manual",
|
|
594
|
+
};
|
|
595
|
+
case "budgets":
|
|
596
|
+
return {
|
|
597
|
+
line: line("budgets", request.budgets, "budgets", budgetSummary(request)),
|
|
598
|
+
abnormal: !request.budgets.value.every((verdict) => verdict.pass),
|
|
599
|
+
};
|
|
600
|
+
case "attestation":
|
|
601
|
+
return {
|
|
602
|
+
line: line("attestation", request.attestation, "policy", attestationSummary(request)),
|
|
603
|
+
abnormal: request.attestation.value.status !== "attested",
|
|
604
|
+
};
|
|
605
|
+
case "provenance":
|
|
606
|
+
return normal(line("provenance", request.provenance, "resolved by", request.provenance.value));
|
|
607
|
+
case "state":
|
|
608
|
+
return normal(line("state", request.state, "state", request.state.value));
|
|
609
|
+
case "requested_ts":
|
|
610
|
+
return normal(line("requested_ts", request.requested_ts, "requested", request.requested_ts.value));
|
|
611
|
+
case "waiting":
|
|
612
|
+
return normal(line("waiting", request.waiting, "waiting", request.waiting.value));
|
|
613
|
+
case "ttl_remaining_ms":
|
|
614
|
+
return normal(line("ttl_remaining_ms", request.ttl_remaining_ms, "ttl", formatTelegramTtl(request.ttl_remaining_ms.value)));
|
|
615
|
+
case "payload_hash":
|
|
616
|
+
return normal(line("payload_hash", request.payload_hash, "payload sha256", request.payload_hash.value));
|
|
617
|
+
case "chain": {
|
|
618
|
+
const chain = request.chain.value;
|
|
619
|
+
return normal(line("chain", request.chain, "chain", `seq ${String(chain.seq)} hash ${chain.hash} (log head seq ${String(chain.head_seq)})`));
|
|
620
|
+
}
|
|
621
|
+
case "token_delivery":
|
|
622
|
+
return request.token_delivery === undefined
|
|
623
|
+
? null
|
|
624
|
+
: normal(line("token_delivery", request.token_delivery, "token delivery", request.token_delivery.value));
|
|
625
|
+
case "est_cost_usd":
|
|
626
|
+
return normal(line("est_cost_usd", request.est_cost_usd, "est. cost", `$${request.est_cost_usd.value.toFixed(2)}`));
|
|
627
|
+
// APRV-144's `gloss` and the `summary` row are two of the five {@link
|
|
628
|
+
// reviewRow} answers above. Under the CLAIMED heading, because a model's
|
|
629
|
+
// sentence is not something the runtime derived, and labelled on the line
|
|
630
|
+
// as well: the `(author)` parenthetical every claimed line already carries
|
|
631
|
+
// is small, uniform and easy to stop seeing, and the gloss is the one line
|
|
632
|
+
// in the message that NO party — not the runtime, not even the requesting
|
|
633
|
+
// agent — stands behind. Nothing here or anywhere else branches on what it
|
|
634
|
+
// says, and a layout cannot move it out from under that heading: the region
|
|
635
|
+
// a line lands in comes from the field's `kind`, applied after the ordering.
|
|
636
|
+
case "rationale":
|
|
637
|
+
return request.rationale === undefined
|
|
638
|
+
? null
|
|
639
|
+
: normal(line("rationale", request.rationale, "rationale", request.rationale.value));
|
|
640
|
+
case "confidence":
|
|
641
|
+
return request.confidence === undefined
|
|
642
|
+
? null
|
|
643
|
+
: normal(line("confidence", request.confidence, "confidence", `${String(request.confidence.value)} (never a gate)`));
|
|
644
|
+
default:
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Build the two regions and the line list. Pure: no I/O, no clock.
|
|
650
|
+
*
|
|
651
|
+
* `heading` is the message's first line. It is a parameter for exactly one
|
|
652
|
+
* reason (APRV-115): a digest member's prompt carries no buttons, and telling
|
|
653
|
+
* a reader "APPROVAL REQUIRED" above a message they cannot answer on is the
|
|
654
|
+
* kind of small lie that costs a channel its legibility. Everything below the
|
|
655
|
+
* first line is identical either way, computed/claimed split included.
|
|
656
|
+
*
|
|
657
|
+
* `layout` is the policy's answer to which rows this channel shows (APRV-218).
|
|
658
|
+
* It defaults to {@link TELEGRAM_PROMPT_LAYOUT}, which is the slimmed prompt
|
|
659
|
+
* APRV-143 and APRV-163 left behind, so a policy that declares no
|
|
660
|
+
* `channels.telegram.prompt` renders byte for byte what it rendered before the
|
|
661
|
+
* key existed. Rendering stays a pure function of (request, layout): the layout
|
|
662
|
+
* chooses among facts the request already carries and teaches this channel
|
|
663
|
+
* nothing about the log.
|
|
664
|
+
*
|
|
665
|
+
* The computed/claimed split survives ANY ordering, and that is a property
|
|
666
|
+
* rather than a convention. `layout.order` decides the sequence rows are
|
|
667
|
+
* considered in; the partition below is by `Line.kind`, which comes from the
|
|
668
|
+
* `TaggedField` the row was built from. A `rows` list that puts `summary`
|
|
669
|
+
* first therefore puts it first among the CLAIMED lines, and never above the
|
|
670
|
+
* computed heading.
|
|
671
|
+
*/
|
|
672
|
+
export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layout = TELEGRAM_PROMPT_LAYOUT) {
|
|
673
|
+
const payload = request.fullPayload.value;
|
|
674
|
+
const computedLines = [];
|
|
675
|
+
const claimedLines = [];
|
|
676
|
+
for (const row of layout.order) {
|
|
677
|
+
const visibility = layout.visibility[row];
|
|
678
|
+
if (visibility === "off")
|
|
679
|
+
continue;
|
|
680
|
+
const candidate = telegramRow(request, row);
|
|
681
|
+
if (candidate === null)
|
|
682
|
+
continue;
|
|
683
|
+
if (visibility === "abnormal" && !candidate.abnormal)
|
|
684
|
+
continue;
|
|
685
|
+
const entry = candidate.abnormal
|
|
686
|
+
? { ...candidate.line, label: `${TELEGRAM_ANOMALY_MARK}${candidate.line.label}` }
|
|
687
|
+
: candidate.line;
|
|
688
|
+
if (entry.kind === "computed")
|
|
689
|
+
computedLines.push(entry);
|
|
690
|
+
else
|
|
691
|
+
claimedLines.push(entry);
|
|
692
|
+
}
|
|
693
|
+
const author = request.summary.kind === "claimed" ? request.summary.author : "the requesting party";
|
|
694
|
+
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
|
|
695
|
+
const header = [
|
|
696
|
+
`<b>${escapeHtml(heading)}</b>`,
|
|
697
|
+
`<code>${escapeHtml(request.action_key.value)}</code>`,
|
|
698
|
+
"",
|
|
699
|
+
"<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
|
|
700
|
+
...computedLines.map(render),
|
|
701
|
+
].join("\n");
|
|
702
|
+
const claimedText = [
|
|
703
|
+
`<b>${TELEGRAM_CLAIMED_HEADING_PREFIX} ${escapeHtml(author)}, ${TELEGRAM_CLAIMED_HEADING_SUFFIX}</b>`,
|
|
704
|
+
...claimedLines.map(render),
|
|
705
|
+
].join("\n");
|
|
706
|
+
return {
|
|
707
|
+
// Computed first, then claimed, whatever order the messages go out in:
|
|
708
|
+
// `lines` is the conformance suite's view of what was rendered, and the
|
|
709
|
+
// two-kind split it checks is a property of the fields, not of the layout.
|
|
710
|
+
lines: [...computedLines, ...claimedLines],
|
|
711
|
+
header,
|
|
712
|
+
claimedText,
|
|
713
|
+
// A whole payload needs no prefix: the canonical block states its own
|
|
714
|
+
// renderer, class, kind and `payload sha256` in its first lines, and a
|
|
715
|
+
// second sha256 above it is one more line between the reader and the
|
|
716
|
+
// action. A TRUNCATED rendering has no canonical block to say any of that,
|
|
717
|
+
// so it gets a prefix worded as what it is: a refusal. Neither is reachable
|
|
718
|
+
// from a layout: the block is not a row (APRV-218).
|
|
719
|
+
payloadText: payload === null
|
|
720
|
+
? null
|
|
721
|
+
: `${payload.truncated ? `--- payload TRUNCATED at render (sha256 ${payload.hash}) — no canonical rendering exists; do not grant on this ---\n` : ""}${payloadRegionText(payload, request.class.value)}`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Split `text` so every chunk survives HTML escaping inside the message limit.
|
|
726
|
+
*
|
|
727
|
+
* Splitting is by *escaped* length, because `&` becomes five characters and a
|
|
728
|
+
* payload full of them would otherwise produce a message Telegram rejects. The
|
|
729
|
+
* payload is never truncated to fit: the bytes a human is asked to approve are
|
|
730
|
+
* the bytes the token will execute, so an oversized payload becomes several
|
|
731
|
+
* messages, never a shortened one.
|
|
732
|
+
*/
|
|
733
|
+
export function chunkForTelegram(text, budget = SEGMENT_BUDGET) {
|
|
734
|
+
const chunks = [];
|
|
735
|
+
let current = "";
|
|
736
|
+
let cost = 0;
|
|
737
|
+
for (const character of text) {
|
|
738
|
+
const size = escapeHtml(character).length;
|
|
739
|
+
if (cost + size > budget && current.length > 0) {
|
|
740
|
+
chunks.push(current);
|
|
741
|
+
current = "";
|
|
742
|
+
cost = 0;
|
|
743
|
+
}
|
|
744
|
+
current += character;
|
|
745
|
+
cost += size;
|
|
746
|
+
}
|
|
747
|
+
if (current.length > 0 || chunks.length === 0)
|
|
748
|
+
chunks.push(current);
|
|
749
|
+
return chunks;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Split an already-marked-up segment so every chunk is valid HTML on its own.
|
|
753
|
+
*
|
|
754
|
+
* {@link chunkForTelegram} may cut anywhere because its caller escapes each
|
|
755
|
+
* chunk and wraps it in `<pre>`; the claimed segment carries markup, so a cut
|
|
756
|
+
* inside `<b>` or inside `&` would reach Telegram as a parse error, and a
|
|
757
|
+
* cut between an opening tag and its close would reach it as unbalanced HTML.
|
|
758
|
+
* Tags and entities are therefore atomic here, and the break is taken at the
|
|
759
|
+
* last line boundary in the chunk when there is one, which keeps each bullet
|
|
760
|
+
* whole and balanced. A bullet longer than the budget on its own (a rationale
|
|
761
|
+
* is unbounded agent text) splits inside its text, between tags, never within
|
|
762
|
+
* one — and it splits rather than being shortened, for the same reason a
|
|
763
|
+
* payload does.
|
|
764
|
+
*/
|
|
765
|
+
export function chunkClaimedForTelegram(text, budget = SEGMENT_BUDGET) {
|
|
766
|
+
/** One line, as pieces no longer than the budget, cut between atoms only. */
|
|
767
|
+
const pieces = (input) => {
|
|
768
|
+
if (input.length <= budget)
|
|
769
|
+
return [input];
|
|
770
|
+
const out = [];
|
|
771
|
+
let piece = "";
|
|
772
|
+
for (const atom of input.match(/<[^>]*>|&[^;\s]*;|[\s\S]/gu) ?? []) {
|
|
773
|
+
if (piece.length + atom.length > budget && piece.length > 0) {
|
|
774
|
+
out.push(piece);
|
|
775
|
+
piece = "";
|
|
776
|
+
}
|
|
777
|
+
piece += atom;
|
|
778
|
+
}
|
|
779
|
+
if (piece.length > 0)
|
|
780
|
+
out.push(piece);
|
|
781
|
+
return out;
|
|
782
|
+
};
|
|
783
|
+
const chunks = [];
|
|
784
|
+
let current = "";
|
|
785
|
+
for (const linePieces of text.split("\n").map(pieces)) {
|
|
786
|
+
for (const piece of linePieces) {
|
|
787
|
+
const candidate = current.length === 0 ? piece : `${current}\n${piece}`;
|
|
788
|
+
if (candidate.length > budget) {
|
|
789
|
+
if (current.length > 0)
|
|
790
|
+
chunks.push(current);
|
|
791
|
+
current = piece;
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
current = candidate;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (current.length > 0 || chunks.length === 0)
|
|
799
|
+
chunks.push(current);
|
|
800
|
+
return chunks;
|
|
801
|
+
}
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
// Digests (APRV-115)
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
/**
|
|
806
|
+
* The shape token of a payload, for grouping.
|
|
807
|
+
*
|
|
808
|
+
* A shell command groups by its `argv[0]`, because that is what makes forty
|
|
809
|
+
* `network.call` prompts "the same question forty times" to the human reading
|
|
810
|
+
* them: forty `curl`s are one decision with forty URLs in it, and a `curl` next
|
|
811
|
+
* to an `rm` is not. Everything else groups by its top-level key set, which is
|
|
812
|
+
* the structural sense in which two payloads are the same shape.
|
|
813
|
+
*
|
|
814
|
+
* Structural, never self-declared: nothing here reads a `kind` or `type` field,
|
|
815
|
+
* for the reason `payload-view.ts` spells out — a field authored by the party
|
|
816
|
+
* under oversight must not choose how the party's requests are presented.
|
|
817
|
+
*/
|
|
818
|
+
export function payloadShapeKey(value) {
|
|
819
|
+
const command = commandPayloadView(value);
|
|
820
|
+
if (command !== null) {
|
|
821
|
+
const argv0 = command.command.trim().split(/\s+/u)[0] ?? "";
|
|
822
|
+
return `argv0:${argv0}`;
|
|
823
|
+
}
|
|
824
|
+
if (value === null)
|
|
825
|
+
return "null";
|
|
826
|
+
if (Array.isArray(value))
|
|
827
|
+
return "array";
|
|
828
|
+
if (typeof value !== "object")
|
|
829
|
+
return `scalar:${typeof value}`;
|
|
830
|
+
return `keys:${Object.keys(value).sort().join(",")}`;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* The grouping key: requests that share it are the same question asked twice.
|
|
834
|
+
*
|
|
835
|
+
* Signed off 2026-08-25 as (class, origin session/task, argv[0] or payload
|
|
836
|
+
* shape). The requesting actor rides along too, which can only ever SPLIT a
|
|
837
|
+
* group — two agents working the same task get two digests — and splitting is
|
|
838
|
+
* the safe direction: it costs a message and never merges two things a human
|
|
839
|
+
* would have wanted to weigh separately.
|
|
840
|
+
*
|
|
841
|
+
* `"\0"` as the separator because every component is agent-influenced text
|
|
842
|
+
* and a separator that can appear inside one would let a crafted task name
|
|
843
|
+
* collide two classes into one group. Written as the escape, never the raw
|
|
844
|
+
* byte: a literal NUL in the source turns this file into "binary" for grep,
|
|
845
|
+
* diff tooling, and editors, and the escape compiles to the same string.
|
|
846
|
+
*/
|
|
847
|
+
export function digestKeyOf(request) {
|
|
848
|
+
return [
|
|
849
|
+
request.class.value,
|
|
850
|
+
request.task.value ?? "",
|
|
851
|
+
request.autonomy.value,
|
|
852
|
+
originOf(request.summary),
|
|
853
|
+
payloadShapeKey(request.fullPayload.value?.value),
|
|
854
|
+
].join("\0");
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Split `requests` into digest groups, preserving queue order.
|
|
858
|
+
*
|
|
859
|
+
* One poll window is the whole window: this is called on the requests one
|
|
860
|
+
* dispatch cycle found undelivered, and nothing here waits for more. A group of
|
|
861
|
+
* one is returned as a group of one, and the caller sends it as an ordinary
|
|
862
|
+
* prompt.
|
|
863
|
+
*/
|
|
864
|
+
/**
|
|
865
|
+
* The key that makes ONE tool call one question (APRV-287), or `null` for a
|
|
866
|
+
* request that names no task or no payload.
|
|
867
|
+
*
|
|
868
|
+
* A shell command that touches several classes raises one request per class
|
|
869
|
+
* (`cli/hook.ts` mints `<task>:<class>` keys), and every one of them carries the
|
|
870
|
+
* same task and the same payload hash: they are one command, asked about once,
|
|
871
|
+
* with the log keeping a record per class because that is what audit granularity
|
|
872
|
+
* requires. Grouping them by class the way {@link digestKeyOf} does put five
|
|
873
|
+
* separate cards on a phone for one `git commit && git push` on 2026-09-06, and
|
|
874
|
+
* the approver had to tap through three rounds of them.
|
|
875
|
+
*
|
|
876
|
+
* The pair is enough on its own. A task id is one tool call, and a payload hash
|
|
877
|
+
* is the bytes it is about, so two requests sharing both are two classes of one
|
|
878
|
+
* command and can never be two commands. Both are computed: the task id is
|
|
879
|
+
* minted by the runtime and the hash is recomputed from the payload bytes
|
|
880
|
+
* (`channels/contract.ts`), so nothing an agent authors chooses this grouping.
|
|
881
|
+
*/
|
|
882
|
+
function toolCallKeyOf(request) {
|
|
883
|
+
const task = request.task.value;
|
|
884
|
+
const hash = request.payload_hash.value;
|
|
885
|
+
if (task === null || task.length === 0)
|
|
886
|
+
return null;
|
|
887
|
+
if (typeof hash !== "string" || hash.length === 0)
|
|
888
|
+
return null;
|
|
889
|
+
return [task, hash].join("\0");
|
|
890
|
+
}
|
|
891
|
+
export function groupForDigest(requests, max = TELEGRAM_DIGEST_MAX_MEMBERS) {
|
|
892
|
+
const groups = [];
|
|
893
|
+
const byKey = new Map();
|
|
894
|
+
// APRV-287, before the class grouping and never instead of it. The classes of
|
|
895
|
+
// one tool call are one question however many they are; everything else is
|
|
896
|
+
// grouped as it always was, so a burst of forty separate `network.call`s from
|
|
897
|
+
// forty tool calls still digests by class.
|
|
898
|
+
// Only where the classes DIFFER: members of one tool call that share a class
|
|
899
|
+
// are grouped by the class key already, and the two groupings then agree.
|
|
900
|
+
// Narrowing it this way keeps every existing grouping exactly as it was and
|
|
901
|
+
// changes only the case this task is about, the one command a human was asked
|
|
902
|
+
// about once per class.
|
|
903
|
+
const oneCall = new Set();
|
|
904
|
+
const seen = new Map();
|
|
905
|
+
for (const request of requests) {
|
|
906
|
+
const key = toolCallKeyOf(request);
|
|
907
|
+
if (key === null)
|
|
908
|
+
continue;
|
|
909
|
+
const classes = seen.get(key) ?? new Set();
|
|
910
|
+
classes.add(request.class.value);
|
|
911
|
+
seen.set(key, classes);
|
|
912
|
+
if (classes.size > 1)
|
|
913
|
+
oneCall.add(key);
|
|
914
|
+
}
|
|
915
|
+
for (const request of requests) {
|
|
916
|
+
const call = toolCallKeyOf(request);
|
|
917
|
+
const key = call !== null && oneCall.has(call) ? `tool-call\0${call}` : digestKeyOf(request);
|
|
918
|
+
let group = byKey.get(key);
|
|
919
|
+
// A group that has reached the cap is closed and a fresh one opened under
|
|
920
|
+
// the same key: a burst of twenty becomes three digests, never one wall.
|
|
921
|
+
if (group === undefined || group.length >= max) {
|
|
922
|
+
group = [];
|
|
923
|
+
byKey.set(key, group);
|
|
924
|
+
groups.push(group);
|
|
925
|
+
}
|
|
926
|
+
group.push(request);
|
|
927
|
+
}
|
|
928
|
+
return groups;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The computed facts a digest's members share, as the digest states them.
|
|
932
|
+
*
|
|
933
|
+
* Every one is read off the first member, which is sound precisely because the
|
|
934
|
+
* grouping key made them equal across the set: a digest whose members disagreed
|
|
935
|
+
* about their class or their task is a digest the listener would not have
|
|
936
|
+
* built. The last line is the one an approver needs most — it says how many
|
|
937
|
+
* payloads are above and that each request has its own.
|
|
938
|
+
*/
|
|
939
|
+
export function digestFacts(members) {
|
|
940
|
+
const first = members[0];
|
|
941
|
+
if (first === undefined)
|
|
942
|
+
return [];
|
|
943
|
+
// APRV-287. A digest may now carry the several classes of ONE tool call, so
|
|
944
|
+
// the class line states the set rather than the first member's, and the
|
|
945
|
+
// grouping line says which of the two groupings put this set together. A
|
|
946
|
+
// digest whose members share one class reads exactly as it did.
|
|
947
|
+
const classes = [...new Set(members.map((member) => member.class.value))];
|
|
948
|
+
const shared = sharedPayload(members);
|
|
949
|
+
const autonomies = [...new Set(members.map((member) => member.autonomy.value))];
|
|
950
|
+
return [
|
|
951
|
+
{ label: "class", text: classes.join(", "), origin: originOf(first.class) },
|
|
952
|
+
{ label: "autonomy", text: autonomies.join(", "), origin: originOf(first.autonomy) },
|
|
953
|
+
{ label: "task", text: first.task.value ?? "(none)", origin: originOf(first.task) },
|
|
954
|
+
{
|
|
955
|
+
label: "grouped by",
|
|
956
|
+
text: shared === null
|
|
957
|
+
? `one class, one task, one payload shape (${payloadShapeKey(first.fullPayload.value?.value)})`
|
|
958
|
+
: `one tool call: one task, one payload, ${String(classes.length)} class(es) of the same command`,
|
|
959
|
+
origin: "grouping",
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
label: "payloads",
|
|
963
|
+
text: shared === null
|
|
964
|
+
? `${members.length} full payloads, one per request, in the ${members.length} prompts above this message`
|
|
965
|
+
: `one payload, shared by all ${members.length} requests, in the prompt above this message`,
|
|
966
|
+
origin: originOf(first.fullPayload),
|
|
967
|
+
},
|
|
968
|
+
];
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* The one payload every member is about, or `null` when they differ
|
|
972
|
+
* (APRV-287).
|
|
973
|
+
*
|
|
974
|
+
* The computed hash decides it, never the rendering: two members share a
|
|
975
|
+
* payload when the bytes the grants bind to are the same bytes. A member whose
|
|
976
|
+
* full payload the channel was not given in full is not a shared payload
|
|
977
|
+
* either, because "they are all this one, which you have read" is a claim about
|
|
978
|
+
* something the approver was shown.
|
|
979
|
+
*/
|
|
980
|
+
function sharedPayload(members) {
|
|
981
|
+
const first = members[0];
|
|
982
|
+
if (first === undefined || members.length < 2)
|
|
983
|
+
return null;
|
|
984
|
+
const hash = first.payload_hash.value;
|
|
985
|
+
if (typeof hash !== "string" || hash.length === 0)
|
|
986
|
+
return null;
|
|
987
|
+
if (!members.every((member) => member.payload_hash.value === hash))
|
|
988
|
+
return null;
|
|
989
|
+
const rendering = first.fullPayload.value;
|
|
990
|
+
if (rendering === null || rendering.truncated)
|
|
991
|
+
return null;
|
|
992
|
+
return first;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The collapsed re-delivery's own message: what is waiting, and one way to
|
|
996
|
+
* clear it (APRV-287).
|
|
997
|
+
*
|
|
998
|
+
* Everything above the buttons is computed by the runtime from the verified log
|
|
999
|
+
* — the count, the ages, the classes — and the per-member lines carry the
|
|
1000
|
+
* agent's own summaries under a heading that says so, exactly as an ordinary
|
|
1001
|
+
* digest does. What it does NOT carry is any payload, and the message says so
|
|
1002
|
+
* in the same breath as it explains why the only button rejects: a decision is
|
|
1003
|
+
* bound to the bytes the approver was shown, and nothing here shows them.
|
|
1004
|
+
*/
|
|
1005
|
+
function renderStaleSummary(digest, stale, open, total) {
|
|
1006
|
+
const lines = [
|
|
1007
|
+
`<b>${escapeHtml(open === 0
|
|
1008
|
+
? `ALL ${total} STALE REQUESTS DECIDED`
|
|
1009
|
+
: `${open} STALE REQUEST${open === 1 ? "" : "S"} — NOBODY IS WAITING ON ${open === 1 ? "IT" : "THEM"}`)}</b>`,
|
|
1010
|
+
"",
|
|
1011
|
+
"<b>COMPUTED — derived by the runtime from the log and the policy</b>",
|
|
1012
|
+
...stale.lines.map((line) => `• ${escapeHtml(line)}`),
|
|
1013
|
+
...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
|
|
1014
|
+
"",
|
|
1015
|
+
`<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
|
|
1016
|
+
];
|
|
1017
|
+
for (const [index, member] of digest.members.entries()) {
|
|
1018
|
+
lines.push(`${index + 1}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)}`);
|
|
1019
|
+
if (member.settled !== null) {
|
|
1020
|
+
lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
|
|
1021
|
+
for (const detail of member.settled.detail)
|
|
1022
|
+
lines.push(` ${escapeHtml(detail)}`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
lines.push("", escapeHtml("These asked while a hook waited, and the wait is long over: no tool call is holding the answer. This message carries NO payload, so it offers no approve button — a decision is bound to the bytes you were shown, and nothing here shows them. Rejecting is one log event per request and authorizes nothing. To approve one instead, decide it on its own card or run `approval grant <action key>`; the requests stay listed by /queue either way."));
|
|
1026
|
+
const rows = open === 0
|
|
1027
|
+
? []
|
|
1028
|
+
: [
|
|
1029
|
+
[
|
|
1030
|
+
{
|
|
1031
|
+
text: `🛑 Reject all (${open})`,
|
|
1032
|
+
callback_data: digestCallbackData("R", digest.allNonce),
|
|
1033
|
+
},
|
|
1034
|
+
],
|
|
1035
|
+
];
|
|
1036
|
+
return {
|
|
1037
|
+
text: lines.join("\n"),
|
|
1038
|
+
keyboard: rows.length === 0 ? null : { inline_keyboard: rows },
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/** The digest's headline, given how much of it is still open. */
|
|
1042
|
+
function digestHeadline(open, total) {
|
|
1043
|
+
if (open === 0)
|
|
1044
|
+
return `ALL ${total} REQUESTS DECIDED`;
|
|
1045
|
+
if (open === total)
|
|
1046
|
+
return `${total} REQUESTS AWAITING APPROVAL`;
|
|
1047
|
+
return `${open} OF ${total} REQUESTS STILL AWAITING APPROVAL`;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* The digest message: text plus the keyboard for whatever is still open.
|
|
1051
|
+
*
|
|
1052
|
+
* Pure. The computed/claimed split of an ordinary prompt is kept — the shared
|
|
1053
|
+
* facts are computed and sit under a heading that says so, the per-member lines
|
|
1054
|
+
* are the agent's own words and sit under one that says they are not verified —
|
|
1055
|
+
* because a digest is a prompt, and SPEC.md §9 does not stop applying because
|
|
1056
|
+
* there are five of them.
|
|
1057
|
+
*
|
|
1058
|
+
* A settled member keeps its line, gains its outcome underneath, and loses its
|
|
1059
|
+
* buttons. The "all" row appears only while two or more members are open: with
|
|
1060
|
+
* one left, "all" is the same tap as its own Approve and a second way to do one
|
|
1061
|
+
* thing is a way to do the wrong one.
|
|
1062
|
+
*/
|
|
1063
|
+
export function renderDigest(digest) {
|
|
1064
|
+
const open = digest.members.filter((member) => member.settled === null);
|
|
1065
|
+
const total = digest.members.length;
|
|
1066
|
+
const stale = digest.stale ?? null;
|
|
1067
|
+
if (stale !== null)
|
|
1068
|
+
return renderStaleSummary(digest, stale, open.length, total);
|
|
1069
|
+
const lines = [
|
|
1070
|
+
`<b>${escapeHtml(digestHeadline(open.length, total))}</b>`,
|
|
1071
|
+
"",
|
|
1072
|
+
"<b>COMPUTED — derived by the runtime from the log and the policy</b>",
|
|
1073
|
+
...digest.facts.map((fact) => `• <b>${escapeHtml(fact.label)}:</b> ${escapeHtml(fact.text)} <i>(${escapeHtml(fact.origin)})</i>`),
|
|
1074
|
+
"",
|
|
1075
|
+
`<b>CLAIMED — authored by ${escapeHtml(digest.author)}, NOT verified by the runtime</b>`,
|
|
1076
|
+
];
|
|
1077
|
+
for (const [index, member] of digest.members.entries()) {
|
|
1078
|
+
lines.push(`${index + 1}. <code>${escapeHtml(member.actionKey)}</code> — ${escapeHtml(member.summary)} · ${escapeHtml(member.cost)}`);
|
|
1079
|
+
if (member.settled !== null) {
|
|
1080
|
+
lines.push(` <b>${escapeHtml(member.settled.headline)}</b>`);
|
|
1081
|
+
for (const detail of member.settled.detail)
|
|
1082
|
+
lines.push(` ${escapeHtml(detail)}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
lines.push("", escapeHtml(`Each button decides ONE request, numbered as above. "all" is ${open.length} separate decisions, one log event each; the full payload of every request is in the messages above this one.`));
|
|
1086
|
+
const rows = [];
|
|
1087
|
+
for (const [index, member] of digest.members.entries()) {
|
|
1088
|
+
if (member.settled !== null)
|
|
1089
|
+
continue;
|
|
1090
|
+
rows.push([
|
|
1091
|
+
{
|
|
1092
|
+
text: `✅ Approve ${index + 1}`,
|
|
1093
|
+
callback_data: callbackData("g", member.nonce, member.actionKey),
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
text: `🛑 Reject ${index + 1}`,
|
|
1097
|
+
callback_data: callbackData("r", member.nonce, member.actionKey),
|
|
1098
|
+
},
|
|
1099
|
+
]);
|
|
1100
|
+
}
|
|
1101
|
+
if (open.length > 1) {
|
|
1102
|
+
rows.push([
|
|
1103
|
+
{
|
|
1104
|
+
text: `✅ Approve all (${open.length})`,
|
|
1105
|
+
callback_data: digestCallbackData("G", digest.allNonce),
|
|
1106
|
+
},
|
|
1107
|
+
{
|
|
1108
|
+
text: `🛑 Reject all (${open.length})`,
|
|
1109
|
+
callback_data: digestCallbackData("R", digest.allNonce),
|
|
1110
|
+
},
|
|
1111
|
+
]);
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
text: lines.join("\n"),
|
|
1115
|
+
keyboard: rows.length === 0 ? null : { inline_keyboard: rows },
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// Callback data
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
/**
|
|
1122
|
+
* The stable short reference to an action key that a button carries (APRV-196).
|
|
1123
|
+
*
|
|
1124
|
+
* The first {@link ACTION_REF_HEX} hex characters of the key's sha256. Two
|
|
1125
|
+
* properties earn it its place, and they are the two the old scheme lacked:
|
|
1126
|
+
*
|
|
1127
|
+
* 1. **It always fits.** `<verb>:<nonce>:<ref>` is well inside Telegram's
|
|
1128
|
+
* 64-byte cap for any nonce this class issues, so the cross-check that used
|
|
1129
|
+
* to be dropped for a long action key is now always present.
|
|
1130
|
+
* 2. **It survives a restart.** The nonce is per-process and per-copy; the ref
|
|
1131
|
+
* is a function of the action key alone, so two copies of the same request
|
|
1132
|
+
* delivered by two different listener processes carry the same ref. That is
|
|
1133
|
+
* what lets a tap on a pre-restart copy resolve to the request the current
|
|
1134
|
+
* process is holding, instead of dying as an unknown nonce.
|
|
1135
|
+
*
|
|
1136
|
+
* It is a REFERENCE and never an authorization. The bytes come back from the
|
|
1137
|
+
* network, so a ref is only ever matched against deliveries THIS process made
|
|
1138
|
+
* (and only from the configured chat); it can select among what the listener
|
|
1139
|
+
* has itself put in front of the approver, and it can name nothing else.
|
|
1140
|
+
*/
|
|
1141
|
+
export const ACTION_REF_HEX = 16;
|
|
1142
|
+
export function actionRefOf(actionKey) {
|
|
1143
|
+
return createHash("sha256").update(actionKey, "utf8").digest("hex").slice(0, ACTION_REF_HEX);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* `callback_data` for one button: `<g|r>:<nonce>:<action ref>`.
|
|
1147
|
+
*
|
|
1148
|
+
* The **nonce is authoritative** where it resolves: it is issued by this process
|
|
1149
|
+
* at `notify` and maps to the request that was actually delivered, so an
|
|
1150
|
+
* ordinary tap never consults the ref for anything but a cross-check (a
|
|
1151
|
+
* mismatch is an anomaly and the callback is dropped). The ref is the fallback
|
|
1152
|
+
* for the copy whose nonce this process never issued, and {@link actionRefOf}
|
|
1153
|
+
* states the bound on what that fallback may reach.
|
|
1154
|
+
*/
|
|
1155
|
+
export function callbackData(verb, nonce, actionKey) {
|
|
1156
|
+
const withRef = `${verb}:${nonce}:${actionRefOf(actionKey)}`;
|
|
1157
|
+
// Unreachable with the nonces this class issues, and kept because the failure
|
|
1158
|
+
// it guards is the worst one available here: `callback_data` over the cap is
|
|
1159
|
+
// refused by `sendMessage`, so an over-long nonce would stop DELIVERY rather
|
|
1160
|
+
// than degrade a lookup. Dropping the reference costs a stale copy's tap its
|
|
1161
|
+
// rescue and leaves every other property intact.
|
|
1162
|
+
return Buffer.byteLength(withRef, "utf8") <= TELEGRAM_MAX_CALLBACK_BYTES
|
|
1163
|
+
? withRef
|
|
1164
|
+
: `${verb}:${nonce}`;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* `callback_data` for a digest's "all" button: `<G|R>:<nonce>` (APRV-115).
|
|
1168
|
+
*
|
|
1169
|
+
* Upper case, and no action key: an "all" button names a *delivery*, and the
|
|
1170
|
+
* set it decides is whichever members of that delivery are still open at the
|
|
1171
|
+
* moment of the tap — which the delivering process knows and the network does
|
|
1172
|
+
* not. Naming keys in the bytes would let something that can reach the bot
|
|
1173
|
+
* choose the set, and there is no length at which that becomes acceptable.
|
|
1174
|
+
*/
|
|
1175
|
+
export function digestCallbackData(verb, nonce) {
|
|
1176
|
+
return `${verb}:${nonce}`;
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* `callback_data` for the checkpoint prompt's two buttons (APRV-257).
|
|
1180
|
+
*
|
|
1181
|
+
* `k:<nonce>` signs, `x:<nonce>` declines. Verbs of their own rather than a
|
|
1182
|
+
* reuse of `g`/`r`, and the separation is load-bearing: {@link CALLBACK_VERBS}
|
|
1183
|
+
* maps every decision verb onto a grant or a reject, so a checkpoint button
|
|
1184
|
+
* spelled `g` would be a button {@link parseCallbackData} hands to the decision
|
|
1185
|
+
* path — where an unknown nonce becomes an action-reference lookup, and a
|
|
1186
|
+
* signature gesture starts hunting for a request to approve. Two vocabularies,
|
|
1187
|
+
* two parsers, and neither can be read as the other.
|
|
1188
|
+
*
|
|
1189
|
+
* No action key and no reference in the bytes: a checkpoint names no request,
|
|
1190
|
+
* and the head it covers is held by the process that issued the nonce, exactly
|
|
1191
|
+
* as a digest's member set is. Nothing that can reach the bot chooses what gets
|
|
1192
|
+
* signed.
|
|
1193
|
+
*/
|
|
1194
|
+
export function checkpointCallbackData(verb, nonce) {
|
|
1195
|
+
return `${verb}:${nonce}`;
|
|
1196
|
+
}
|
|
1197
|
+
/** `k:<nonce>` / `x:<nonce>`, or `null` for anything else. Never throws. */
|
|
1198
|
+
export function parseCheckpointCallback(data) {
|
|
1199
|
+
if (typeof data !== "string")
|
|
1200
|
+
return null;
|
|
1201
|
+
const verb = data.slice(0, 1);
|
|
1202
|
+
if ((verb !== "k" && verb !== "x") || data.slice(1, 2) !== ":")
|
|
1203
|
+
return null;
|
|
1204
|
+
const nonce = data.slice(2);
|
|
1205
|
+
if (nonce.length === 0 || nonce.includes(":"))
|
|
1206
|
+
return null;
|
|
1207
|
+
return { sign: verb === "k", nonce };
|
|
1208
|
+
}
|
|
1209
|
+
const CALLBACK_VERBS = {
|
|
1210
|
+
g: { decision: "grant", scope: "one" },
|
|
1211
|
+
r: { decision: "reject", scope: "one" },
|
|
1212
|
+
G: { decision: "grant", scope: "all" },
|
|
1213
|
+
R: { decision: "reject", scope: "all" },
|
|
1214
|
+
};
|
|
1215
|
+
export function parseCallbackData(data) {
|
|
1216
|
+
if (typeof data !== "string")
|
|
1217
|
+
return null;
|
|
1218
|
+
const first = data.indexOf(":");
|
|
1219
|
+
if (first === -1)
|
|
1220
|
+
return null;
|
|
1221
|
+
const verb = CALLBACK_VERBS[data.slice(0, first)];
|
|
1222
|
+
if (verb === undefined)
|
|
1223
|
+
return null;
|
|
1224
|
+
const rest = data.slice(first + 1);
|
|
1225
|
+
const second = rest.indexOf(":");
|
|
1226
|
+
const nonce = second === -1 ? rest : rest.slice(0, second);
|
|
1227
|
+
if (nonce.length === 0)
|
|
1228
|
+
return null;
|
|
1229
|
+
return {
|
|
1230
|
+
decision: verb.decision,
|
|
1231
|
+
scope: verb.scope,
|
|
1232
|
+
nonce,
|
|
1233
|
+
actionRef: second === -1 ? null : rest.slice(second + 1),
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
// ---------------------------------------------------------------------------
|
|
1237
|
+
// Review cards (APRV-299)
|
|
1238
|
+
// ---------------------------------------------------------------------------
|
|
1239
|
+
/**
|
|
1240
|
+
* The headline of a retrospective review card.
|
|
1241
|
+
*
|
|
1242
|
+
* Deliberately not {@link TELEGRAM_PROMPT_HEADING} and deliberately not a
|
|
1243
|
+
* question. A sample is an action that ALREADY RAN: nothing is pending, no
|
|
1244
|
+
* token is minted by any button on this message, and a card that said
|
|
1245
|
+
* "APPROVAL REQUIRED" would be telling the approver they are holding something
|
|
1246
|
+
* up. The supervised bargain (SPEC.md §5.2) is "execute now, a fraction is
|
|
1247
|
+
* reviewed after", and this is the "after".
|
|
1248
|
+
*/
|
|
1249
|
+
export const TELEGRAM_REVIEW_HEADING = "REVIEW — THIS ALREADY RAN";
|
|
1250
|
+
/** The headline a recorded review puts on the card it settles. */
|
|
1251
|
+
export const TELEGRAM_REVIEW_RECORDED = "✓ REVIEWED";
|
|
1252
|
+
/** The headline a recorded DENIAL puts on the card it settles. */
|
|
1253
|
+
export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
|
|
1254
|
+
/**
|
|
1255
|
+
* The headline a card wears while a first Deny tap is armed and nothing has
|
|
1256
|
+
* been recorded.
|
|
1257
|
+
*/
|
|
1258
|
+
export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
|
|
1259
|
+
/**
|
|
1260
|
+
* What a review tap's single answer says (APRV-302).
|
|
1261
|
+
*
|
|
1262
|
+
* {@link TELEGRAM_ACK_HEARD}'s "deciding" is a request card's word: something is
|
|
1263
|
+
* pending, and the tap just settled it. A review decides nothing — the action
|
|
1264
|
+
* ran, and what the tap does is record what a person thought of it — so a
|
|
1265
|
+
* reviewer told they were "deciding" is being told the wrong thing about the
|
|
1266
|
+
* card in front of them. The load-bearing half is carried over unchanged: this
|
|
1267
|
+
* claims only that the tap ARRIVED, never that anything was appended, because at
|
|
1268
|
+
* the moment it is sent nothing has been and `core/audit.ts` may still refuse.
|
|
1269
|
+
* What became of it is on the card edit that follows.
|
|
1270
|
+
*/
|
|
1271
|
+
export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
|
|
1272
|
+
/** The toast a first Deny tap gets: it says plainly that nothing was written. */
|
|
1273
|
+
export const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed — nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
|
|
1274
|
+
/** The toast a reaction that needs the human's own words gets. */
|
|
1275
|
+
export const TELEGRAM_REVIEW_NOTE_TOAST = "Heard — reply to the prompt with why. Nothing is recorded until it arrives.";
|
|
1276
|
+
/**
|
|
1277
|
+
* What the ForceReply prompt asks for.
|
|
1278
|
+
*
|
|
1279
|
+
* A separate message rather than a second keyboard, because Telegram's inline
|
|
1280
|
+
* keyboards have no text input at all — the same limitation the reject path
|
|
1281
|
+
* documents. The prompt is bound to its card by the message id the reply names,
|
|
1282
|
+
* which this process holds and the network does not.
|
|
1283
|
+
*/
|
|
1284
|
+
export function reviewNotePromptLines(reaction, verdict, actionKey) {
|
|
1285
|
+
return [
|
|
1286
|
+
`WHY ${reaction.toUpperCase()}?`,
|
|
1287
|
+
`Reply to this message with the reason. It is recorded verbatim beside a ${verdict} review of ${actionKey}.`,
|
|
1288
|
+
"Nothing has been appended yet, and a blank reply appends nothing: the grade an agent is most likely to act on is the one it can least interpret alone.",
|
|
1289
|
+
];
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The six things a review card's buttons can say (APRV-299).
|
|
1293
|
+
*
|
|
1294
|
+
* `ok` and `deny` are the verdict, which is enforcement; the four reactions are
|
|
1295
|
+
* the grade, which is not (SPEC.md §11.1 invariant 10). Both travel in the same
|
|
1296
|
+
* closed vocabulary because they arrive through the same six buttons, and a
|
|
1297
|
+
* seventh word would be a button nobody drew.
|
|
1298
|
+
*/
|
|
1299
|
+
export const REVIEW_CHOICES = ["ok", "deny", ...REACTIONS];
|
|
1300
|
+
/**
|
|
1301
|
+
* `callback_data` for one review button: `v:<nonce>:<choice>`.
|
|
1302
|
+
*
|
|
1303
|
+
* Its own verb, for exactly the reason the checkpoint prompt's is its own
|
|
1304
|
+
* (APRV-257): {@link CALLBACK_VERBS} maps every DECISION verb onto a grant or a
|
|
1305
|
+
* reject, so a review button spelled `g` would be handed to the decision path,
|
|
1306
|
+
* where an unresolved nonce falls back to an action-reference lookup and a
|
|
1307
|
+
* gesture about something that already happened would start hunting for a
|
|
1308
|
+
* request to approve. Three vocabularies, three parsers, and none can be read
|
|
1309
|
+
* as another.
|
|
1310
|
+
*
|
|
1311
|
+
* No action reference in the bytes, and no sample seq: the card names a
|
|
1312
|
+
* DELIVERY, and which sample that delivery is about is held by the process that
|
|
1313
|
+
* issued the nonce. Nothing that can reach the bot chooses what gets reviewed.
|
|
1314
|
+
* There is also no stale-copy ladder underneath it: a review is never urgent,
|
|
1315
|
+
* a lost card leaves the sample open, and the next cycle offers it again.
|
|
1316
|
+
*/
|
|
1317
|
+
export function reviewCallbackData(choice, nonce) {
|
|
1318
|
+
return `v:${nonce}:${choice}`;
|
|
1319
|
+
}
|
|
1320
|
+
/** `v:<nonce>:<choice>`, or `null` for anything else. Never throws. */
|
|
1321
|
+
export function parseReviewCallback(data) {
|
|
1322
|
+
if (typeof data !== "string")
|
|
1323
|
+
return null;
|
|
1324
|
+
if (data.slice(0, 2) !== "v:")
|
|
1325
|
+
return null;
|
|
1326
|
+
const rest = data.slice(2);
|
|
1327
|
+
const split = rest.indexOf(":");
|
|
1328
|
+
if (split <= 0)
|
|
1329
|
+
return null;
|
|
1330
|
+
const nonce = rest.slice(0, split);
|
|
1331
|
+
const choice = rest.slice(split + 1);
|
|
1332
|
+
const found = REVIEW_CHOICES.find((candidate) => candidate === choice);
|
|
1333
|
+
return found === undefined ? null : { nonce, choice: found };
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* The label each button carries. Emoji live here and never in message text.
|
|
1337
|
+
*
|
|
1338
|
+
* Bare emoji, no words (APRV-302). The first live cards put a word beside every
|
|
1339
|
+
* glyph, which bought nothing: six labelled buttons on a phone wrap, and the
|
|
1340
|
+
* words repeated what the card had already said in full sentences above them.
|
|
1341
|
+
* The layout is what carries the meaning now: row one is the verdict (record it
|
|
1342
|
+
* as fine, or arm the denial), row two is the grade, worst to best, in the same
|
|
1343
|
+
* order `REACTIONS` gives everywhere else.
|
|
1344
|
+
*/
|
|
1345
|
+
const REVIEW_BUTTON_LABELS = {
|
|
1346
|
+
ok: "✅",
|
|
1347
|
+
deny: "🛑",
|
|
1348
|
+
disliked: "👎",
|
|
1349
|
+
indifferent: "😐",
|
|
1350
|
+
liked: "👍",
|
|
1351
|
+
loved: "❤️",
|
|
1352
|
+
};
|
|
1353
|
+
/**
|
|
1354
|
+
* How much of one refusal message a card carries.
|
|
1355
|
+
*
|
|
1356
|
+
* The audit refusals are paragraphs — they explain what the reviewer meant and
|
|
1357
|
+
* how to say it instead — and a card carrying one whole can overrun Telegram's
|
|
1358
|
+
* message limit, at which point the edit fails and the human is told nothing at
|
|
1359
|
+
* all. So the prose is cut and the cut is marked. The CODE is never cut: it is
|
|
1360
|
+
* the machine-readable half, it is short, and it is on its own line above.
|
|
1361
|
+
*/
|
|
1362
|
+
const REVIEW_NOTICE_MAX = 900;
|
|
1363
|
+
function trimNotice(text) {
|
|
1364
|
+
return text.length <= REVIEW_NOTICE_MAX
|
|
1365
|
+
? text
|
|
1366
|
+
: `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* The card's message: the rows, whatever notice the last tap produced, and the
|
|
1370
|
+
* keyboard.
|
|
1371
|
+
*
|
|
1372
|
+
* No paragraph explaining the buttons (APRV-302). The heading
|
|
1373
|
+
* ({@link TELEGRAM_REVIEW_HEADING}) is what says a review is not a request, and
|
|
1374
|
+
* the deny latch says itself: the first tap is answered by
|
|
1375
|
+
* {@link TELEGRAM_REVIEW_ARM_TOAST} and the card's own heading becomes
|
|
1376
|
+
* {@link TELEGRAM_REVIEW_ARMED} until it is spent. Four sentences of rules under
|
|
1377
|
+
* every card said the same thing to a reader who had already read them once, and
|
|
1378
|
+
* pushed the rows a review is actually about off the first screen.
|
|
1379
|
+
*
|
|
1380
|
+
* Pure. Two things it deliberately does NOT carry, and both are the same rule
|
|
1381
|
+
* read twice: no payload region, and no approve button. SPEC.md §10.3 requires
|
|
1382
|
+
* the canonical rendering in front of an approver before a DECISION is
|
|
1383
|
+
* collected, and this collects none — the action ran, the review says only what
|
|
1384
|
+
* a person thought of it, and a card that offered an approve would be
|
|
1385
|
+
* presenting a settled fact as a live authorization. A sample is never
|
|
1386
|
+
* delivered as an approval request and never accepts a token.
|
|
1387
|
+
*/
|
|
1388
|
+
export function renderReviewCard(state) {
|
|
1389
|
+
const card = state.card;
|
|
1390
|
+
const key = card.fields.action_key.value;
|
|
1391
|
+
if (state.settled !== null) {
|
|
1392
|
+
return {
|
|
1393
|
+
text: [
|
|
1394
|
+
`<b>${escapeHtml(state.settled.headline)}</b>`,
|
|
1395
|
+
`<code>${escapeHtml(key)}</code>`,
|
|
1396
|
+
"",
|
|
1397
|
+
...state.settled.detail.map((entry) => escapeHtml(trimNotice(entry))),
|
|
1398
|
+
].join("\n"),
|
|
1399
|
+
keyboard: null,
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
const computedLines = [];
|
|
1403
|
+
const claimedLines = [];
|
|
1404
|
+
for (const row of REVIEW_CARD_ROWS) {
|
|
1405
|
+
const candidate = reviewRow(card.fields, row);
|
|
1406
|
+
if (candidate === null)
|
|
1407
|
+
continue;
|
|
1408
|
+
if (candidate.line.kind === "computed")
|
|
1409
|
+
computedLines.push(candidate.line);
|
|
1410
|
+
else
|
|
1411
|
+
claimedLines.push(candidate.line);
|
|
1412
|
+
}
|
|
1413
|
+
computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
|
|
1414
|
+
computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
|
|
1415
|
+
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
|
|
1416
|
+
const author = originOf(card.fields.summary);
|
|
1417
|
+
const lines = [
|
|
1418
|
+
`<b>${escapeHtml(state.denyArmed ? `${TELEGRAM_REVIEW_HEADING} — ${TELEGRAM_REVIEW_ARMED}` : TELEGRAM_REVIEW_HEADING)}</b>`,
|
|
1419
|
+
`<code>${escapeHtml(key)}</code>`,
|
|
1420
|
+
"",
|
|
1421
|
+
"<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
|
|
1422
|
+
...computedLines.map(render),
|
|
1423
|
+
"",
|
|
1424
|
+
`<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
|
|
1425
|
+
...claimedLines.map(render),
|
|
1426
|
+
];
|
|
1427
|
+
if (state.notice !== null) {
|
|
1428
|
+
lines.push("", `<b>${escapeHtml(state.notice.headline)}</b>`, ...state.notice.lines.map((entry) => escapeHtml(trimNotice(entry))));
|
|
1429
|
+
}
|
|
1430
|
+
const button = (choice) => ({
|
|
1431
|
+
text: REVIEW_BUTTON_LABELS[choice],
|
|
1432
|
+
callback_data: reviewCallbackData(choice, state.nonce),
|
|
1433
|
+
});
|
|
1434
|
+
return {
|
|
1435
|
+
text: lines.join("\n"),
|
|
1436
|
+
keyboard: {
|
|
1437
|
+
inline_keyboard: [
|
|
1438
|
+
[button("ok"), button("deny")],
|
|
1439
|
+
REACTIONS.map((reaction) => button(reaction)),
|
|
1440
|
+
],
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
// ---------------------------------------------------------------------------
|
|
1445
|
+
// Errors and redaction
|
|
1446
|
+
// ---------------------------------------------------------------------------
|
|
1447
|
+
/** A Bot API call that did not produce a usable result. */
|
|
1448
|
+
export class TelegramApiError extends Error {
|
|
1449
|
+
method;
|
|
1450
|
+
status;
|
|
1451
|
+
description;
|
|
1452
|
+
constructor(message, method,
|
|
1453
|
+
/**
|
|
1454
|
+
* The HTTP status, when the failure was an HTTP one. `null` for a transport
|
|
1455
|
+
* failure, an unparseable body, or an `ok: false` envelope that arrived
|
|
1456
|
+
* with a 200 (APRV-277).
|
|
1457
|
+
*/
|
|
1458
|
+
status = null,
|
|
1459
|
+
/**
|
|
1460
|
+
* The Bot API's own `description` for this failure, redacted, when the
|
|
1461
|
+
* error body carried one. `null` when the body was absent, unreadable, not
|
|
1462
|
+
* JSON, or carried no description.
|
|
1463
|
+
*/
|
|
1464
|
+
description = null) {
|
|
1465
|
+
super(message);
|
|
1466
|
+
this.method = method;
|
|
1467
|
+
this.status = status;
|
|
1468
|
+
this.description = description;
|
|
1469
|
+
this.name = "TelegramApiError";
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Telegram's wording for "that edit would have changed nothing" (APRV-277).
|
|
1474
|
+
*
|
|
1475
|
+
* Matched on the description rather than the status alone, because 400 is also
|
|
1476
|
+
* every malformed edit, every wrong chat and every deleted message.
|
|
1477
|
+
*/
|
|
1478
|
+
const TELEGRAM_NOT_MODIFIED = /message is not modified/iu;
|
|
1479
|
+
/**
|
|
1480
|
+
* Whether a failed call is the Bot API saying an edit changed nothing
|
|
1481
|
+
* (APRV-277).
|
|
1482
|
+
*
|
|
1483
|
+
* `editMessageText` answers 400 "Bad Request: message is not modified" when the
|
|
1484
|
+
* text and the keyboard it was handed are already what the message holds. Every
|
|
1485
|
+
* caller here re-annotates from the verified log rather than from memory, so a
|
|
1486
|
+
* message annotated once and derived again produces exactly that: the phone
|
|
1487
|
+
* already shows the outcome, and the operator has nothing to be told. It is the
|
|
1488
|
+
* one 400 that means the intended state stands, which is why it is the only one
|
|
1489
|
+
* that goes unreported.
|
|
1490
|
+
*/
|
|
1491
|
+
export function isMessageNotModified(cause) {
|
|
1492
|
+
return (cause instanceof TelegramApiError &&
|
|
1493
|
+
cause.status === 400 &&
|
|
1494
|
+
cause.description !== null &&
|
|
1495
|
+
TELEGRAM_NOT_MODIFIED.test(cause.description));
|
|
1496
|
+
}
|
|
1497
|
+
export class TelegramChannel {
|
|
1498
|
+
name = "telegram";
|
|
1499
|
+
token;
|
|
1500
|
+
chatId;
|
|
1501
|
+
apiBase;
|
|
1502
|
+
fetchImpl;
|
|
1503
|
+
pollTimeoutSeconds;
|
|
1504
|
+
requestTimeoutMs;
|
|
1505
|
+
backoffMs;
|
|
1506
|
+
maxBackoffMs;
|
|
1507
|
+
complain;
|
|
1508
|
+
makeNonce;
|
|
1509
|
+
/** The policy's approval TTL, or `null` when it declares none (APRV-135). */
|
|
1510
|
+
approvalTtlMs;
|
|
1511
|
+
now;
|
|
1512
|
+
/** The listener's verified-log probe for a stale tap (APRV-196), or null. */
|
|
1513
|
+
describeAction;
|
|
1514
|
+
/** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
|
|
1515
|
+
layout;
|
|
1516
|
+
/** When {@link sweep} last ran, so the poll loop can call it every cycle. */
|
|
1517
|
+
lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
1518
|
+
/**
|
|
1519
|
+
* The callback query being handled, and whether an ack has been attempted for
|
|
1520
|
+
* it (APRV-196). Set and cleared by {@link handleUpdate}, which processes
|
|
1521
|
+
* updates one at a time and awaits each.
|
|
1522
|
+
*/
|
|
1523
|
+
ack = null;
|
|
1524
|
+
handler = null;
|
|
1525
|
+
/**
|
|
1526
|
+
* What to do with a bot command (APRV-216). Absent unless the runtime asked
|
|
1527
|
+
* for commands, and its absence is what keeps `message` out of
|
|
1528
|
+
* `allowed_updates` — see {@link onCommand}.
|
|
1529
|
+
*/
|
|
1530
|
+
commandHandler = null;
|
|
1531
|
+
/**
|
|
1532
|
+
* What to do with a checkpoint tap (APRV-257). Absent unless the runtime
|
|
1533
|
+
* registered one, and its absence makes {@link offerCheckpoint} refuse: a
|
|
1534
|
+
* button nobody is listening for is a button that spins on a phone.
|
|
1535
|
+
*/
|
|
1536
|
+
checkpointHandler = null;
|
|
1537
|
+
/**
|
|
1538
|
+
* Checkpoint nonce -> the head that prompt asked about, and the message it is
|
|
1539
|
+
* on. **In memory only**, like every other map in this class and for the same
|
|
1540
|
+
* reason (SPEC.md §10.3: channels hold no state that is a source of truth).
|
|
1541
|
+
*
|
|
1542
|
+
* The head lives HERE and not in the callback bytes, so what is signed is
|
|
1543
|
+
* what this process put on the screen. Losing the map to a restart costs a
|
|
1544
|
+
* tap its meaning — the button answers `unknown-callback` and the listener
|
|
1545
|
+
* offers again on its next lapse — and can never cost a signature over
|
|
1546
|
+
* something nobody was shown.
|
|
1547
|
+
*/
|
|
1548
|
+
checkpointNonces = new Map();
|
|
1549
|
+
/**
|
|
1550
|
+
* What to do with a review tap (APRV-299). Absent unless the runtime
|
|
1551
|
+
* registered one, and its absence makes {@link offerReview} refuse, for the
|
|
1552
|
+
* reason {@link offerCheckpoint} refuses: a button nobody is listening for is
|
|
1553
|
+
* a button that spins on a phone.
|
|
1554
|
+
*/
|
|
1555
|
+
reviewHandler = null;
|
|
1556
|
+
/**
|
|
1557
|
+
* Review card message id -> what is on it. Delivery bookkeeping, never truth
|
|
1558
|
+
* (SPEC.md §10.3). Losing it to a restart costs the card its buttons; the
|
|
1559
|
+
* sample stays open in the log, `approval audit list` still names it, and the
|
|
1560
|
+
* next cycle offers a fresh card.
|
|
1561
|
+
*/
|
|
1562
|
+
reviewCards = new Map();
|
|
1563
|
+
/** Review nonce -> the card message it was issued for. */
|
|
1564
|
+
reviewNonces = new Map();
|
|
1565
|
+
/** Note-prompt message id -> the card whose reply it is waiting for. */
|
|
1566
|
+
reviewNotePrompts = new Map();
|
|
1567
|
+
deliveries = new Map();
|
|
1568
|
+
/** Digest message id -> what is on it. Delivery bookkeeping, never truth. */
|
|
1569
|
+
digests = new Map();
|
|
1570
|
+
/** "All" nonce -> the digest message it was issued for. */
|
|
1571
|
+
allNonces = new Map();
|
|
1572
|
+
rendered = [];
|
|
1573
|
+
offset = 0;
|
|
1574
|
+
counter = 0;
|
|
1575
|
+
stopped = false;
|
|
1576
|
+
inFlight = null;
|
|
1577
|
+
counters = {
|
|
1578
|
+
notified: 0,
|
|
1579
|
+
updates: 0,
|
|
1580
|
+
decisions: 0,
|
|
1581
|
+
pollErrors: 0,
|
|
1582
|
+
anomalies: {
|
|
1583
|
+
"foreign-chat": 0,
|
|
1584
|
+
"malformed-callback": 0,
|
|
1585
|
+
"unknown-callback": 0,
|
|
1586
|
+
"key-mismatch": 0,
|
|
1587
|
+
"stale-copy": 0,
|
|
1588
|
+
"unknown-command": 0,
|
|
1589
|
+
},
|
|
1590
|
+
staleCopyDecisions: 0,
|
|
1591
|
+
commands: 0,
|
|
1592
|
+
reviews: 0,
|
|
1593
|
+
};
|
|
1594
|
+
constructor(config) {
|
|
1595
|
+
this.token = config.token;
|
|
1596
|
+
this.chatId = String(config.chatId);
|
|
1597
|
+
this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
|
|
1598
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
1599
|
+
this.pollTimeoutSeconds = config.pollTimeoutSeconds ?? DEFAULT_POLL_TIMEOUT_SECONDS;
|
|
1600
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? null;
|
|
1601
|
+
this.backoffMs = config.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
1602
|
+
this.maxBackoffMs = config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
1603
|
+
this.complain =
|
|
1604
|
+
config.log ??
|
|
1605
|
+
((message) => {
|
|
1606
|
+
process.stderr.write(`${message}\n`);
|
|
1607
|
+
});
|
|
1608
|
+
this.approvalTtlMs = config.approvalTtlMs ?? null;
|
|
1609
|
+
this.now = config.now ?? (() => Date.now());
|
|
1610
|
+
this.describeAction = config.describeAction ?? null;
|
|
1611
|
+
this.layout = config.layout ?? TELEGRAM_PROMPT_LAYOUT;
|
|
1612
|
+
this.makeNonce =
|
|
1613
|
+
config.nonce ??
|
|
1614
|
+
(() => {
|
|
1615
|
+
this.counter += 1;
|
|
1616
|
+
return `${this.counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
// -------------------------------------------------------------------------
|
|
1620
|
+
// Channel
|
|
1621
|
+
// -------------------------------------------------------------------------
|
|
1622
|
+
onDecision(handler) {
|
|
1623
|
+
this.handler = handler;
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Register what to do with a checkpoint tap (APRV-257).
|
|
1627
|
+
*
|
|
1628
|
+
* The handler is the runtime's, on the runtime's side of the boundary, and it
|
|
1629
|
+
* is where the vault passphrase and the signing live. This channel holds a
|
|
1630
|
+
* nonce, a message id and a `(seq, hash)`, and hands the head back when the
|
|
1631
|
+
* button is pressed — the same shape as {@link onDecision}, for the same
|
|
1632
|
+
* reason: a channel that signed anything would be a channel with authority.
|
|
1633
|
+
*/
|
|
1634
|
+
onCheckpoint(handler) {
|
|
1635
|
+
this.checkpointHandler = handler;
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Put one `CHECKPOINT DUE` prompt in the chat, with a Sign and a Not now
|
|
1639
|
+
* button (APRV-257).
|
|
1640
|
+
*
|
|
1641
|
+
* A unit like any other: the paced walkthrough sends it as one thing to read,
|
|
1642
|
+
* and it is never grouped into a digest, because a digest is a set of
|
|
1643
|
+
* SIMILAR REQUESTS decided together and a checkpoint is neither a request nor
|
|
1644
|
+
* similar to one.
|
|
1645
|
+
*
|
|
1646
|
+
* Refuses when no handler is registered, rather than sending a dead button.
|
|
1647
|
+
*/
|
|
1648
|
+
async offerCheckpoint(prompt) {
|
|
1649
|
+
if (this.checkpointHandler === null) {
|
|
1650
|
+
throw new Error("no checkpoint handler is registered on the telegram channel; the runtime registers one before offerCheckpoint(), and a channel that signed its own checkpoint would be deciding rather than transporting (SPEC.md §10.3)");
|
|
1651
|
+
}
|
|
1652
|
+
const nonce = this.makeNonce();
|
|
1653
|
+
const result = await this.call("sendMessage", {
|
|
1654
|
+
chat_id: this.chatId,
|
|
1655
|
+
text: prompt.lines
|
|
1656
|
+
.map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
|
|
1657
|
+
.join("\n"),
|
|
1658
|
+
parse_mode: "HTML",
|
|
1659
|
+
disable_web_page_preview: true,
|
|
1660
|
+
reply_markup: {
|
|
1661
|
+
inline_keyboard: [
|
|
1662
|
+
[
|
|
1663
|
+
{ text: "Sign", callback_data: checkpointCallbackData("k", nonce) },
|
|
1664
|
+
{ text: "Not now", callback_data: checkpointCallbackData("x", nonce) },
|
|
1665
|
+
],
|
|
1666
|
+
],
|
|
1667
|
+
},
|
|
1668
|
+
});
|
|
1669
|
+
const deliveryId = String(result.message_id);
|
|
1670
|
+
this.checkpointNonces.set(nonce, { deliveryId, head: prompt.head });
|
|
1671
|
+
return deliveryId;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Register what to do with a review tap (APRV-299).
|
|
1675
|
+
*
|
|
1676
|
+
* The handler is the runtime's, on the runtime's side of the boundary, and it
|
|
1677
|
+
* is where the human-only `reviewSample` lives — same shape as
|
|
1678
|
+
* {@link onDecision} and {@link onCheckpoint}, for the same reason: a channel
|
|
1679
|
+
* that appended an `audit.reviewed` of its own would be a supervision backlog
|
|
1680
|
+
* emptying itself through its own transport.
|
|
1681
|
+
*
|
|
1682
|
+
* Registering it, like registering a command handler, is what makes this
|
|
1683
|
+
* channel read `message` updates at all: the note a `loved` or `disliked`
|
|
1684
|
+
* asks for arrives as a reply, and an inline keyboard has no text input.
|
|
1685
|
+
*/
|
|
1686
|
+
onReview(handler) {
|
|
1687
|
+
this.reviewHandler = handler;
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Put one retrospective review card in the chat (APRV-299).
|
|
1691
|
+
*
|
|
1692
|
+
* A unit like a checkpoint prompt: one thing to read, never grouped into a
|
|
1693
|
+
* digest, and never delivered through {@link notify} — a digest is a set of
|
|
1694
|
+
* similar pending REQUESTS decided together, and a sample is neither pending
|
|
1695
|
+
* nor a request. It sends ONE message: no payload region, and a keyboard
|
|
1696
|
+
* whose six buttons collect a verdict and a grade and mint nothing.
|
|
1697
|
+
*
|
|
1698
|
+
* Refuses when no handler is registered, rather than sending a dead button.
|
|
1699
|
+
*/
|
|
1700
|
+
async offerReview(card) {
|
|
1701
|
+
if (this.reviewHandler === null) {
|
|
1702
|
+
throw new Error("no review handler is registered on the telegram channel; the runtime registers one before offerReview(), and a channel that recorded its own audit.reviewed would be the party under oversight closing its own audit item (SPEC.md §5.2, §10.3)");
|
|
1703
|
+
}
|
|
1704
|
+
const nonce = this.makeNonce();
|
|
1705
|
+
const state = {
|
|
1706
|
+
// Assigned once the message exists; nothing consults it before then.
|
|
1707
|
+
deliveryId: "",
|
|
1708
|
+
card,
|
|
1709
|
+
nonce,
|
|
1710
|
+
denyArmed: false,
|
|
1711
|
+
settled: null,
|
|
1712
|
+
notice: null,
|
|
1713
|
+
awaitingNote: null,
|
|
1714
|
+
deliveredAtMs: this.now(),
|
|
1715
|
+
};
|
|
1716
|
+
const drawn = renderReviewCard(state);
|
|
1717
|
+
const result = await this.call("sendMessage", {
|
|
1718
|
+
chat_id: this.chatId,
|
|
1719
|
+
text: drawn.text,
|
|
1720
|
+
parse_mode: "HTML",
|
|
1721
|
+
disable_web_page_preview: true,
|
|
1722
|
+
...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
|
|
1723
|
+
});
|
|
1724
|
+
const deliveryId = String(result.message_id);
|
|
1725
|
+
state.deliveryId = deliveryId;
|
|
1726
|
+
// Armed only now: until the message with the buttons on it exists there is
|
|
1727
|
+
// nothing a callback could legitimately answer.
|
|
1728
|
+
this.reviewCards.set(deliveryId, state);
|
|
1729
|
+
this.reviewNonces.set(nonce, deliveryId);
|
|
1730
|
+
return deliveryId;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Register what to do with `/queue`, `/skip` and `/next` (APRV-216).
|
|
1734
|
+
*
|
|
1735
|
+
* **Registering is what makes this channel read messages at all.** Until a
|
|
1736
|
+
* handler is here, `getUpdates` asks for `callback_query` only, exactly as it
|
|
1737
|
+
* did before this task, so a listener in `burst` delivery consumes no message
|
|
1738
|
+
* updates — which matters because `approval setup channel telegram`
|
|
1739
|
+
* discovers the approver chat by reading one (APRV-74), and a listener that
|
|
1740
|
+
* swallowed it would break the bootstrap of the very channel it runs on.
|
|
1741
|
+
*
|
|
1742
|
+
* The handler owns whatever answer the human gets. This class sends nothing
|
|
1743
|
+
* of its own for a command: it holds no queue to summarise (SPEC.md §10.3),
|
|
1744
|
+
* so the sentence a command produces is written where the pending set is
|
|
1745
|
+
* re-derived, in `cli/channel-telegram.ts`.
|
|
1746
|
+
*/
|
|
1747
|
+
onCommand(handler) {
|
|
1748
|
+
this.commandHandler = handler;
|
|
1749
|
+
}
|
|
1750
|
+
health() {
|
|
1751
|
+
const missing = [];
|
|
1752
|
+
if (this.token.length === 0)
|
|
1753
|
+
missing.push(TELEGRAM_TOKEN_ENV);
|
|
1754
|
+
if (this.chatId.length === 0)
|
|
1755
|
+
missing.push(TELEGRAM_CHAT_ENV);
|
|
1756
|
+
if (missing.length > 0) {
|
|
1757
|
+
return { ok: false, detail: `unconfigured: ${missing.join(", ")} is empty` };
|
|
1758
|
+
}
|
|
1759
|
+
const anomalies = Object.values(this.counters.anomalies).reduce((sum, n) => sum + n, 0);
|
|
1760
|
+
const detail = `chat ${this.chatId} via ${this.apiBase}; ${this.counters.notified} notified, ` +
|
|
1761
|
+
`${this.counters.decisions} decision(s), ${this.counters.pollErrors} recovered poll error(s), ` +
|
|
1762
|
+
`${anomalies} ignored callback(s)`;
|
|
1763
|
+
return { ok: true, detail };
|
|
1764
|
+
}
|
|
1765
|
+
/** The rendering split of the most recent `notify`, for the conformance suite. */
|
|
1766
|
+
lastRendered() {
|
|
1767
|
+
return this.rendered;
|
|
1768
|
+
}
|
|
1769
|
+
/** Delivery, decision and anomaly counters. Live; read from anywhere. */
|
|
1770
|
+
stats() {
|
|
1771
|
+
return { ...this.counters, anomalies: { ...this.counters.anomalies } };
|
|
1772
|
+
}
|
|
1773
|
+
/** Ignored callbacks so far. Exposed for `health()` and for operators. */
|
|
1774
|
+
anomalyCount(kind) {
|
|
1775
|
+
if (kind !== undefined)
|
|
1776
|
+
return this.counters.anomalies[kind];
|
|
1777
|
+
return Object.values(this.counters.anomalies).reduce((sum, n) => sum + n, 0);
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Put a request, or a set of them, in front of the approver.
|
|
1781
|
+
*
|
|
1782
|
+
* One request is one prompt: its header, its payload chunks, and the
|
|
1783
|
+
* Approve/Reject keyboard on the last message, whose `message_id` is the
|
|
1784
|
+
* delivery id. A {@link ChannelBatch} goes through {@link notifyBatch} and
|
|
1785
|
+
* comes back as a digest when it can be one; either way it gets one shared
|
|
1786
|
+
* batch delivery id, which is what this returns and what every resulting
|
|
1787
|
+
* event will carry.
|
|
1788
|
+
*/
|
|
1789
|
+
async notify(target) {
|
|
1790
|
+
if ("requests" in target)
|
|
1791
|
+
return (await this.notifyBatch(target)).batchDeliveryId;
|
|
1792
|
+
const delivered = await this.deliverOne(target, undefined);
|
|
1793
|
+
this.rendered = [delivered.rendered];
|
|
1794
|
+
return delivered.deliveryId;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Deliver a set as one digest, or as one message per member when it cannot
|
|
1798
|
+
* be one (APRV-115).
|
|
1799
|
+
*
|
|
1800
|
+
* The fallback is taken for a set of fewer than two, and for one whose digest
|
|
1801
|
+
* text would not fit inside {@link TELEGRAM_MAX_MESSAGE_CHARS}. Both are the
|
|
1802
|
+
* same rule: the approver sees every member before any button that decides
|
|
1803
|
+
* more than one appears, and when that cannot be arranged the channel sends
|
|
1804
|
+
* MORE messages rather than fewer.
|
|
1805
|
+
*
|
|
1806
|
+
* Not atomic, and it cannot be: a `sendMessage` that fails part way leaves
|
|
1807
|
+
* the messages already sent in the chat, and this throws. Nothing is armed —
|
|
1808
|
+
* the member nonces are registered only once the digest message carrying
|
|
1809
|
+
* their buttons exists — so the caller's retry re-sends the set and the
|
|
1810
|
+
* approver gets a duplicate prompt, never a live button on a half-sent one.
|
|
1811
|
+
*/
|
|
1812
|
+
async notifyBatch(batch) {
|
|
1813
|
+
const members = batch.requests;
|
|
1814
|
+
const batchDeliveryId = batch.deliveryId ?? `tg-batch-${this.makeNonce()}`;
|
|
1815
|
+
const digest = members.length < 2 ? null : await this.deliverDigest(members, batchDeliveryId);
|
|
1816
|
+
if (digest !== null) {
|
|
1817
|
+
this.rendered = digest.rendered;
|
|
1818
|
+
return digest;
|
|
1819
|
+
}
|
|
1820
|
+
const rendered = [];
|
|
1821
|
+
const delivered = [];
|
|
1822
|
+
for (const member of members) {
|
|
1823
|
+
const one = await this.deliverOne(member, batchDeliveryId);
|
|
1824
|
+
rendered.push(one.rendered);
|
|
1825
|
+
delivered.push({ action_key: member.action_key.value, delivery_id: one.deliveryId });
|
|
1826
|
+
}
|
|
1827
|
+
this.rendered = rendered;
|
|
1828
|
+
return { batchDeliveryId, digestId: null, members: delivered, rendered };
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Deliver a set of stale pending requests as ONE message with a reject-all
|
|
1832
|
+
* button (APRV-287).
|
|
1833
|
+
*
|
|
1834
|
+
* Returns `null` when the message would not fit, and the caller then leaves
|
|
1835
|
+
* the members undelivered so the next cycle shows them the ordinary way:
|
|
1836
|
+
* SPEC.md §10.3's rule for this bookkeeping is that losing it degrades to
|
|
1837
|
+
* showing a request again, never to a pending request nobody is shown.
|
|
1838
|
+
*/
|
|
1839
|
+
async notifyStale(members, stale) {
|
|
1840
|
+
if (members.length === 0)
|
|
1841
|
+
return null;
|
|
1842
|
+
return this.deliverDigest(members, `tg-batch-${this.makeNonce()}`, stale);
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* The digest itself: every member's prompt and payload, then the one message
|
|
1846
|
+
* that carries the buttons.
|
|
1847
|
+
*
|
|
1848
|
+
* Returns `null` when the digest message would not fit, so the caller falls
|
|
1849
|
+
* back — and it decides that BEFORE sending anything, because a fallback
|
|
1850
|
+
* discovered after four member prompts had gone out would double them.
|
|
1851
|
+
*
|
|
1852
|
+
* `stale` (APRV-287) makes it the collapsed re-delivery instead: no member
|
|
1853
|
+
* prompts, no payload, one reject-all button. See {@link StaleSummary}.
|
|
1854
|
+
*/
|
|
1855
|
+
async deliverDigest(members, batchDeliveryId, stale = null) {
|
|
1856
|
+
const allNonce = this.makeNonce();
|
|
1857
|
+
const deliveredAtMs = this.now();
|
|
1858
|
+
const state = {
|
|
1859
|
+
deliveredAtMs,
|
|
1860
|
+
// Assigned once the message exists; nothing consults it before then.
|
|
1861
|
+
deliveryId: "",
|
|
1862
|
+
batchDeliveryId,
|
|
1863
|
+
allNonce,
|
|
1864
|
+
stale,
|
|
1865
|
+
facts: stale === null ? digestFacts(members) : [],
|
|
1866
|
+
author: originOf(members[0].summary),
|
|
1867
|
+
members: members.map((member) => ({
|
|
1868
|
+
actionKey: member.action_key.value,
|
|
1869
|
+
nonce: this.makeNonce(),
|
|
1870
|
+
summary: member.summary.value ?? "(none given)",
|
|
1871
|
+
cost: `$${member.est_cost_usd.value.toFixed(2)}`,
|
|
1872
|
+
settled: null,
|
|
1873
|
+
})),
|
|
1874
|
+
};
|
|
1875
|
+
const drawn = renderDigest(state);
|
|
1876
|
+
if (drawn.text.length > TELEGRAM_MAX_MESSAGE_CHARS)
|
|
1877
|
+
return null;
|
|
1878
|
+
// APRV-287. When every member binds to the SAME payload — the several
|
|
1879
|
+
// classes of one tool call — the payload is sent once instead of once per
|
|
1880
|
+
// member. The approver still reads every byte they are deciding about
|
|
1881
|
+
// before any button appears (SPEC.md §10.3), because there is one set of
|
|
1882
|
+
// bytes and it is above the digest; what goes away is five copies of one
|
|
1883
|
+
// command, which was five messages of the flood this task is about.
|
|
1884
|
+
// A collapsed re-delivery sends no payload at all, which is the whole of
|
|
1885
|
+
// what makes it one message; its own text says so and offers no approve, so
|
|
1886
|
+
// it renders no request and claims none.
|
|
1887
|
+
const shared = sharedPayload(members);
|
|
1888
|
+
const rendered = [];
|
|
1889
|
+
if (stale !== null) {
|
|
1890
|
+
// Nothing to render: no prompt goes out for a member here.
|
|
1891
|
+
}
|
|
1892
|
+
else if (shared === null) {
|
|
1893
|
+
for (const [index, member] of members.entries()) {
|
|
1894
|
+
const one = await this.sendPrompt(member, `REQUEST ${index + 1} OF ${members.length} — decide it on the digest below`, null);
|
|
1895
|
+
rendered.push({ ...one.rendered, batchDeliveryId });
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
else {
|
|
1899
|
+
const one = await this.sendPrompt(shared, `THE COMMAND ALL ${members.length} REQUESTS ARE ABOUT — decide it on the digest below`, null);
|
|
1900
|
+
for (const member of members) {
|
|
1901
|
+
rendered.push({
|
|
1902
|
+
...one.rendered,
|
|
1903
|
+
action_key: member.action_key.value,
|
|
1904
|
+
batchDeliveryId,
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
const result = await this.call("sendMessage", {
|
|
1909
|
+
chat_id: this.chatId,
|
|
1910
|
+
text: drawn.text,
|
|
1911
|
+
parse_mode: "HTML",
|
|
1912
|
+
disable_web_page_preview: true,
|
|
1913
|
+
...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
|
|
1914
|
+
});
|
|
1915
|
+
const deliveryId = String(result.message_id);
|
|
1916
|
+
state.deliveryId = deliveryId;
|
|
1917
|
+
// Armed only now, and all at once: until the message with the buttons on it
|
|
1918
|
+
// exists there is nothing a callback could legitimately answer.
|
|
1919
|
+
for (const member of state.members) {
|
|
1920
|
+
this.deliveries.set(member.nonce, {
|
|
1921
|
+
actionKey: member.actionKey,
|
|
1922
|
+
actionRef: actionRefOf(member.actionKey),
|
|
1923
|
+
deliveryId,
|
|
1924
|
+
batchDeliveryId,
|
|
1925
|
+
deliveredAtMs,
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
this.digests.set(deliveryId, state);
|
|
1929
|
+
this.allNonces.set(allNonce, deliveryId);
|
|
1930
|
+
this.counters.notified += members.length;
|
|
1931
|
+
return {
|
|
1932
|
+
batchDeliveryId,
|
|
1933
|
+
digestId: deliveryId,
|
|
1934
|
+
members: state.members.map((member) => ({
|
|
1935
|
+
action_key: member.actionKey,
|
|
1936
|
+
delivery_id: deliveryId,
|
|
1937
|
+
})),
|
|
1938
|
+
rendered,
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Send one request's messages: the computed header, the payload chunks, then
|
|
1943
|
+
* the claimed block, with `keyboard` (when there is one) on the last.
|
|
1944
|
+
*
|
|
1945
|
+
* The claimed block goes last because it is the human-meaningful description
|
|
1946
|
+
* of the act, and the message a reader answers on should be the one that says
|
|
1947
|
+
* what they are answering about; bookkeeping above it is context, not the
|
|
1948
|
+
* question. SPEC §10.3 allows claimed material to sit around the canonical
|
|
1949
|
+
* block while it stays visibly separated and labelled, which the heading on
|
|
1950
|
+
* every claimed message keeps. It is always sent, so a missing summary is a
|
|
1951
|
+
* visible "(none given)" rather than an absent message, and so the keyboard
|
|
1952
|
+
* has one message it can always ride on.
|
|
1953
|
+
*
|
|
1954
|
+
* Shared by the ordinary prompt and by a digest member, which differ in
|
|
1955
|
+
* exactly two things: the heading, and whether anything is armed.
|
|
1956
|
+
*/
|
|
1957
|
+
async sendPrompt(request, heading, keyboard) {
|
|
1958
|
+
const rendering = renderTelegram(request, heading, this.layout);
|
|
1959
|
+
const segments = [rendering.header];
|
|
1960
|
+
if (rendering.payloadText !== null) {
|
|
1961
|
+
const chunks = chunkForTelegram(rendering.payloadText);
|
|
1962
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
1963
|
+
const label = chunks.length === 1
|
|
1964
|
+
? `<b>${PAYLOAD_CHUNK_LABEL}</b>`
|
|
1965
|
+
: `<b>PAYLOAD ${index + 1}/${chunks.length} — ${PAYLOAD_CHUNK_LABEL_TAIL}</b>`;
|
|
1966
|
+
segments.push(`${label}\n<pre>${escapeHtml(chunk)}</pre>`);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
for (const [index, chunk] of chunkClaimedForTelegram(rendering.claimedText).entries()) {
|
|
1970
|
+
segments.push(index === 0 ? chunk : `<b>${TELEGRAM_CLAIMED_CONTINUED_HEADING}</b>\n${chunk}`);
|
|
1971
|
+
}
|
|
1972
|
+
let deliveryId = "";
|
|
1973
|
+
for (const [index, segment] of segments.entries()) {
|
|
1974
|
+
const last = index === segments.length - 1;
|
|
1975
|
+
const result = await this.call("sendMessage", {
|
|
1976
|
+
chat_id: this.chatId,
|
|
1977
|
+
text: segment,
|
|
1978
|
+
parse_mode: "HTML",
|
|
1979
|
+
disable_web_page_preview: true,
|
|
1980
|
+
...(last && keyboard !== null ? { reply_markup: keyboard } : {}),
|
|
1981
|
+
});
|
|
1982
|
+
if (last)
|
|
1983
|
+
deliveryId = String(result.message_id);
|
|
1984
|
+
}
|
|
1985
|
+
const fields = rendering.lines.map((entry) => ({
|
|
1986
|
+
field: entry.field,
|
|
1987
|
+
kind: entry.kind,
|
|
1988
|
+
text: entry.text,
|
|
1989
|
+
}));
|
|
1990
|
+
return {
|
|
1991
|
+
deliveryId,
|
|
1992
|
+
rendered: {
|
|
1993
|
+
action_key: request.action_key.value,
|
|
1994
|
+
fields,
|
|
1995
|
+
fullPayloadText: rendering.payloadText,
|
|
1996
|
+
},
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
async deliverOne(request, batchDeliveryId) {
|
|
2000
|
+
const actionKey = request.action_key.value;
|
|
2001
|
+
const nonce = this.makeNonce();
|
|
2002
|
+
const keyboard = {
|
|
2003
|
+
inline_keyboard: [
|
|
2004
|
+
[
|
|
2005
|
+
{ text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
|
|
2006
|
+
{ text: "🛑 Reject", callback_data: callbackData("r", nonce, actionKey) },
|
|
2007
|
+
],
|
|
2008
|
+
],
|
|
2009
|
+
};
|
|
2010
|
+
const sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
|
|
2011
|
+
this.counters.notified += 1;
|
|
2012
|
+
this.deliveries.set(nonce, {
|
|
2013
|
+
actionKey,
|
|
2014
|
+
actionRef: actionRefOf(actionKey),
|
|
2015
|
+
deliveryId: sent.deliveryId,
|
|
2016
|
+
deliveredAtMs: this.now(),
|
|
2017
|
+
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2018
|
+
});
|
|
2019
|
+
return {
|
|
2020
|
+
deliveryId: sent.deliveryId,
|
|
2021
|
+
rendered: {
|
|
2022
|
+
...sent.rendered,
|
|
2023
|
+
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2024
|
+
},
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* Forget every nonce issued for `deliveryId`, and report the action key it
|
|
2029
|
+
* was issued for.
|
|
2030
|
+
*
|
|
2031
|
+
* Called by {@link annotate} before the edit goes out, so a tap on a button
|
|
2032
|
+
* the edit does not manage to remove resolves to nothing and is answered as
|
|
2033
|
+
* a `stale-copy` rather than carried to the gate as a decision attempt.
|
|
2034
|
+
* Forgetting is never the channel growing state, and forgetting a SETTLED
|
|
2035
|
+
* request is what stops APRV-196's action-reference fallback from finding it:
|
|
2036
|
+
* the ladder rescues a tap on an old copy of a request still open here, and
|
|
2037
|
+
* a decided one is not that.
|
|
2038
|
+
*/
|
|
2039
|
+
disarm(deliveryId) {
|
|
2040
|
+
let actionKey = "";
|
|
2041
|
+
// Deleting the current entry mid-iteration is defined behaviour for a Map,
|
|
2042
|
+
// and every nonce for this message id goes — a re-notify of the same
|
|
2043
|
+
// message would otherwise leave an older nonce still resolving.
|
|
2044
|
+
for (const [nonce, delivery] of this.deliveries) {
|
|
2045
|
+
if (delivery.deliveryId !== deliveryId)
|
|
2046
|
+
continue;
|
|
2047
|
+
actionKey = delivery.actionKey;
|
|
2048
|
+
this.deliveries.delete(nonce);
|
|
2049
|
+
}
|
|
2050
|
+
const digest = this.digests.get(deliveryId);
|
|
2051
|
+
if (digest !== undefined) {
|
|
2052
|
+
this.allNonces.delete(digest.allNonce);
|
|
2053
|
+
this.digests.delete(deliveryId);
|
|
2054
|
+
}
|
|
2055
|
+
return actionKey;
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Drop the delivery bookkeeping no callback can still be honoured against
|
|
2059
|
+
* (APRV-135).
|
|
2060
|
+
*
|
|
2061
|
+
* The condition is both halves of the sentence, evaluated per entry:
|
|
2062
|
+
*
|
|
2063
|
+
* 1. **Every member is terminal.** For a digest that means every member
|
|
2064
|
+
* carries a `settled` outcome; for a unit delivery it is automatic in the
|
|
2065
|
+
* other direction, since annotating a decided, expired or withdrawn
|
|
2066
|
+
* request already forgets its nonces ({@link disarm}), so a delivery still
|
|
2067
|
+
* in the map is one this process has not seen settled. A request past its
|
|
2068
|
+
* approval TTL is terminal too — the gate refuses every decision on it —
|
|
2069
|
+
* which is what lets an unannotated delivery be swept at all.
|
|
2070
|
+
* 2. **Older than the retention window**, which is the policy's approval TTL
|
|
2071
|
+
* when it declares one and {@link TELEGRAM_DEFAULT_RETENTION_MS} when it
|
|
2072
|
+
* does not. Measured from the moment THIS process delivered the message,
|
|
2073
|
+
* which is at or after the `approval.requested` the TTL actually runs
|
|
2074
|
+
* from, so the window this sweep waits out is never shorter than the one
|
|
2075
|
+
* the gate enforces.
|
|
2076
|
+
*
|
|
2077
|
+
* Both together are what makes forgetting safe: a live button can never
|
|
2078
|
+
* reference a dropped entry, because the state in which no callback can still
|
|
2079
|
+
* be honoured is exactly the state in which the entry is dropped. A tap that
|
|
2080
|
+
* arrives anyway is answered by the stale-callback path a restarted
|
|
2081
|
+
* listener's buttons already take: `stale-copy` since APRV-196, counted,
|
|
2082
|
+
* toasted with what the log says became of the request, never carried to the
|
|
2083
|
+
* gate.
|
|
2084
|
+
*
|
|
2085
|
+
* Process memory only. No event, no message edit, no log read. `nowMs`
|
|
2086
|
+
* defaults to the configured clock and is a parameter so a test can run a
|
|
2087
|
+
* simulated week without one.
|
|
2088
|
+
*/
|
|
2089
|
+
sweep(nowMs = this.now()) {
|
|
2090
|
+
this.lastSweepMs = nowMs;
|
|
2091
|
+
const retention = this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
|
|
2092
|
+
const expired = (deliveredAtMs) => nowMs - deliveredAtMs >= retention;
|
|
2093
|
+
// Past the approval TTL the gate refuses every decision, so the request is
|
|
2094
|
+
// terminal whether or not this process saw it settle. With no TTL declared
|
|
2095
|
+
// nothing expires, and only an observed settlement makes an entry droppable.
|
|
2096
|
+
const lapsed = (deliveredAtMs) => this.approvalTtlMs !== null && nowMs - deliveredAtMs >= this.approvalTtlMs;
|
|
2097
|
+
let digests = 0;
|
|
2098
|
+
for (const [deliveryId, digest] of this.digests) {
|
|
2099
|
+
const terminal = digest.members.every((member) => member.settled !== null);
|
|
2100
|
+
if (!(terminal || lapsed(digest.deliveredAtMs)) || !expired(digest.deliveredAtMs))
|
|
2101
|
+
continue;
|
|
2102
|
+
this.digests.delete(deliveryId);
|
|
2103
|
+
this.allNonces.delete(digest.allNonce);
|
|
2104
|
+
digests += 1;
|
|
2105
|
+
}
|
|
2106
|
+
let deliveries = 0;
|
|
2107
|
+
for (const [nonce, delivery] of this.deliveries) {
|
|
2108
|
+
// A nonce whose digest is still remembered is still armed on a message
|
|
2109
|
+
// with buttons, whatever its own age says; the digest is the entry that
|
|
2110
|
+
// decides, and it was just judged above.
|
|
2111
|
+
if (this.digests.has(delivery.deliveryId))
|
|
2112
|
+
continue;
|
|
2113
|
+
if (!lapsed(delivery.deliveredAtMs) || !expired(delivery.deliveredAtMs))
|
|
2114
|
+
continue;
|
|
2115
|
+
this.deliveries.delete(nonce);
|
|
2116
|
+
deliveries += 1;
|
|
2117
|
+
}
|
|
2118
|
+
// APRV-299. A review card is droppable on the same pair of conditions read
|
|
2119
|
+
// for a thing that has no TTL: the runtime recorded an answer for it (so no
|
|
2120
|
+
// button on it can still be honoured — the nonce is already gone) AND it is
|
|
2121
|
+
// older than the retention window. An UNSETTLED card is never swept, and
|
|
2122
|
+
// that is the safe direction: its sample stays open in the log whatever
|
|
2123
|
+
// this map holds, so keeping the buttons alive costs a map entry and
|
|
2124
|
+
// dropping them early would cost a human their thumb.
|
|
2125
|
+
for (const [deliveryId, card] of this.reviewCards) {
|
|
2126
|
+
if (card.settled === null || !expired(card.deliveredAtMs))
|
|
2127
|
+
continue;
|
|
2128
|
+
this.reviewCards.delete(deliveryId);
|
|
2129
|
+
this.reviewNonces.delete(card.nonce);
|
|
2130
|
+
if (card.awaitingNote !== null)
|
|
2131
|
+
this.reviewNotePrompts.delete(card.awaitingNote.promptId);
|
|
2132
|
+
}
|
|
2133
|
+
return { deliveries, digests };
|
|
2134
|
+
}
|
|
2135
|
+
/** How many entries the bookkeeping holds. For tests and for operators. */
|
|
2136
|
+
bookkeepingSize() {
|
|
2137
|
+
return {
|
|
2138
|
+
deliveries: this.deliveries.size,
|
|
2139
|
+
digests: this.digests.size,
|
|
2140
|
+
allNonces: this.allNonces.size,
|
|
2141
|
+
reviewCards: this.reviewCards.size,
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Mark one digest member settled and redraw the digest (APRV-115).
|
|
2146
|
+
*
|
|
2147
|
+
* The member's own nonce is forgotten first, so a tap on a button the redraw
|
|
2148
|
+
* does not manage to remove resolves to nothing rather than reaching the
|
|
2149
|
+
* gate. The other members keep theirs: a partially decided digest is a real
|
|
2150
|
+
* state and the rest of it is still answerable.
|
|
2151
|
+
*/
|
|
2152
|
+
async settleMember(digest, actionKey, outcome, detail) {
|
|
2153
|
+
const member = digest.members.find((entry) => entry.actionKey === actionKey);
|
|
2154
|
+
if (member === undefined || member.settled !== null)
|
|
2155
|
+
return;
|
|
2156
|
+
member.settled = { headline: outcome, detail };
|
|
2157
|
+
this.deliveries.delete(member.nonce);
|
|
2158
|
+
if (digest.members.every((entry) => entry.settled !== null)) {
|
|
2159
|
+
this.allNonces.delete(digest.allNonce);
|
|
2160
|
+
}
|
|
2161
|
+
await this.redraw(digest);
|
|
2162
|
+
}
|
|
2163
|
+
/** One `editMessageText` that replaces a digest's text and its keyboard. */
|
|
2164
|
+
async redraw(digest) {
|
|
2165
|
+
const drawn = renderDigest(digest);
|
|
2166
|
+
await this.call("editMessageText", {
|
|
2167
|
+
chat_id: this.chatId,
|
|
2168
|
+
message_id: Number(digest.deliveryId),
|
|
2169
|
+
text: drawn.text,
|
|
2170
|
+
parse_mode: "HTML",
|
|
2171
|
+
disable_web_page_preview: true,
|
|
2172
|
+
...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Edit a delivered message to say what became of its question, and remove the
|
|
2177
|
+
* buttons (APRV-106 for withdrawal, generalized in APRV-113 to every terminal
|
|
2178
|
+
* state).
|
|
2179
|
+
*
|
|
2180
|
+
* ONE `editMessageText` call, not two. Telegram's `editMessageText` replaces
|
|
2181
|
+
* the reply markup along with the text, and omitting `reply_markup` clears
|
|
2182
|
+
* it — so the annotation and the disarming land together, and there is no
|
|
2183
|
+
* window in which the message reads "approved" and still offers a tap.
|
|
2184
|
+
*
|
|
2185
|
+
* The text is REPLACED rather than appended to, because this class does not
|
|
2186
|
+
* remember what it sent (it remembers a nonce and a message id) and refetching
|
|
2187
|
+
* a message to append to it would be the channel reconstructing state it is
|
|
2188
|
+
* not supposed to hold. What the approver keeps is the outcome, the action key
|
|
2189
|
+
* and the detail lines, which is what a chat transcript needs to stay readable.
|
|
2190
|
+
*
|
|
2191
|
+
* `outcome` is a headline word (see {@link TELEGRAM_TERMINAL_HEADLINES}) and
|
|
2192
|
+
* `detail` the lines under it; both are HTML-escaped here, and neither may
|
|
2193
|
+
* carry an execution token — no caller in this repository has one to give,
|
|
2194
|
+
* since {@link DecisionOutcome} deliberately does not carry it.
|
|
2195
|
+
*
|
|
2196
|
+
* Best effort: {@link TelegramApiError} propagates to the caller, which logs
|
|
2197
|
+
* it and carries on. A message that could not be edited is a cosmetic
|
|
2198
|
+
* problem — the log has already settled the request, so a tap on the stale
|
|
2199
|
+
* buttons is refused by the gate and answered with the refusal toast.
|
|
2200
|
+
*/
|
|
2201
|
+
async annotate(deliveryId, outcome, detail,
|
|
2202
|
+
/**
|
|
2203
|
+
* Which request this settles, when `deliveryId` names a digest (APRV-115).
|
|
2204
|
+
* A digest holds several, so an annotation without one can only mean the
|
|
2205
|
+
* whole delivery is over — which is handled by falling through to the
|
|
2206
|
+
* message-replacing path below, buttons and all.
|
|
2207
|
+
*/
|
|
2208
|
+
actionKey) {
|
|
2209
|
+
const digest = this.digests.get(deliveryId);
|
|
2210
|
+
if (digest !== undefined && actionKey !== undefined) {
|
|
2211
|
+
await this.settleMember(digest, actionKey, outcome, detail);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const settledKey = this.disarm(deliveryId);
|
|
2215
|
+
const text = [
|
|
2216
|
+
`<b>${escapeHtml(outcome)}</b>`,
|
|
2217
|
+
`<code>${escapeHtml(actionKey ?? settledKey)}</code>`,
|
|
2218
|
+
"",
|
|
2219
|
+
...detail.map((entry) => escapeHtml(entry)),
|
|
2220
|
+
].join("\n");
|
|
2221
|
+
await this.call("editMessageText", {
|
|
2222
|
+
chat_id: this.chatId,
|
|
2223
|
+
message_id: Number(deliveryId),
|
|
2224
|
+
text,
|
|
2225
|
+
parse_mode: "HTML",
|
|
2226
|
+
disable_web_page_preview: true,
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* The withdrawal case of {@link annotate} (APRV-106), and the one the
|
|
2231
|
+
* {@link Channel} interface names. Its wording is unchanged.
|
|
2232
|
+
*/
|
|
2233
|
+
async retract(deliveryId, reason, actionKey) {
|
|
2234
|
+
await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Send one plain message that carries no question (APRV-196).
|
|
2238
|
+
*
|
|
2239
|
+
* Used for the re-delivery banner the listener puts in front of a startup
|
|
2240
|
+
* batch. It arms nothing, remembers nothing, and names no action key: a
|
|
2241
|
+
* banner is a sentence about the messages that follow, and a reader who
|
|
2242
|
+
* mistook it for a request would be a reader the banner had made worse off.
|
|
2243
|
+
* `lines` are escaped here, exactly as everything else interpolated into an
|
|
2244
|
+
* HTML-mode message is.
|
|
2245
|
+
*/
|
|
2246
|
+
async announce(lines) {
|
|
2247
|
+
const result = await this.call("sendMessage", {
|
|
2248
|
+
chat_id: this.chatId,
|
|
2249
|
+
text: lines
|
|
2250
|
+
.map((entry, index) => index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry))
|
|
2251
|
+
.join("\n"),
|
|
2252
|
+
parse_mode: "HTML",
|
|
2253
|
+
disable_web_page_preview: true,
|
|
2254
|
+
});
|
|
2255
|
+
return String(result.message_id);
|
|
2256
|
+
}
|
|
2257
|
+
// -------------------------------------------------------------------------
|
|
2258
|
+
// Long polling
|
|
2259
|
+
// -------------------------------------------------------------------------
|
|
2260
|
+
/**
|
|
2261
|
+
* Long-poll `getUpdates` until {@link stop} is called (or one batch, with
|
|
2262
|
+
* `once`).
|
|
2263
|
+
*
|
|
2264
|
+
* **The loop survives the network.** A poll that times out, is refused, drops
|
|
2265
|
+
* its socket, returns a 5xx, or answers with something that is not JSON is
|
|
2266
|
+
* counted, complained about on stderr, and retried after a doubling backoff.
|
|
2267
|
+
* There is no failure mode in which the listener quietly stops listening: the
|
|
2268
|
+
* whole value of a push channel is that a human's inbox keeps receiving, and
|
|
2269
|
+
* a listener that died at 3am on a transient 502 would fail exactly when the
|
|
2270
|
+
* queue was filling up.
|
|
2271
|
+
*
|
|
2272
|
+
* Each iteration begins with {@link TelegramListenOptions.beforePoll} when
|
|
2273
|
+
* one is supplied, which is where the runtime's dispatch cycle runs: the
|
|
2274
|
+
* loop is therefore "deliver anything newly pending, then wait for a
|
|
2275
|
+
* decision", not "deliver once at startup, then wait forever".
|
|
2276
|
+
*/
|
|
2277
|
+
async listen(options = {}) {
|
|
2278
|
+
this.stopped = false;
|
|
2279
|
+
let backoff = this.backoffMs;
|
|
2280
|
+
while (!this.stopped) {
|
|
2281
|
+
try {
|
|
2282
|
+
if (options.beforePoll !== undefined) {
|
|
2283
|
+
await options.beforePoll();
|
|
2284
|
+
if (this.stopped)
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
await this.pollOnce();
|
|
2288
|
+
backoff = this.backoffMs;
|
|
2289
|
+
if (options.once === true)
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
catch (cause) {
|
|
2293
|
+
if (this.stopped)
|
|
2294
|
+
return;
|
|
2295
|
+
this.counters.pollErrors += 1;
|
|
2296
|
+
this.complain(`approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`);
|
|
2297
|
+
await sleep(backoff);
|
|
2298
|
+
backoff = Math.min(backoff * 2, this.maxBackoffMs);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
/** Stop the loop and abort any in-flight request. */
|
|
2303
|
+
stop() {
|
|
2304
|
+
this.stopped = true;
|
|
2305
|
+
this.inFlight?.abort();
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* One `getUpdates` batch, processed. Throws on a transport failure — which is
|
|
2309
|
+
* what {@link listen} catches and retries.
|
|
2310
|
+
*/
|
|
2311
|
+
async pollOnce() {
|
|
2312
|
+
// APRV-135. Before the long poll, not after: this is where the loop is
|
|
2313
|
+
// about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
|
|
2314
|
+
// the block would be a sweep that never runs on a quiet chat. Rate-limited
|
|
2315
|
+
// so a driver calling `pollOnce` in a tight loop does not spend its time
|
|
2316
|
+
// walking two maps.
|
|
2317
|
+
const nowMs = this.now();
|
|
2318
|
+
if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
|
|
2319
|
+
this.sweep(nowMs);
|
|
2320
|
+
const updates = await this.call("getUpdates", {
|
|
2321
|
+
offset: this.offset,
|
|
2322
|
+
timeout: this.pollTimeoutSeconds,
|
|
2323
|
+
// APRV-216. `message` is asked for only while a command handler is
|
|
2324
|
+
// registered: see {@link onCommand} for why a listener that reads
|
|
2325
|
+
// messages nobody asked for would break `approval setup channel
|
|
2326
|
+
// telegram`'s chat discovery.
|
|
2327
|
+
// APRV-299 adds the second reason to read messages: a `loved` or
|
|
2328
|
+
// `disliked` on a review card collects the human's words as a REPLY,
|
|
2329
|
+
// because an inline keyboard has no text input.
|
|
2330
|
+
allowed_updates: this.commandHandler === null && this.reviewHandler === null
|
|
2331
|
+
? ["callback_query"]
|
|
2332
|
+
: ["callback_query", "message"],
|
|
2333
|
+
}, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
|
|
2334
|
+
const result = {
|
|
2335
|
+
updates: 0,
|
|
2336
|
+
outcomes: [],
|
|
2337
|
+
ignored: [],
|
|
2338
|
+
commands: [],
|
|
2339
|
+
reviews: [],
|
|
2340
|
+
};
|
|
2341
|
+
for (const raw of updates) {
|
|
2342
|
+
const update = (raw ?? {});
|
|
2343
|
+
const id = update["update_id"];
|
|
2344
|
+
if (typeof id === "number")
|
|
2345
|
+
this.offset = Math.max(this.offset, id + 1);
|
|
2346
|
+
this.counters.updates += 1;
|
|
2347
|
+
result.updates += 1;
|
|
2348
|
+
await this.handleUpdate(update, result);
|
|
2349
|
+
}
|
|
2350
|
+
return result;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Exactly one `answerCallbackQuery` per callback query, on every path
|
|
2354
|
+
* (APRV-196).
|
|
2355
|
+
*
|
|
2356
|
+
* The incident this closes: a tap that reached no branch with a toast on it
|
|
2357
|
+
* spun on the approver's phone until Telegram gave up, and the human — with
|
|
2358
|
+
* no way to tell a swallowed tap from a slow one — tapped again. So the ack
|
|
2359
|
+
* is a property of the WRAPPER rather than of each branch: every route below
|
|
2360
|
+
* still writes its own, better sentence, and anything that fails to (a throw
|
|
2361
|
+
* halfway through, a branch a later change forgets) is caught here and
|
|
2362
|
+
* answered with {@link TELEGRAM_ACK_FALLBACK}.
|
|
2363
|
+
*
|
|
2364
|
+
* A thrown handler is answered and swallowed rather than propagated, and that
|
|
2365
|
+
* is deliberate: `pollOnce` throwing puts `listen` into its backoff, so one
|
|
2366
|
+
* malformed update would cost the whole batch and the poll after it. Nothing
|
|
2367
|
+
* is lost by continuing — the gate has already appended whatever it appended,
|
|
2368
|
+
* and the log is what says so.
|
|
2369
|
+
*
|
|
2370
|
+
* APRV-206 moved WHEN that one answer is sent on the decision path: it now
|
|
2371
|
+
* goes out before the gate runs, so the spinner on the phone is one Bot API
|
|
2372
|
+
* call long instead of one decision long. The guarantee is unchanged and is
|
|
2373
|
+
* now enforced in one place — {@link safeAnswer} answers a query at most once,
|
|
2374
|
+
* so the fallback below cannot follow an early ack with a second call.
|
|
2375
|
+
*/
|
|
2376
|
+
async handleUpdate(update, result) {
|
|
2377
|
+
const callback = update["callback_query"];
|
|
2378
|
+
if (typeof callback !== "object" || callback === null) {
|
|
2379
|
+
// APRV-216. Swallowed for the same reason a thrown decision handler is:
|
|
2380
|
+
// letting it out would reach `pollOnce`, and a listener that dropped into
|
|
2381
|
+
// backoff because one command failed would be a listener that stopped
|
|
2382
|
+
// listening over something that wrote nothing.
|
|
2383
|
+
try {
|
|
2384
|
+
await this.handleMessage(update, result);
|
|
2385
|
+
}
|
|
2386
|
+
catch (cause) {
|
|
2387
|
+
this.complain(`approval: telegram failed while handling a command: ${this.describe(cause)} — nothing was appended and the listener is still up`);
|
|
2388
|
+
}
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const query = callback;
|
|
2392
|
+
const callbackId = typeof query["id"] === "string" ? query["id"] : "";
|
|
2393
|
+
this.ack = { id: callbackId, answered: false };
|
|
2394
|
+
try {
|
|
2395
|
+
await this.routeCallback(query, callbackId, result);
|
|
2396
|
+
}
|
|
2397
|
+
catch (cause) {
|
|
2398
|
+
this.complain(`approval: telegram failed while handling a callback: ${this.describe(cause)} — the tap is answered; whatever the gate appended stands`);
|
|
2399
|
+
await this.safeAnswer(callbackId, TELEGRAM_ACK_FALLBACK);
|
|
2400
|
+
}
|
|
2401
|
+
finally {
|
|
2402
|
+
const pending = this.ack;
|
|
2403
|
+
this.ack = null;
|
|
2404
|
+
if (pending !== null && !pending.answered) {
|
|
2405
|
+
await this.safeAnswer(callbackId, TELEGRAM_ACK_FALLBACK);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* A `message` update: the bot-command path (APRV-216).
|
|
2411
|
+
*
|
|
2412
|
+
* Three rules, in this order, and each of them is a refusal to act on
|
|
2413
|
+
* something the network said:
|
|
2414
|
+
*
|
|
2415
|
+
* 1. **No handler, no reading.** A channel with no command handler wants no
|
|
2416
|
+
* message updates and did not ask for any; one that arrives anyway (a
|
|
2417
|
+
* webhook backlog, a poll issued before the handler was registered) is
|
|
2418
|
+
* dropped without a counter, because there is nothing wrong with it.
|
|
2419
|
+
* 2. **The configured chat only.** A message from anywhere else is counted
|
|
2420
|
+
* `foreign-chat` and answered with nothing at all. Not even a refusal
|
|
2421
|
+
* reply: a stranger who can reach the bot learns from silence that the
|
|
2422
|
+
* bot is there, and learns from a reply what it is for.
|
|
2423
|
+
* 3. **A closed vocabulary.** `/queue`, `/skip`, `/next`. Anything else
|
|
2424
|
+
* beginning with `/` is counted `unknown-command`; anything not beginning
|
|
2425
|
+
* with `/` is ordinary chat and is ignored silently.
|
|
2426
|
+
*
|
|
2427
|
+
* A command decides nothing and appends nothing — it cannot, because it
|
|
2428
|
+
* never reaches {@link handler}. The handler it does reach reorders what the
|
|
2429
|
+
* runtime shows next, which is process memory on the runtime's side of the
|
|
2430
|
+
* boundary (SPEC.md §10.3).
|
|
2431
|
+
*/
|
|
2432
|
+
async handleMessage(update, result) {
|
|
2433
|
+
const handler = this.commandHandler;
|
|
2434
|
+
if (handler === null && this.reviewHandler === null)
|
|
2435
|
+
return;
|
|
2436
|
+
const raw = update["message"];
|
|
2437
|
+
if (typeof raw !== "object" || raw === null)
|
|
2438
|
+
return;
|
|
2439
|
+
const message = raw;
|
|
2440
|
+
const text = message["text"];
|
|
2441
|
+
if (typeof text !== "string")
|
|
2442
|
+
return;
|
|
2443
|
+
// APRV-299, before the command vocabulary: a reply to an outstanding note
|
|
2444
|
+
// prompt is the human's own words, and it is bound to its card by the
|
|
2445
|
+
// message id THIS process issued, never by anything the reply asserts about
|
|
2446
|
+
// itself. A reply naming a prompt this process is not holding falls through
|
|
2447
|
+
// and is ordinary chat.
|
|
2448
|
+
if (await this.handleNoteReply(message, text, result))
|
|
2449
|
+
return;
|
|
2450
|
+
if (handler === null || !text.trim().startsWith("/"))
|
|
2451
|
+
return;
|
|
2452
|
+
const chat = (message["chat"] ?? {});
|
|
2453
|
+
const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
|
|
2454
|
+
if (chatId !== this.chatId) {
|
|
2455
|
+
this.counters.anomalies["foreign-chat"] += 1;
|
|
2456
|
+
result.ignored.push({
|
|
2457
|
+
kind: "foreign-chat",
|
|
2458
|
+
detail: `command from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`,
|
|
2459
|
+
});
|
|
2460
|
+
this.complain(`approval: telegram ignored a command (foreign-chat): from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
const command = parseBotCommand(text);
|
|
2464
|
+
if (command === null) {
|
|
2465
|
+
this.counters.anomalies["unknown-command"] += 1;
|
|
2466
|
+
result.ignored.push({ kind: "unknown-command", detail: this.redact(text.trim()) });
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
this.counters.commands += 1;
|
|
2470
|
+
result.commands.push(command);
|
|
2471
|
+
// A throw here is caught by `handleUpdate`, which complains and carries on:
|
|
2472
|
+
// a command that failed must not cost the batch or the poll after it, and
|
|
2473
|
+
// there is nothing to undo, because a command writes nothing.
|
|
2474
|
+
await handler(command);
|
|
2475
|
+
}
|
|
2476
|
+
async routeCallback(query, callbackId, result) {
|
|
2477
|
+
const message = (query["message"] ?? {});
|
|
2478
|
+
const chat = (message["chat"] ?? {});
|
|
2479
|
+
const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
|
|
2480
|
+
// (a) Not our chat. Counted, answered, never decided, never logged.
|
|
2481
|
+
if (chatId !== this.chatId) {
|
|
2482
|
+
await this.ignore(result, callbackId, "foreign-chat", `callback from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`, "This bot only accepts decisions from its configured approval chat.");
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
// APRV-257, before the decision vocabulary and in a parser of its own. A
|
|
2486
|
+
// checkpoint button decides no request, so it must never reach the ladder
|
|
2487
|
+
// below — where an unresolved nonce falls back to an action reference, and
|
|
2488
|
+
// a signature gesture would start looking for something to approve.
|
|
2489
|
+
const checkpoint = parseCheckpointCallback(query["data"]);
|
|
2490
|
+
if (checkpoint !== null) {
|
|
2491
|
+
await this.handleCheckpointTap(checkpoint, callbackId, result);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
// APRV-299, before the decision vocabulary and in a parser of its own, for
|
|
2495
|
+
// the same reason the checkpoint one is: a review button decides no request
|
|
2496
|
+
// and must never reach the ladder below, where an unresolved nonce falls
|
|
2497
|
+
// back to an action reference and a gesture about something that already
|
|
2498
|
+
// happened would start looking for something to approve.
|
|
2499
|
+
const review = parseReviewCallback(query["data"]);
|
|
2500
|
+
if (review !== null) {
|
|
2501
|
+
await this.handleReviewTap(review, callbackId, result);
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
const parsed = parseCallbackData(query["data"]);
|
|
2505
|
+
if (parsed === null) {
|
|
2506
|
+
await this.ignore(result, callbackId, "malformed-callback", `callback_data ${JSON.stringify(query["data"])} is not a decision this channel issued`, "Unrecognized button.");
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
// APRV-115. An "all" button names a digest, not a request: the set it
|
|
2510
|
+
// decides is whatever is still open on that delivery right now, which this
|
|
2511
|
+
// process knows and the callback bytes deliberately do not say.
|
|
2512
|
+
if (parsed.scope === "all") {
|
|
2513
|
+
await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
// The resolution ladder (APRV-196). A tap is answered by the nonce when
|
|
2517
|
+
// this process issued it, by the action reference when it did not, and by
|
|
2518
|
+
// the log when neither is holding the action open.
|
|
2519
|
+
let delivery = this.deliveries.get(parsed.nonce);
|
|
2520
|
+
let viaStaleCopy = false;
|
|
2521
|
+
if (delivery === undefined && parsed.actionRef !== null) {
|
|
2522
|
+
// The pre-restart copy. Its nonce died with the process that issued it,
|
|
2523
|
+
// but the request it names is one THIS process has since re-delivered, so
|
|
2524
|
+
// the tap decides that request — on the live copy's message, which is
|
|
2525
|
+
// where the annotation belongs. The bytes select among what this listener
|
|
2526
|
+
// has itself put in this chat and can name nothing else; the gate then
|
|
2527
|
+
// does everything it does for any other tap.
|
|
2528
|
+
delivery = this.liveDeliveryFor(parsed.actionRef);
|
|
2529
|
+
viaStaleCopy = delivery !== undefined;
|
|
2530
|
+
}
|
|
2531
|
+
if (delivery === undefined) {
|
|
2532
|
+
if (parsed.actionRef !== null) {
|
|
2533
|
+
// Nothing open here for that action. Say what the log says, which is
|
|
2534
|
+
// the only thing that knows: decided, lapsed, withdrawn, or unknown.
|
|
2535
|
+
const described = this.describeAction?.(parsed.actionRef) ?? null;
|
|
2536
|
+
await this.ignore(result, callbackId, "stale-copy", `no open delivery for action ref ${JSON.stringify(parsed.actionRef)} (an earlier copy of a request this listener is not holding open)`, described ?? TELEGRAM_STALE_UNKNOWN);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
await this.ignore(result, callbackId, "unknown-callback", `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
|
|
2540
|
+
// Two ways to get here, and the reply has to serve both: a button this
|
|
2541
|
+
// process never issued (a restart forgot it), and a button on a message
|
|
2542
|
+
// this process has already annotated (APRV-113 forgets the nonce with
|
|
2543
|
+
// the edit). Either way the message text is the thing to read.
|
|
2544
|
+
"This button is no longer live — read the message for the outcome, or the newest message for the request.");
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
if (!viaStaleCopy && parsed.actionRef !== null && parsed.actionRef !== delivery.actionRef) {
|
|
2548
|
+
await this.ignore(result, callbackId, "key-mismatch", `callback references ${JSON.stringify(parsed.actionRef)} but the nonce was issued for ${JSON.stringify(delivery.actionKey)}`, "That button does not match a delivered request.");
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const decision = {
|
|
2552
|
+
action_key: delivery.actionKey,
|
|
2553
|
+
decision: parsed.decision,
|
|
2554
|
+
deliveryId: delivery.deliveryId,
|
|
2555
|
+
...(delivery.batchDeliveryId === undefined
|
|
2556
|
+
? {}
|
|
2557
|
+
: { batchDeliveryId: delivery.batchDeliveryId }),
|
|
2558
|
+
...(parsed.decision === "reject"
|
|
2559
|
+
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
|
|
2560
|
+
: {}),
|
|
2561
|
+
};
|
|
2562
|
+
if (this.handler === null) {
|
|
2563
|
+
await this.ignore(result, callbackId, "unknown-callback", "a callback arrived before the runtime registered a decision handler", "The runtime is not ready to record decisions.");
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
// APRV-206. The ack goes out BEFORE the gate runs, and it is the only
|
|
2567
|
+
// answer this query will get. Everything below — reading the verified log,
|
|
2568
|
+
// re-checking the budgets, appending under the lock — used to happen while
|
|
2569
|
+
// the button spun, so the spinner's length was the log's length. It claims
|
|
2570
|
+
// no decision (see {@link TELEGRAM_ACK_HEARD}): at this instant nothing has
|
|
2571
|
+
// been appended, and the annotation below is what says what the log holds.
|
|
2572
|
+
await this.safeAnswer(callbackId, `${viaStaleCopy ? TELEGRAM_STALE_COPY_PREFIX : ""}${TELEGRAM_ACK_HEARD}`);
|
|
2573
|
+
// APRV-206. A handler that throws is the one case the early ack cannot be
|
|
2574
|
+
// taken back: the human has been told their tap arrived, and the toast that
|
|
2575
|
+
// used to say "this listener could not finish reading your tap" is spent. So
|
|
2576
|
+
// the message says it instead, and the throw still reaches `handleUpdate`,
|
|
2577
|
+
// which complains and keeps the poll loop alive exactly as before.
|
|
2578
|
+
let outcome;
|
|
2579
|
+
try {
|
|
2580
|
+
outcome = this.handler(decision);
|
|
2581
|
+
}
|
|
2582
|
+
catch (cause) {
|
|
2583
|
+
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2584
|
+
TELEGRAM_HANDLER_FAILED,
|
|
2585
|
+
]);
|
|
2586
|
+
throw cause;
|
|
2587
|
+
}
|
|
2588
|
+
this.counters.decisions += 1;
|
|
2589
|
+
if (viaStaleCopy) {
|
|
2590
|
+
this.counters.staleCopyDecisions += 1;
|
|
2591
|
+
this.complain(`approval: telegram resolved a tap on an earlier copy of ${delivery.actionKey} to the live delivery (message ${delivery.deliveryId})`);
|
|
2592
|
+
}
|
|
2593
|
+
result.outcomes.push({ action_key: delivery.actionKey, outcome });
|
|
2594
|
+
// APRV-113, and since APRV-206 the ONLY place the outcome is stated. The
|
|
2595
|
+
// tap is visible in the transcript rather than in a toast that vanishes,
|
|
2596
|
+
// and the words come from the record the gate actually appended.
|
|
2597
|
+
//
|
|
2598
|
+
// Best effort in the same sense `retract` is — a failed edit is complained
|
|
2599
|
+
// about and dropped, because the decision is already in the log and nothing
|
|
2600
|
+
// about it depends on a chat message being redrawn.
|
|
2601
|
+
if (!outcome.ok) {
|
|
2602
|
+
// APRV-206. A refusal used to be a toast; the single answer is now spent
|
|
2603
|
+
// on the ack, so it is said here or nowhere. `annotate` disarms the
|
|
2604
|
+
// message, which is right in both directions: a terminal request has no
|
|
2605
|
+
// decision left to collect, and a still-pending one is re-delivered as a
|
|
2606
|
+
// fresh prompt by the next dispatch cycle.
|
|
2607
|
+
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2608
|
+
this.answerFor(outcome),
|
|
2609
|
+
]);
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
const record = outcome.record;
|
|
2613
|
+
const headline = outcome.decision === "grant"
|
|
2614
|
+
? TELEGRAM_TERMINAL_HEADLINES.granted
|
|
2615
|
+
: TELEGRAM_TERMINAL_HEADLINES.rejected;
|
|
2616
|
+
try {
|
|
2617
|
+
await this.annotate(delivery.deliveryId, headline, [decidedLine(record.actor, record.ts, record.seq)], delivery.actionKey);
|
|
2618
|
+
}
|
|
2619
|
+
catch (cause) {
|
|
2620
|
+
// APRV-277: the one failure that is not one. See isMessageNotModified.
|
|
2621
|
+
if (isMessageNotModified(cause))
|
|
2622
|
+
return;
|
|
2623
|
+
this.complain(`approval: telegram could not annotate the decided ${delivery.actionKey} (message ${delivery.deliveryId}): ${this.describe(cause)} — the decision is recorded; only the message is stale`);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* One tap over every still-open member of a digest (APRV-115).
|
|
2628
|
+
*
|
|
2629
|
+
* **N decisions, never one.** Each member is turned into its own
|
|
2630
|
+
* {@link ChannelDecision} — its own action key, its own payload binding — and
|
|
2631
|
+
* handed to the runtime's handler on its own, which records it through the
|
|
2632
|
+
* gate's compare-and-append on its own. There is no code path here that could
|
|
2633
|
+
* produce a single event covering two actions, because there is no call here
|
|
2634
|
+
* that writes anything at all.
|
|
2635
|
+
*
|
|
2636
|
+
* A member that refuses (already decided elsewhere, expired, withdrawn) does
|
|
2637
|
+
* not stop the rest, for the reason `channels/batch.ts` sets out: abandoning
|
|
2638
|
+
* four answers because the fifth had lapsed would discard a human's decision,
|
|
2639
|
+
* and un-appending the ones already written is not a thing the log permits.
|
|
2640
|
+
* The toast says how many landed and how many did not.
|
|
2641
|
+
*
|
|
2642
|
+
* The digest is redrawn ONCE at the end rather than per member: N edits of
|
|
2643
|
+
* the same message would show the approver their own decisions arriving one
|
|
2644
|
+
* at a time, and would spend N Bot API calls to end in the same place.
|
|
2645
|
+
*/
|
|
2646
|
+
async handleDigestAll(decision, nonce, callbackId, result) {
|
|
2647
|
+
const deliveryId = this.allNonces.get(nonce);
|
|
2648
|
+
const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
|
|
2649
|
+
if (digest === undefined) {
|
|
2650
|
+
await this.ignore(result, callbackId, "unknown-callback", `no digest for nonce ${JSON.stringify(nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`, "This button is no longer live — read the message for the outcome, or the newest message for the requests.");
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
if (this.handler === null) {
|
|
2654
|
+
await this.ignore(result, callbackId, "unknown-callback", "a callback arrived before the runtime registered a decision handler", "The runtime is not ready to record decisions.");
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
// APRV-206, and this path needs it most: an "all" tap runs one gate
|
|
2658
|
+
// decision per open member, so its old post-hoc toast made the spinner N
|
|
2659
|
+
// decisions long. The redraw below is what says how each member ended.
|
|
2660
|
+
await this.safeAnswer(callbackId, TELEGRAM_ACK_HEARD);
|
|
2661
|
+
const open = digest.members.filter((member) => member.settled === null);
|
|
2662
|
+
let landed = 0;
|
|
2663
|
+
const refusals = [];
|
|
2664
|
+
for (const member of open) {
|
|
2665
|
+
const one = {
|
|
2666
|
+
action_key: member.actionKey,
|
|
2667
|
+
decision,
|
|
2668
|
+
deliveryId: digest.deliveryId,
|
|
2669
|
+
batchDeliveryId: digest.batchDeliveryId,
|
|
2670
|
+
...(decision === "reject"
|
|
2671
|
+
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
|
|
2672
|
+
: {}),
|
|
2673
|
+
};
|
|
2674
|
+
const outcome = this.handler(one);
|
|
2675
|
+
this.counters.decisions += 1;
|
|
2676
|
+
result.outcomes.push({ action_key: member.actionKey, outcome });
|
|
2677
|
+
if (!outcome.ok) {
|
|
2678
|
+
refusals.push(outcome.code);
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
landed += 1;
|
|
2682
|
+
// Bookkeeping only: the words come from the record the gate appended.
|
|
2683
|
+
member.settled = {
|
|
2684
|
+
headline: outcome.decision === "grant"
|
|
2685
|
+
? TELEGRAM_TERMINAL_HEADLINES.granted
|
|
2686
|
+
: TELEGRAM_TERMINAL_HEADLINES.rejected,
|
|
2687
|
+
detail: [decidedLine(outcome.record.actor, outcome.record.ts, outcome.record.seq)],
|
|
2688
|
+
};
|
|
2689
|
+
this.deliveries.delete(member.nonce);
|
|
2690
|
+
}
|
|
2691
|
+
if (digest.members.every((member) => member.settled !== null)) {
|
|
2692
|
+
this.allNonces.delete(digest.allNonce);
|
|
2693
|
+
}
|
|
2694
|
+
const word = decision === "grant" ? "Approved" : "Rejected";
|
|
2695
|
+
const summary = refusals.length === 0
|
|
2696
|
+
? `${word} ${landed} — one log event each.`
|
|
2697
|
+
: `${word} ${landed}; ${refusals.length} refused (${[...new Set(refusals)].join(", ")}). Nothing was recorded for those.`;
|
|
2698
|
+
// APRV-206: the summary is no longer a toast (the tap's one answer was
|
|
2699
|
+
// spent acknowledging it). The approver reads the outcome off the redrawn
|
|
2700
|
+
// digest, member by member, and the operator gets the tally here.
|
|
2701
|
+
this.complain(`approval: telegram digest ${digest.deliveryId}: ${summary}`);
|
|
2702
|
+
try {
|
|
2703
|
+
await this.redraw(digest);
|
|
2704
|
+
}
|
|
2705
|
+
catch (cause) {
|
|
2706
|
+
// APRV-277: the one failure that is not one. See isMessageNotModified.
|
|
2707
|
+
if (isMessageNotModified(cause))
|
|
2708
|
+
return;
|
|
2709
|
+
this.complain(`approval: telegram could not redraw the digest (message ${digest.deliveryId}): ${this.describe(cause)} — the decisions are recorded; only the message is stale`);
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* {@link annotate}, with a failed edit complained about rather than thrown
|
|
2714
|
+
* (APRV-206).
|
|
2715
|
+
*
|
|
2716
|
+
* Every caller on the decision path wants the same thing from a failed edit:
|
|
2717
|
+
* say so on the operator's terminal and carry on, because whatever the gate
|
|
2718
|
+
* did or did not append has already happened and no chat message changes it.
|
|
2719
|
+
*
|
|
2720
|
+
* The exception is {@link isMessageNotModified}, which says the message
|
|
2721
|
+
* already reads the way this call wanted it to read (APRV-277). Nothing is
|
|
2722
|
+
* printed for it: there is no staleness to warn about.
|
|
2723
|
+
*/
|
|
2724
|
+
async annotateQuietly(deliveryId, actionKey, headline, detail) {
|
|
2725
|
+
try {
|
|
2726
|
+
await this.annotate(deliveryId, headline, detail, actionKey);
|
|
2727
|
+
}
|
|
2728
|
+
catch (cause) {
|
|
2729
|
+
// APRV-277: the one failure that is not one. See isMessageNotModified.
|
|
2730
|
+
if (isMessageNotModified(cause))
|
|
2731
|
+
return;
|
|
2732
|
+
this.complain(`approval: telegram could not annotate ${actionKey} (message ${deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* What a refused tap is told, in the message edit (APRV-206; it was the toast
|
|
2737
|
+
* until the single answer moved to the early ack).
|
|
2738
|
+
*
|
|
2739
|
+
* The duplicate case is the one worth naming: a second tap on a request the
|
|
2740
|
+
* gate has already decided produces `already-decided`, no second event, and
|
|
2741
|
+
* this text. Telegram redelivers callbacks on its own, so this path is
|
|
2742
|
+
* ordinary traffic, not an error.
|
|
2743
|
+
*
|
|
2744
|
+
* The sentences themselves moved to `channels/contract.ts` in APRV-235, so
|
|
2745
|
+
* that this message edit and the line the terminal channel prints are the
|
|
2746
|
+
* same words and cannot drift apart: a human who taps on their phone and
|
|
2747
|
+
* then reads the operator's terminal should not have to decide which of two
|
|
2748
|
+
* wordings to believe. The edit puts {@link TELEGRAM_NOT_RECORDED} above it
|
|
2749
|
+
* and clears the buttons, in `annotate`'s single call.
|
|
2750
|
+
*/
|
|
2751
|
+
answerFor(outcome) {
|
|
2752
|
+
return refusedDecisionLine(outcome.code);
|
|
2753
|
+
}
|
|
2754
|
+
/**
|
|
2755
|
+
* A tap on `Sign` or `Not now` (APRV-257).
|
|
2756
|
+
*
|
|
2757
|
+
* The nonce is authoritative and there is no fallback ladder underneath it:
|
|
2758
|
+
* a checkpoint names no request, so there is no action reference to rescue a
|
|
2759
|
+
* stale copy with, and a tap this process cannot resolve is answered as
|
|
2760
|
+
* `unknown-callback` rather than guessed at. The cost is one dead button
|
|
2761
|
+
* after a restart, and the listener offers again on its next lapse.
|
|
2762
|
+
*
|
|
2763
|
+
* The nonce is consumed BEFORE the handler runs, so a double tap cannot
|
|
2764
|
+
* produce two records: the second tap finds nothing and says so. Even if it
|
|
2765
|
+
* did, `appendCheckpointAt` is a compare-and-append and the log would carry
|
|
2766
|
+
* two honest checkpoints over the same head, which is harmless — but a human
|
|
2767
|
+
* who taps twice should be told what happened rather than shown two
|
|
2768
|
+
* successes.
|
|
2769
|
+
*
|
|
2770
|
+
* The ack goes out FIRST (APRV-206's rule), because signing reads a vault
|
|
2771
|
+
* and appends to a log, and a spinner that lasted a decision long is what
|
|
2772
|
+
* that task removed.
|
|
2773
|
+
*/
|
|
2774
|
+
async handleCheckpointTap(tap, callbackId, result) {
|
|
2775
|
+
const held = this.checkpointNonces.get(tap.nonce);
|
|
2776
|
+
if (held === undefined) {
|
|
2777
|
+
await this.ignore(result, callbackId, "unknown-callback", `checkpoint nonce ${JSON.stringify(tap.nonce)} was not issued by this process`, "This checkpoint prompt is from an earlier run. A fresh one is offered when the next is due.");
|
|
2778
|
+
return;
|
|
2779
|
+
}
|
|
2780
|
+
this.checkpointNonces.delete(tap.nonce);
|
|
2781
|
+
const handler = this.checkpointHandler;
|
|
2782
|
+
if (handler === null) {
|
|
2783
|
+
await this.ignore(result, callbackId, "unknown-callback", "a checkpoint tap arrived with no handler registered", TELEGRAM_ACK_FALLBACK);
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
|
|
2787
|
+
const response = await handler({ sign: tap.sign, head: held.head });
|
|
2788
|
+
// Edited here rather than through `annotate`, which renders an action key
|
|
2789
|
+
// under its headline. A checkpoint has none, and an empty `<code></code>`
|
|
2790
|
+
// where a request's key belongs would be this channel implying a request.
|
|
2791
|
+
// Sending no `reply_markup` is what takes the buttons away.
|
|
2792
|
+
await this.call("editMessageText", {
|
|
2793
|
+
chat_id: this.chatId,
|
|
2794
|
+
message_id: Number(held.deliveryId),
|
|
2795
|
+
text: [
|
|
2796
|
+
`<b>${escapeHtml(response.headline)}</b>`,
|
|
2797
|
+
"",
|
|
2798
|
+
...response.detail.map((entry) => escapeHtml(entry)),
|
|
2799
|
+
].join("\n"),
|
|
2800
|
+
parse_mode: "HTML",
|
|
2801
|
+
disable_web_page_preview: true,
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* A tap on one of a review card's six buttons (APRV-299).
|
|
2806
|
+
*
|
|
2807
|
+
* The nonce is authoritative and there is no fallback ladder underneath it,
|
|
2808
|
+
* for the reason {@link reviewCallbackData} gives: a review is never urgent,
|
|
2809
|
+
* a card this process is not holding leaves its sample open, and the next
|
|
2810
|
+
* cycle offers a fresh one. An unresolvable tap is answered
|
|
2811
|
+
* `unknown-callback` rather than guessed at.
|
|
2812
|
+
*
|
|
2813
|
+
* Which combinations are legal is decided by `core/audit.ts` and by nothing
|
|
2814
|
+
* here. A denied review that says the human loved the work is refused by
|
|
2815
|
+
* `reviewSample` before it reads the log, with the code SPEC.md §11.2 names,
|
|
2816
|
+
* and this method's job is to put that pair in front of it rather than to
|
|
2817
|
+
* re-implement the rule. The one thing this method owns is the ARMING, which
|
|
2818
|
+
* is process memory that appends nothing.
|
|
2819
|
+
*/
|
|
2820
|
+
async handleReviewTap(tap, callbackId, result) {
|
|
2821
|
+
const deliveryId = this.reviewNonces.get(tap.nonce);
|
|
2822
|
+
const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
|
|
2823
|
+
if (state === undefined || state.settled !== null) {
|
|
2824
|
+
await this.ignore(result, callbackId, "unknown-callback", `no open review card for nonce ${JSON.stringify(tap.nonce)} (a restarted listener forgets its buttons; the sample stays open and is offered again)`, "This review card is no longer live — the sample is still open, and a fresh card is sent on a later cycle. `approval audit review` names it from a terminal.");
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
if (this.reviewHandler === null) {
|
|
2828
|
+
await this.ignore(result, callbackId, "unknown-callback", "a review tap arrived before the runtime registered a review handler", "The runtime is not ready to record reviews.");
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
// The first Deny tap arms and writes nothing. Stated on the card, so the
|
|
2832
|
+
// approver reads the state rather than inferring it from a toast.
|
|
2833
|
+
if (tap.choice === "deny" && !state.denyArmed) {
|
|
2834
|
+
state.denyArmed = true;
|
|
2835
|
+
state.notice = null;
|
|
2836
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ARM_TOAST);
|
|
2837
|
+
await this.redrawReview(state);
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
const verdict = state.denyArmed ? "denied" : "ok";
|
|
2841
|
+
if (tap.choice === "ok" || tap.choice === "deny") {
|
|
2842
|
+
// `ok` with deny armed is a correction, and it disarms: a human who
|
|
2843
|
+
// reached for Deny and then chose OK meant OK, and nothing was written in
|
|
2844
|
+
// between for the change of mind to contradict.
|
|
2845
|
+
const chosen = tap.choice === "deny" ? "denied" : "ok";
|
|
2846
|
+
state.denyArmed = chosen === "denied";
|
|
2847
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2848
|
+
await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict: chosen }, result);
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const reaction = tap.choice;
|
|
2852
|
+
// The two grades that require the human's own words ask for them FIRST, and
|
|
2853
|
+
// nothing is appended until the reply arrives. The exception is the pair
|
|
2854
|
+
// `core/audit.ts` refuses outright: a denied review that says liked or
|
|
2855
|
+
// loved is answered with `reaction-conflicts-verdict` before any log is
|
|
2856
|
+
// read, so asking for a note to go with it would collect words for a record
|
|
2857
|
+
// that was never going to exist.
|
|
2858
|
+
const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
|
|
2859
|
+
if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
|
|
2860
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
|
|
2861
|
+
await this.askForNote(state, verdict, reaction);
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2865
|
+
await this.recordReview(state, { sampleSeq: state.card.sampleSeq, verdict, reaction }, result);
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
|
|
2869
|
+
* its reply will record (APRV-299).
|
|
2870
|
+
*
|
|
2871
|
+
* The pending grade lives HERE and not in the reply's own text, exactly as a
|
|
2872
|
+
* checkpoint's head lives in this process rather than in the callback bytes:
|
|
2873
|
+
* what gets recorded is what this process put on the screen. Losing the map
|
|
2874
|
+
* to a restart costs the reply its meaning — nothing is appended, the sample
|
|
2875
|
+
* stays open, and a fresh card is offered — and can never cost a record
|
|
2876
|
+
* nobody asked for.
|
|
2877
|
+
*
|
|
2878
|
+
* A second prompt replaces the first: only one grade can be outstanding on
|
|
2879
|
+
* one card, and the older prompt stops resolving so a late reply to it lands
|
|
2880
|
+
* nowhere rather than recording a grade the human moved on from.
|
|
2881
|
+
*/
|
|
2882
|
+
async askForNote(state, verdict, reaction) {
|
|
2883
|
+
if (state.awaitingNote !== null)
|
|
2884
|
+
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
2885
|
+
const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
|
|
2886
|
+
const sent = await this.call("sendMessage", {
|
|
2887
|
+
chat_id: this.chatId,
|
|
2888
|
+
text: lines
|
|
2889
|
+
.map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
|
|
2890
|
+
.join("\n"),
|
|
2891
|
+
parse_mode: "HTML",
|
|
2892
|
+
disable_web_page_preview: true,
|
|
2893
|
+
reply_markup: { force_reply: true },
|
|
2894
|
+
});
|
|
2895
|
+
const promptId = String(sent.message_id);
|
|
2896
|
+
state.awaitingNote = { promptId, verdict, reaction };
|
|
2897
|
+
this.reviewNotePrompts.set(promptId, state.deliveryId);
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* A message replying to an outstanding note prompt (APRV-299).
|
|
2901
|
+
*
|
|
2902
|
+
* Returns `true` when this update was a note reply and has been dealt with,
|
|
2903
|
+
* so the command path below never sees it. Three refusals to act on something
|
|
2904
|
+
* the network said, in order: a reply naming no prompt this process issued is
|
|
2905
|
+
* not ours, a reply from another chat is counted `foreign-chat` and answered
|
|
2906
|
+
* with nothing at all, and the words themselves are passed to the runtime
|
|
2907
|
+
* verbatim — a blank one included, because whether blank is a note is
|
|
2908
|
+
* `core/audit.ts`'s rule and not this channel's.
|
|
2909
|
+
*/
|
|
2910
|
+
async handleNoteReply(message, text, result) {
|
|
2911
|
+
const replyTo = message["reply_to_message"];
|
|
2912
|
+
if (typeof replyTo !== "object" || replyTo === null)
|
|
2913
|
+
return false;
|
|
2914
|
+
const promptId = String(replyTo["message_id"] ?? "");
|
|
2915
|
+
const deliveryId = this.reviewNotePrompts.get(promptId);
|
|
2916
|
+
if (deliveryId === undefined)
|
|
2917
|
+
return false;
|
|
2918
|
+
const chat = (message["chat"] ?? {});
|
|
2919
|
+
const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
|
|
2920
|
+
if (chatId !== this.chatId) {
|
|
2921
|
+
this.counters.anomalies["foreign-chat"] += 1;
|
|
2922
|
+
result.ignored.push({
|
|
2923
|
+
kind: "foreign-chat",
|
|
2924
|
+
detail: `note reply from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`,
|
|
2925
|
+
});
|
|
2926
|
+
this.complain(`approval: telegram ignored a review note (foreign-chat): from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`);
|
|
2927
|
+
return true;
|
|
2928
|
+
}
|
|
2929
|
+
const state = this.reviewCards.get(deliveryId);
|
|
2930
|
+
const pending = state?.awaitingNote ?? null;
|
|
2931
|
+
this.reviewNotePrompts.delete(promptId);
|
|
2932
|
+
if (state === undefined || pending === null || pending.promptId !== promptId)
|
|
2933
|
+
return true;
|
|
2934
|
+
state.awaitingNote = null;
|
|
2935
|
+
await this.recordReview(state, {
|
|
2936
|
+
sampleSeq: state.card.sampleSeq,
|
|
2937
|
+
verdict: pending.verdict,
|
|
2938
|
+
reaction: pending.reaction,
|
|
2939
|
+
note: text,
|
|
2940
|
+
}, result);
|
|
2941
|
+
return true;
|
|
2942
|
+
}
|
|
2943
|
+
/**
|
|
2944
|
+
* Hand one review tap to the runtime and redraw the card from its answer
|
|
2945
|
+
* (APRV-299).
|
|
2946
|
+
*
|
|
2947
|
+
* A recorded review settles the card and forgets its nonce, so a tap on a
|
|
2948
|
+
* button the edit does not manage to remove resolves to nothing rather than
|
|
2949
|
+
* recording a second human observation of one item. A REFUSAL does neither:
|
|
2950
|
+
* nothing was appended, the sample is still open, and the codes that get here
|
|
2951
|
+
* are ones the reviewer can act on — `reaction-conflicts-verdict` asks them
|
|
2952
|
+
* to say which half they meant, and `note-required` asks for words — so the
|
|
2953
|
+
* buttons stay, with the refusal rendered above them and the arming intact.
|
|
2954
|
+
*/
|
|
2955
|
+
async recordReview(state, tap, result) {
|
|
2956
|
+
const handler = this.reviewHandler;
|
|
2957
|
+
if (handler === null)
|
|
2958
|
+
return;
|
|
2959
|
+
let response;
|
|
2960
|
+
try {
|
|
2961
|
+
response = await handler(tap);
|
|
2962
|
+
}
|
|
2963
|
+
catch (cause) {
|
|
2964
|
+
state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
|
|
2965
|
+
await this.redrawReview(state);
|
|
2966
|
+
throw cause;
|
|
2967
|
+
}
|
|
2968
|
+
this.counters.reviews += 1;
|
|
2969
|
+
result.reviews.push({ tap, ok: response.ok });
|
|
2970
|
+
if (response.ok) {
|
|
2971
|
+
state.settled = { headline: response.headline, detail: response.detail };
|
|
2972
|
+
state.notice = null;
|
|
2973
|
+
state.denyArmed = false;
|
|
2974
|
+
this.reviewNonces.delete(state.nonce);
|
|
2975
|
+
if (state.awaitingNote !== null) {
|
|
2976
|
+
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
2977
|
+
state.awaitingNote = null;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
else {
|
|
2981
|
+
state.notice = { headline: response.headline, lines: response.detail };
|
|
2982
|
+
}
|
|
2983
|
+
await this.redrawReview(state);
|
|
2984
|
+
}
|
|
2985
|
+
/** One `editMessageText` that replaces a review card's text and its keyboard. */
|
|
2986
|
+
async redrawReview(state) {
|
|
2987
|
+
const drawn = renderReviewCard(state);
|
|
2988
|
+
try {
|
|
2989
|
+
await this.call("editMessageText", {
|
|
2990
|
+
chat_id: this.chatId,
|
|
2991
|
+
message_id: Number(state.deliveryId),
|
|
2992
|
+
text: drawn.text,
|
|
2993
|
+
parse_mode: "HTML",
|
|
2994
|
+
disable_web_page_preview: true,
|
|
2995
|
+
...(drawn.keyboard === null ? {} : { reply_markup: drawn.keyboard }),
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
catch (cause) {
|
|
2999
|
+
// APRV-277: the one failure that is not one. See isMessageNotModified.
|
|
3000
|
+
if (isMessageNotModified(cause))
|
|
3001
|
+
return;
|
|
3002
|
+
// Cosmetic in the same sense every other failed edit here is: whatever
|
|
3003
|
+
// the runtime appended has already happened, and no chat message changes
|
|
3004
|
+
// it. The sample's state is the log's answer, never this card's text.
|
|
3005
|
+
this.complain(`approval: telegram could not redraw the review card for sample seq ${String(state.card.sampleSeq)} (message ${state.deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
async ignore(result, callbackId, kind, detail, reply) {
|
|
3009
|
+
this.counters.anomalies[kind] += 1;
|
|
3010
|
+
result.ignored.push({ kind, detail });
|
|
3011
|
+
this.complain(`approval: telegram ignored a callback (${kind}): ${detail}`);
|
|
3012
|
+
// A refusal toast is a courtesy, not part of the decision path: it is best
|
|
3013
|
+
// effort and its failure is not the listener's problem. Through
|
|
3014
|
+
// {@link safeAnswer} since APRV-196, so that this counts as THE ack for the
|
|
3015
|
+
// query and `handleUpdate`'s guarantee does not add a second, vaguer one on
|
|
3016
|
+
// top of the sentence this path already chose.
|
|
3017
|
+
await this.safeAnswer(callbackId, reply);
|
|
3018
|
+
}
|
|
3019
|
+
async answer(callbackId, text) {
|
|
3020
|
+
if (callbackId.length === 0)
|
|
3021
|
+
return;
|
|
3022
|
+
await this.call("answerCallbackQuery", { callback_query_id: callbackId, text });
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Answer, and never throw (APRV-196).
|
|
3026
|
+
*
|
|
3027
|
+
* A toast is a courtesy on every path, including the successful one: the
|
|
3028
|
+
* decision is already in the log by the time the ack is attempted, and an
|
|
3029
|
+
* `answerCallbackQuery` that fails (Telegram drops a query after its own
|
|
3030
|
+
* window, and a phone on a train produces plenty of late taps) must not
|
|
3031
|
+
* abandon the annotation or push the poll loop into backoff.
|
|
3032
|
+
*
|
|
3033
|
+
* The attempt is recorded either way, so {@link handleUpdate}'s guarantee
|
|
3034
|
+
* does not turn one failed ack into a second doomed call.
|
|
3035
|
+
*
|
|
3036
|
+
* **Idempotent per callback query since APRV-206.** A query that has already
|
|
3037
|
+
* been answered in this handling is not answered again: the early ack the
|
|
3038
|
+
* decision path sends is THE answer, and every later sentence — a branch's
|
|
3039
|
+
* own toast, the wrapper's fallback — becomes a no-op rather than a second
|
|
3040
|
+
* `answerCallbackQuery`. APRV-196's "exactly one per callback" therefore
|
|
3041
|
+
* holds structurally, in this one method, instead of by every branch
|
|
3042
|
+
* remembering to return.
|
|
3043
|
+
*/
|
|
3044
|
+
async safeAnswer(callbackId, text) {
|
|
3045
|
+
if (this.ack !== null && this.ack.id === callbackId) {
|
|
3046
|
+
if (this.ack.answered)
|
|
3047
|
+
return;
|
|
3048
|
+
this.ack.answered = true;
|
|
3049
|
+
}
|
|
3050
|
+
if (callbackId.length === 0)
|
|
3051
|
+
return;
|
|
3052
|
+
try {
|
|
3053
|
+
await this.answer(callbackId, text);
|
|
3054
|
+
}
|
|
3055
|
+
catch (cause) {
|
|
3056
|
+
this.complain(`approval: telegram could not answer a callback (${this.describe(cause)}) — the tap has no toast; the log is unaffected`);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* The delivery this process is holding open for an action reference, if any
|
|
3061
|
+
* (APRV-196).
|
|
3062
|
+
*
|
|
3063
|
+
* A linear walk of the delivery map rather than a second index: the map is
|
|
3064
|
+
* bounded by the pending queue and swept (APRV-135), this runs only on the
|
|
3065
|
+
* uncommon path where a nonce did not resolve, and a second map would be a
|
|
3066
|
+
* second thing to keep in step with `disarm`, `settleMember` and `sweep` —
|
|
3067
|
+
* three places where forgetting is the safety property.
|
|
3068
|
+
*
|
|
3069
|
+
* Digest members are eligible: a member's nonce is deleted the moment it is
|
|
3070
|
+
* settled, so a member still in the map is one still armed on a live message.
|
|
3071
|
+
*/
|
|
3072
|
+
liveDeliveryFor(actionRef) {
|
|
3073
|
+
for (const delivery of this.deliveries.values()) {
|
|
3074
|
+
if (delivery.actionRef === actionRef)
|
|
3075
|
+
return delivery;
|
|
3076
|
+
}
|
|
3077
|
+
return undefined;
|
|
3078
|
+
}
|
|
3079
|
+
// -------------------------------------------------------------------------
|
|
3080
|
+
// Transport
|
|
3081
|
+
// -------------------------------------------------------------------------
|
|
3082
|
+
/** Replace the token with a placeholder anywhere it appears in `text`. */
|
|
3083
|
+
redact(text) {
|
|
3084
|
+
return this.token.length === 0 ? text : text.split(this.token).join("<token redacted>");
|
|
3085
|
+
}
|
|
3086
|
+
describe(cause) {
|
|
3087
|
+
return this.redact(cause instanceof Error ? cause.message : String(cause));
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* The Bot API's own `description` for a failed response, redacted (APRV-277).
|
|
3091
|
+
*
|
|
3092
|
+
* `null` whenever there is nothing trustworthy to quote: the body could not
|
|
3093
|
+
* be read, it was not JSON, or it carried no description. Every failure mode
|
|
3094
|
+
* here is silent by design, because this runs on a path that is already
|
|
3095
|
+
* reporting a failure and a second one thrown from the diagnostic would
|
|
3096
|
+
* replace the real reason with a worse one.
|
|
3097
|
+
*/
|
|
3098
|
+
async describeFailure(response) {
|
|
3099
|
+
let body;
|
|
3100
|
+
try {
|
|
3101
|
+
body = await response.text();
|
|
3102
|
+
}
|
|
3103
|
+
catch {
|
|
3104
|
+
return null;
|
|
3105
|
+
}
|
|
3106
|
+
let parsed;
|
|
3107
|
+
try {
|
|
3108
|
+
parsed = JSON.parse(body);
|
|
3109
|
+
}
|
|
3110
|
+
catch {
|
|
3111
|
+
return null;
|
|
3112
|
+
}
|
|
3113
|
+
if (parsed === null || typeof parsed !== "object")
|
|
3114
|
+
return null;
|
|
3115
|
+
const description = parsed["description"];
|
|
3116
|
+
if (typeof description !== "string" || description.length === 0)
|
|
3117
|
+
return null;
|
|
3118
|
+
return this.redact(description);
|
|
3119
|
+
}
|
|
3120
|
+
/**
|
|
3121
|
+
* One Bot API call.
|
|
3122
|
+
*
|
|
3123
|
+
* The token is in the URL, which is how the Bot API works — there is no
|
|
3124
|
+
* header form. It is therefore never put in a message body, an error string,
|
|
3125
|
+
* or a log line: {@link redact} scrubs everything that leaves this class, and
|
|
3126
|
+
* the test suite scans every request body and every log byte for it.
|
|
3127
|
+
*/
|
|
3128
|
+
async call(method, body, timeoutMs = this.requestTimeoutMs ?? 30_000) {
|
|
3129
|
+
const controller = new AbortController();
|
|
3130
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3131
|
+
if (method === "getUpdates")
|
|
3132
|
+
this.inFlight = controller;
|
|
3133
|
+
let raw;
|
|
3134
|
+
try {
|
|
3135
|
+
const response = await this.fetchImpl(`${this.apiBase}/bot${this.token}/${method}`, {
|
|
3136
|
+
method: "POST",
|
|
3137
|
+
headers: { "content-type": "application/json" },
|
|
3138
|
+
body: JSON.stringify(body),
|
|
3139
|
+
signal: controller.signal,
|
|
3140
|
+
});
|
|
3141
|
+
if (!response.ok) {
|
|
3142
|
+
// APRV-277. The Bot API puts its reason in the error body's
|
|
3143
|
+
// `description`, and dropping it made every failure read as a bare
|
|
3144
|
+
// status: an edit that changed nothing and an edit into a chat the bot
|
|
3145
|
+
// was thrown out of were the same "HTTP 400" on the operator's
|
|
3146
|
+
// terminal. Read best effort — a status is still worth reporting when
|
|
3147
|
+
// the body is missing, truncated, or not JSON at all.
|
|
3148
|
+
const description = await this.describeFailure(response);
|
|
3149
|
+
throw new TelegramApiError(description === null
|
|
3150
|
+
? `${method}: HTTP ${response.status}`
|
|
3151
|
+
: `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
|
|
3152
|
+
}
|
|
3153
|
+
raw = await response.text();
|
|
3154
|
+
}
|
|
3155
|
+
catch (cause) {
|
|
3156
|
+
if (cause instanceof TelegramApiError)
|
|
3157
|
+
throw cause;
|
|
3158
|
+
throw new TelegramApiError(`${method}: ${this.describe(cause)}`, method);
|
|
3159
|
+
}
|
|
3160
|
+
finally {
|
|
3161
|
+
clearTimeout(timer);
|
|
3162
|
+
if (method === "getUpdates")
|
|
3163
|
+
this.inFlight = null;
|
|
3164
|
+
}
|
|
3165
|
+
let parsed;
|
|
3166
|
+
try {
|
|
3167
|
+
parsed = JSON.parse(raw);
|
|
3168
|
+
}
|
|
3169
|
+
catch {
|
|
3170
|
+
throw new TelegramApiError(`${method}: response was not JSON`, method);
|
|
3171
|
+
}
|
|
3172
|
+
const envelope = (parsed ?? {});
|
|
3173
|
+
if (envelope["ok"] !== true) {
|
|
3174
|
+
const said = envelope["description"];
|
|
3175
|
+
const description = typeof said === "string" && said.length > 0 ? this.redact(said) : null;
|
|
3176
|
+
// Unchanged wording: anything the Bot API put here is still shown, and an
|
|
3177
|
+
// absent one is still "no description". `description` is the narrower
|
|
3178
|
+
// field — a non-empty string only — because that is what a caller is
|
|
3179
|
+
// entitled to match on (APRV-277).
|
|
3180
|
+
const shown = said === undefined || said === null ? "no description" : this.redact(String(said));
|
|
3181
|
+
throw new TelegramApiError(`${method}: the Bot API refused (${shown})`, method,
|
|
3182
|
+
// No HTTP status: this envelope arrived on a 2xx. The description is
|
|
3183
|
+
// carried anyway so a caller reads the same field whichever shape the
|
|
3184
|
+
// failure took (APRV-277).
|
|
3185
|
+
null, description);
|
|
3186
|
+
}
|
|
3187
|
+
return envelope["result"];
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
//# sourceMappingURL=telegram.js.map
|