approval-md 0.2.0 → 0.4.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/README.md +85 -877
- package/SPEC.md +150 -19
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +53 -1
- package/dist/src/channels/contract.js +304 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +394 -12
- package/dist/src/channels/telegram.js +621 -68
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +564 -34
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +177 -13
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +763 -28
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +10 -1
- package/dist/src/cli/channel.js +14 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +41 -6
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +1322 -122
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +366 -22
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +14 -2
- package/dist/src/cli/help.js +395 -82
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +828 -4
- package/dist/src/cli/hook.js +2972 -142
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +56 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +7 -1
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +134 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +510 -47
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +234 -2
- package/dist/src/core/attest.js +342 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +73 -3
- package/dist/src/core/audit.js +65 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +205 -0
- package/dist/src/core/command-class.js +1433 -28
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +392 -6
- package/dist/src/core/gate.js +852 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +181 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +89 -22
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +17 -1
- package/dist/src/core/policy-explain.js +48 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +65 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +81 -0
- package/dist/src/core/policy-match.js +87 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +75 -0
- package/dist/src/core/policy-proposal.js +110 -4
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +182 -0
- package/dist/src/core/read-scope.js +256 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +501 -0
- package/dist/src/core/sender-identity.js +609 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +307 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +30 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +2563 -73
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +6 -2
- package/schema/event.schema.json +595 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +100 -4
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -34,22 +34,59 @@
|
|
|
34
34
|
* notify, long-poll, callbacks, failure modes — against a local mock Bot API
|
|
35
35
|
* server and never touches the real network.
|
|
36
36
|
*
|
|
37
|
+
* ## Two transports, one update path (APRV-424)
|
|
38
|
+
*
|
|
39
|
+
* An update reaches this channel either because it long-polled for it
|
|
40
|
+
* ({@link TelegramChannel.pollOnce}) or because Telegram posted it to a URL the
|
|
41
|
+
* runtime registered ({@link TelegramChannel.deliverUpdate}, fed by
|
|
42
|
+
* `channels/telegram-webhook.ts`). That is the WHOLE of the difference. Both
|
|
43
|
+
* entry points hand the update to the same {@link TelegramChannel.handleUpdate}
|
|
44
|
+
* and therefore to the same chat check, the same `senderOf`, the same
|
|
45
|
+
* resolution ladder, the same `onDecision` handler and the same
|
|
46
|
+
* annotate-after-decision edit; a decision does not know which door it came
|
|
47
|
+
* through, and there is no second copy of any of it to drift.
|
|
48
|
+
*
|
|
49
|
+
* The two are mutually exclusive per bot, and the Bot API says so first:
|
|
50
|
+
* `getUpdates` is refused outright while a webhook is set.
|
|
51
|
+
* {@link TelegramChannel.claimTransport} refuses the in-process form of the
|
|
52
|
+
* mistake, and the verbs above ask `getWebhookInfo` and the ownership registry
|
|
53
|
+
* about the cross-process one.
|
|
54
|
+
*
|
|
37
55
|
* ## Config-declared identity — SPEC.md §11
|
|
38
56
|
*
|
|
39
57
|
* > Human identity in v0.1 is config-declared (an environment variable or
|
|
40
58
|
* > flag); the trust boundary is the local machine, and anyone who can set that
|
|
41
59
|
* > configuration and write to the log is inside it.
|
|
42
60
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
61
|
+
* What this channel authenticates, exactly: that the callback arrived **from
|
|
62
|
+
* the configured chat id**, and which **Telegram account** the Bot API
|
|
63
|
+
* attributes the tap to (`callback_query.from.id`). It authenticates no
|
|
64
|
+
* person, because no transport can: an account id is evidence about an
|
|
65
|
+
* account.
|
|
66
|
+
*
|
|
67
|
+
* What is done with the second fact is the operator's to decide, and since
|
|
68
|
+
* APRV-324 there are two settings:
|
|
69
|
+
*
|
|
70
|
+
* - **No `senders` block in the policy.** Nothing changes from every build
|
|
71
|
+
* before it. The decision is recorded against the human actor the *runtime*
|
|
72
|
+
* was configured with (`APPROVAL_HUMAN` / `--as`), so the guarantee is
|
|
73
|
+
* "someone with access to the configured chat, on a runtime configured by
|
|
74
|
+
* someone with local control, tapped Approve" — not "alice tapped Approve".
|
|
75
|
+
* Anyone in that chat can approve as the configured actor. Use a private chat
|
|
76
|
+
* with the bot, and treat the chat's membership as part of the trust
|
|
77
|
+
* boundary.
|
|
78
|
+
* - **A `senders` block mapping account ids to approvers.** The decision is
|
|
79
|
+
* recorded against the person the operator attested that account to, and a
|
|
80
|
+
* tap from an account the policy does not name is REFUSED rather than
|
|
81
|
+
* recorded under the listener's identity. The guarantee becomes "the account
|
|
82
|
+
* the operator attested to alice tapped Approve", which is a statement about
|
|
83
|
+
* Telegram's session handling and the operator's assertion, and still not a
|
|
84
|
+
* proof of personhood. Cryptographic identity is future work and is not a
|
|
85
|
+
* v0.1 claim.
|
|
86
|
+
*
|
|
87
|
+
* The channel itself resolves nothing either way. It reports the account it
|
|
88
|
+
* saw; `channels/contract.ts` resolves it against the attested policy, and
|
|
89
|
+
* `core/gate.ts` decides. See `design/channel-sender-identity.md`.
|
|
53
90
|
*
|
|
54
91
|
* ## Formatting: HTML, not MarkdownV2 — a deliberate choice
|
|
55
92
|
*
|
|
@@ -370,6 +407,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
|
370
407
|
export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
371
408
|
/** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
|
|
372
409
|
export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
|
|
410
|
+
/**
|
|
411
|
+
* How many refused cards' nonces a channel remembers as dead (APRV-442).
|
|
412
|
+
*
|
|
413
|
+
* A bound on memory and nothing else: a nonce that falls off the end reaches
|
|
414
|
+
* the action-reference fallback, and the gate judges that tap in full.
|
|
415
|
+
*/
|
|
416
|
+
export const TELEGRAM_REFUSED_NONCE_CAP = 1024;
|
|
373
417
|
// ---------------------------------------------------------------------------
|
|
374
418
|
// Anomalies
|
|
375
419
|
// ---------------------------------------------------------------------------
|
|
@@ -1206,6 +1250,42 @@ export function parseCheckpointCallback(data) {
|
|
|
1206
1250
|
return null;
|
|
1207
1251
|
return { sign: verb === "k", nonce };
|
|
1208
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The account the Bot API attributes a callback to (APRV-324, amended SPEC.md
|
|
1255
|
+
* §10.3).
|
|
1256
|
+
*
|
|
1257
|
+
* `callback_query.from.id` and nothing else. Telegram assembles the `from`
|
|
1258
|
+
* object itself, from the session the tap arrived on, which is why it is the
|
|
1259
|
+
* one field on an update this channel treats as evidence about a person. Three
|
|
1260
|
+
* neighbours are deliberately not read:
|
|
1261
|
+
*
|
|
1262
|
+
* - **`from.username`.** Mutable and reusable, so a mapping keyed on it would
|
|
1263
|
+
* hand an identity over with a handle. Never a key here and never recorded
|
|
1264
|
+
* (`core/sender-identity.ts` states the argument in full).
|
|
1265
|
+
* - **`message.from`.** The author of the message the button sits on — this bot
|
|
1266
|
+
* — rather than the person who pressed it.
|
|
1267
|
+
* - **`data`, and any text.** What the sender says about themselves, which
|
|
1268
|
+
* §11.1 invariant 4 says may raise scrutiny and never lower it. A callback
|
|
1269
|
+
* whose payload names a user id names it about itself; the id here comes from
|
|
1270
|
+
* the transport, and the two disagreeing changes nothing.
|
|
1271
|
+
*
|
|
1272
|
+
* `undefined` when the update carries no usable id: a sender this channel could
|
|
1273
|
+
* not read is not a sender it may guess at, and the decision then travels with
|
|
1274
|
+
* none, which the contract reads as today's configured attribution.
|
|
1275
|
+
*/
|
|
1276
|
+
export function senderOf(query, channel) {
|
|
1277
|
+
const from = query["from"];
|
|
1278
|
+
if (typeof from !== "object" || from === null)
|
|
1279
|
+
return undefined;
|
|
1280
|
+
const id = from["id"];
|
|
1281
|
+
// Telegram sends a JSON number; a string is accepted for the same reason the
|
|
1282
|
+
// chat check accepts one, and nothing else is: an object or an array here is
|
|
1283
|
+
// a shape this channel does not understand rather than an id to stringify.
|
|
1284
|
+
if (typeof id !== "number" && typeof id !== "string")
|
|
1285
|
+
return undefined;
|
|
1286
|
+
const text = String(id);
|
|
1287
|
+
return text.length === 0 ? undefined : { channel, id: text };
|
|
1288
|
+
}
|
|
1209
1289
|
const CALLBACK_VERBS = {
|
|
1210
1290
|
g: { decision: "grant", scope: "one" },
|
|
1211
1291
|
r: { decision: "reject", scope: "one" },
|
|
@@ -1494,6 +1574,60 @@ export function isMessageNotModified(cause) {
|
|
|
1494
1574
|
cause.description !== null &&
|
|
1495
1575
|
TELEGRAM_NOT_MODIFIED.test(cause.description));
|
|
1496
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Telegram's wording for "somebody else is already long-polling this bot".
|
|
1579
|
+
*
|
|
1580
|
+
* Matched on the description as well as the status, for the same reason
|
|
1581
|
+
* {@link TELEGRAM_NOT_MODIFIED} is: 409 is a conflict, and the conflict this
|
|
1582
|
+
* project cares about is specifically the terminated-by-other-getUpdates one.
|
|
1583
|
+
*/
|
|
1584
|
+
const TELEGRAM_POLL_CONFLICT = /terminated by other getupdates|make sure that only one bot/iu;
|
|
1585
|
+
/**
|
|
1586
|
+
* Whether a failed poll is the Bot API saying another process holds this bot
|
|
1587
|
+
* (APRV-390).
|
|
1588
|
+
*
|
|
1589
|
+
* It is the one poll failure that is not transient and not the network's
|
|
1590
|
+
* fault: retrying it forever produces an identical line every few seconds and
|
|
1591
|
+
* never recovers, because the other poller is not going to stop. {@link
|
|
1592
|
+
* TelegramChannel.listen} reports it once, with whatever the caller knows
|
|
1593
|
+
* about who the other process is, and then stops repeating itself.
|
|
1594
|
+
*/
|
|
1595
|
+
export function isPollConflict(cause) {
|
|
1596
|
+
return (cause instanceof TelegramApiError &&
|
|
1597
|
+
cause.method === "getUpdates" &&
|
|
1598
|
+
(cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
|
|
1599
|
+
}
|
|
1600
|
+
/** A fresh, empty batch report. One shape for both transports (APRV-424). */
|
|
1601
|
+
function emptyPollResult() {
|
|
1602
|
+
return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [] };
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
|
|
1606
|
+
*
|
|
1607
|
+
* One code, because there is one fact: this channel is already receiving
|
|
1608
|
+
* updates by the other transport.
|
|
1609
|
+
*/
|
|
1610
|
+
export const TELEGRAM_TRANSPORT_REFUSAL_CODES = ["transport-conflict"];
|
|
1611
|
+
/**
|
|
1612
|
+
* The in-process half of "long-poll and webhook are mutually exclusive"
|
|
1613
|
+
* (APRV-424).
|
|
1614
|
+
*
|
|
1615
|
+
* It is a belt on the braces and says so. The exclusion that matters is per
|
|
1616
|
+
* BOT and lives above this class, where a process can ask the Bot API
|
|
1617
|
+
* (`getWebhookInfo`) and the per-machine ownership registry who else is
|
|
1618
|
+
* receiving this bot's updates; a channel object knows only about itself.
|
|
1619
|
+
* What this rules out is the one failure a caller could cause here and
|
|
1620
|
+
* nowhere else: feeding one channel instance from both a poll loop and an
|
|
1621
|
+
* HTTP handler, where `handleUpdate`'s single ack slot and the delivery maps
|
|
1622
|
+
* would be written by two arrivals nobody ordered.
|
|
1623
|
+
*/
|
|
1624
|
+
export class TelegramTransportError extends Error {
|
|
1625
|
+
code = "transport-conflict";
|
|
1626
|
+
constructor(message) {
|
|
1627
|
+
super(message);
|
|
1628
|
+
this.name = "TelegramTransportError";
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1497
1631
|
export class TelegramChannel {
|
|
1498
1632
|
name = "telegram";
|
|
1499
1633
|
token;
|
|
@@ -1569,11 +1703,60 @@ export class TelegramChannel {
|
|
|
1569
1703
|
digests = new Map();
|
|
1570
1704
|
/** "All" nonce -> the digest message it was issued for. */
|
|
1571
1705
|
allNonces = new Map();
|
|
1706
|
+
/**
|
|
1707
|
+
* Action keys whose card this process disarmed on a tap the gate did NOT
|
|
1708
|
+
* settle (APRV-442): a refusal (`sender-unmapped`, a budget, an unattested
|
|
1709
|
+
* policy, anything `ok: false`) or a handler that threw. Drained by
|
|
1710
|
+
* {@link takeReleased}.
|
|
1711
|
+
*
|
|
1712
|
+
* The disarm is right and stays, and {@link refusedNonces} keeps it final:
|
|
1713
|
+
* the refused card's bytes resolve to nothing, not even through the
|
|
1714
|
+
* action-reference fallback to the card that replaces it. What the disarm
|
|
1715
|
+
* must not do is leave the listener believing the REQUEST is on the
|
|
1716
|
+
* approver's phone, which is what `DispatchState.delivered` goes on saying
|
|
1717
|
+
* until something tells it otherwise. This set is that something: it names
|
|
1718
|
+
* a card, never an outcome, and the listener re-derives from the verified
|
|
1719
|
+
* log whether the request is still pending before it offers a new card.
|
|
1720
|
+
* Nothing here decides, and losing it to a restart costs nothing, because a
|
|
1721
|
+
* restarted listener re-sends everything pending anyway.
|
|
1722
|
+
*/
|
|
1723
|
+
released = new Set();
|
|
1724
|
+
/**
|
|
1725
|
+
* Nonces of cards a refused or failed tap disarmed (APRV-442), oldest first,
|
|
1726
|
+
* capped at {@link TELEGRAM_REFUSED_NONCE_CAP}.
|
|
1727
|
+
*
|
|
1728
|
+
* Without this, a released request's fresh card would give the refused
|
|
1729
|
+
* card's bytes somewhere to go: the nonce misses, the action reference
|
|
1730
|
+
* resolves to the new live card, and a Telegram redelivery of the refused
|
|
1731
|
+
* tap (a webhook retry) or a client replaying the removed buttons would be
|
|
1732
|
+
* refused again, append again, disarm the new card and cause another
|
|
1733
|
+
* re-send, once per replay. A nonce in this set never takes the fallback, so
|
|
1734
|
+
* a refused card stays exactly as dead as it was before APRV-442 and only
|
|
1735
|
+
* the REQUEST is offered again. Forgetting an entry past the cap degrades to
|
|
1736
|
+
* the fallback, whose tap the gate still judges in full: volume, never a
|
|
1737
|
+
* decision.
|
|
1738
|
+
*/
|
|
1739
|
+
refusedNonces = new Set();
|
|
1572
1740
|
rendered = [];
|
|
1573
1741
|
offset = 0;
|
|
1574
1742
|
counter = 0;
|
|
1575
1743
|
stopped = false;
|
|
1576
1744
|
inFlight = null;
|
|
1745
|
+
/**
|
|
1746
|
+
* The transport this channel has started receiving updates by, or `null`
|
|
1747
|
+
* before the first one (APRV-424). Sticky for the life of the object: a
|
|
1748
|
+
* process that stopped polling has not stopped being a poller, and the
|
|
1749
|
+
* question this answers is which arrival path owns the maps below.
|
|
1750
|
+
*/
|
|
1751
|
+
transport = null;
|
|
1752
|
+
/**
|
|
1753
|
+
* Everything {@link redact} scrubs out of anything that leaves this class.
|
|
1754
|
+
*
|
|
1755
|
+
* The bot token from construction, plus any secret a later call was handed:
|
|
1756
|
+
* {@link registerWebhook} adds the `secret_token` before it sends it, so a
|
|
1757
|
+
* Bot API error quoting the request cannot put it on an operator's terminal.
|
|
1758
|
+
*/
|
|
1759
|
+
secrets;
|
|
1577
1760
|
counters = {
|
|
1578
1761
|
notified: 0,
|
|
1579
1762
|
updates: 0,
|
|
@@ -1593,6 +1776,8 @@ export class TelegramChannel {
|
|
|
1593
1776
|
};
|
|
1594
1777
|
constructor(config) {
|
|
1595
1778
|
this.token = config.token;
|
|
1779
|
+
this.secrets =
|
|
1780
|
+
config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
|
|
1596
1781
|
this.chatId = String(config.chatId);
|
|
1597
1782
|
this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
|
|
1598
1783
|
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
@@ -1855,8 +2040,15 @@ export class TelegramChannel {
|
|
|
1855
2040
|
async deliverDigest(members, batchDeliveryId, stale = null) {
|
|
1856
2041
|
const allNonce = this.makeNonce();
|
|
1857
2042
|
const deliveredAtMs = this.now();
|
|
2043
|
+
// The digest's window is the widest member's: it may be forgotten only when
|
|
2044
|
+
// every member is past deciding, and a member nothing bounds keeps it.
|
|
2045
|
+
const windows = members.map((member) => this.retentionWindowMs(member));
|
|
2046
|
+
const windowMs = windows.includes(null)
|
|
2047
|
+
? null
|
|
2048
|
+
: Math.max(...windows.filter((window) => window !== null));
|
|
1858
2049
|
const state = {
|
|
1859
2050
|
deliveredAtMs,
|
|
2051
|
+
windowMs,
|
|
1860
2052
|
// Assigned once the message exists; nothing consults it before then.
|
|
1861
2053
|
deliveryId: "",
|
|
1862
2054
|
batchDeliveryId,
|
|
@@ -1916,13 +2108,14 @@ export class TelegramChannel {
|
|
|
1916
2108
|
state.deliveryId = deliveryId;
|
|
1917
2109
|
// Armed only now, and all at once: until the message with the buttons on it
|
|
1918
2110
|
// exists there is nothing a callback could legitimately answer.
|
|
1919
|
-
for (const member of state.members) {
|
|
2111
|
+
for (const [index, member] of state.members.entries()) {
|
|
1920
2112
|
this.deliveries.set(member.nonce, {
|
|
1921
2113
|
actionKey: member.actionKey,
|
|
1922
2114
|
actionRef: actionRefOf(member.actionKey),
|
|
1923
2115
|
deliveryId,
|
|
1924
2116
|
batchDeliveryId,
|
|
1925
2117
|
deliveredAtMs,
|
|
2118
|
+
windowMs: windows[index] ?? null,
|
|
1926
2119
|
});
|
|
1927
2120
|
}
|
|
1928
2121
|
this.digests.set(deliveryId, state);
|
|
@@ -2014,6 +2207,7 @@ export class TelegramChannel {
|
|
|
2014
2207
|
actionRef: actionRefOf(actionKey),
|
|
2015
2208
|
deliveryId: sent.deliveryId,
|
|
2016
2209
|
deliveredAtMs: this.now(),
|
|
2210
|
+
windowMs: this.retentionWindowMs(request),
|
|
2017
2211
|
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2018
2212
|
});
|
|
2019
2213
|
return {
|
|
@@ -2054,6 +2248,31 @@ export class TelegramChannel {
|
|
|
2054
2248
|
}
|
|
2055
2249
|
return actionKey;
|
|
2056
2250
|
}
|
|
2251
|
+
/**
|
|
2252
|
+
* How long after delivery the request behind a prompt can still be decided,
|
|
2253
|
+
* on this process's clock, or `null` when nothing bounds it (APRV-423, second
|
|
2254
|
+
* review pass).
|
|
2255
|
+
*
|
|
2256
|
+
* The SHORTER of two bounds, each of which is at or past the gate's own
|
|
2257
|
+
* lapse, so the sweep never forgets a button a decision could still reach:
|
|
2258
|
+
*
|
|
2259
|
+
* - the request's `ttl_remaining_ms`, computed by the listener from the
|
|
2260
|
+
* verified log against the window the gate judges by (the policy's TTL
|
|
2261
|
+
* narrowed by the harness cap the requesting hook declared). It was
|
|
2262
|
+
* measured at tagging time, and delivery is at or after tagging, so
|
|
2263
|
+
* counting it from delivery ends at or after the real lapse;
|
|
2264
|
+
* - the policy's own TTL ({@link TelegramConfig.approvalTtlMs}), counted from
|
|
2265
|
+
* delivery, which is at or after the `approval.requested` it really runs
|
|
2266
|
+
* from. This was the only bound before APRV-423 and it stays as one.
|
|
2267
|
+
*
|
|
2268
|
+
* A request neither bounds is decidable forever, and its entry is dropped
|
|
2269
|
+
* only on an observed settlement. This channel still reads no policy and no
|
|
2270
|
+
* log: both numbers arrive computed, by the verb and by the tagger.
|
|
2271
|
+
*/
|
|
2272
|
+
retentionWindowMs(request) {
|
|
2273
|
+
const bounds = [request.ttl_remaining_ms.value, this.approvalTtlMs].filter((bound) => bound !== null);
|
|
2274
|
+
return bounds.length === 0 ? null : Math.min(...bounds);
|
|
2275
|
+
}
|
|
2057
2276
|
/**
|
|
2058
2277
|
* Drop the delivery bookkeeping no callback can still be honoured against
|
|
2059
2278
|
* (APRV-135).
|
|
@@ -2088,17 +2307,23 @@ export class TelegramChannel {
|
|
|
2088
2307
|
*/
|
|
2089
2308
|
sweep(nowMs = this.now()) {
|
|
2090
2309
|
this.lastSweepMs = nowMs;
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
//
|
|
2094
|
-
//
|
|
2095
|
-
|
|
2096
|
-
const
|
|
2310
|
+
// APRV-423 (second review pass): both halves read the ENTRY's own window,
|
|
2311
|
+
// which is the effective one the gate judges its request by, and fall back
|
|
2312
|
+
// to the policy's TTL and then the default only for an entry that carries
|
|
2313
|
+
// none (a review card, which has no request).
|
|
2314
|
+
const retentionOf = (windowMs) => windowMs ?? this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
|
|
2315
|
+
const expired = (deliveredAtMs, windowMs) => nowMs - deliveredAtMs >= retentionOf(windowMs);
|
|
2316
|
+
// Past its window the gate refuses every decision on the request, so it is
|
|
2317
|
+
// terminal whether or not this process saw it settle. With nothing bounding
|
|
2318
|
+
// it nothing expires, and only an observed settlement makes it droppable.
|
|
2319
|
+
const lapsed = (deliveredAtMs, windowMs) => windowMs !== null && nowMs - deliveredAtMs >= windowMs;
|
|
2097
2320
|
let digests = 0;
|
|
2098
2321
|
for (const [deliveryId, digest] of this.digests) {
|
|
2099
2322
|
const terminal = digest.members.every((member) => member.settled !== null);
|
|
2100
|
-
if (!(terminal || lapsed(digest.deliveredAtMs
|
|
2323
|
+
if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
|
|
2324
|
+
!expired(digest.deliveredAtMs, digest.windowMs)) {
|
|
2101
2325
|
continue;
|
|
2326
|
+
}
|
|
2102
2327
|
this.digests.delete(deliveryId);
|
|
2103
2328
|
this.allNonces.delete(digest.allNonce);
|
|
2104
2329
|
digests += 1;
|
|
@@ -2110,8 +2335,10 @@ export class TelegramChannel {
|
|
|
2110
2335
|
// decides, and it was just judged above.
|
|
2111
2336
|
if (this.digests.has(delivery.deliveryId))
|
|
2112
2337
|
continue;
|
|
2113
|
-
if (!lapsed(delivery.deliveredAtMs
|
|
2338
|
+
if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
|
|
2339
|
+
!expired(delivery.deliveredAtMs, delivery.windowMs)) {
|
|
2114
2340
|
continue;
|
|
2341
|
+
}
|
|
2115
2342
|
this.deliveries.delete(nonce);
|
|
2116
2343
|
deliveries += 1;
|
|
2117
2344
|
}
|
|
@@ -2123,7 +2350,7 @@ export class TelegramChannel {
|
|
|
2123
2350
|
// this map holds, so keeping the buttons alive costs a map entry and
|
|
2124
2351
|
// dropping them early would cost a human their thumb.
|
|
2125
2352
|
for (const [deliveryId, card] of this.reviewCards) {
|
|
2126
|
-
if (card.settled === null || !expired(card.deliveredAtMs))
|
|
2353
|
+
if (card.settled === null || !expired(card.deliveredAtMs, null))
|
|
2127
2354
|
continue;
|
|
2128
2355
|
this.reviewCards.delete(deliveryId);
|
|
2129
2356
|
this.reviewNonces.delete(card.nonce);
|
|
@@ -2233,6 +2460,51 @@ export class TelegramChannel {
|
|
|
2233
2460
|
async retract(deliveryId, reason, actionKey) {
|
|
2234
2461
|
await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
|
|
2235
2462
|
}
|
|
2463
|
+
/**
|
|
2464
|
+
* The action keys whose card a refused or failed tap disarmed since the last
|
|
2465
|
+
* call, and forget them (APRV-442).
|
|
2466
|
+
*
|
|
2467
|
+
* The listener's dispatch cycle calls this once per cycle and offers a fresh
|
|
2468
|
+
* card for every key the verified log still calls pending. It is a statement
|
|
2469
|
+
* about this process's cards and nothing else: it carries no decision, no
|
|
2470
|
+
* sender and no outcome, and a key in it that the log has since settled is
|
|
2471
|
+
* simply not offered.
|
|
2472
|
+
*/
|
|
2473
|
+
takeReleased() {
|
|
2474
|
+
const keys = [...this.released];
|
|
2475
|
+
this.released.clear();
|
|
2476
|
+
return keys;
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Mark `delivery`'s card dead and its request released, BEFORE the
|
|
2480
|
+
* annotation that disarms it (APRV-442).
|
|
2481
|
+
*
|
|
2482
|
+
* Every nonce still armed for this request on this message is recorded in
|
|
2483
|
+
* {@link refusedNonces}, together with the nonce the tap carried (which
|
|
2484
|
+
* differs when the tap came from an earlier copy through the fallback), so
|
|
2485
|
+
* none of those bytes can later resolve to the fresh card. On a digest only
|
|
2486
|
+
* this member's nonce is taken: its siblings stay armed on the digest.
|
|
2487
|
+
*/
|
|
2488
|
+
release(delivery, tapNonce) {
|
|
2489
|
+
const dead = [tapNonce];
|
|
2490
|
+
for (const [nonce, entry] of this.deliveries) {
|
|
2491
|
+
if (entry.deliveryId === delivery.deliveryId && entry.actionKey === delivery.actionKey) {
|
|
2492
|
+
dead.push(nonce);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
for (const nonce of dead) {
|
|
2496
|
+
// Re-inserted so a nonce marked again counts as the newest.
|
|
2497
|
+
this.refusedNonces.delete(nonce);
|
|
2498
|
+
this.refusedNonces.add(nonce);
|
|
2499
|
+
}
|
|
2500
|
+
while (this.refusedNonces.size > TELEGRAM_REFUSED_NONCE_CAP) {
|
|
2501
|
+
const oldest = this.refusedNonces.values().next().value;
|
|
2502
|
+
if (oldest === undefined)
|
|
2503
|
+
break;
|
|
2504
|
+
this.refusedNonces.delete(oldest);
|
|
2505
|
+
}
|
|
2506
|
+
this.released.add(delivery.actionKey);
|
|
2507
|
+
}
|
|
2236
2508
|
/**
|
|
2237
2509
|
* Send one plain message that carries no question (APRV-196).
|
|
2238
2510
|
*
|
|
@@ -2255,6 +2527,32 @@ export class TelegramChannel {
|
|
|
2255
2527
|
return String(result.message_id);
|
|
2256
2528
|
}
|
|
2257
2529
|
// -------------------------------------------------------------------------
|
|
2530
|
+
// Identity
|
|
2531
|
+
// -------------------------------------------------------------------------
|
|
2532
|
+
/**
|
|
2533
|
+
* Which bot this token is, from one `getMe` (APRV-390).
|
|
2534
|
+
*
|
|
2535
|
+
* `getMe` is the only Bot API call that answers it, and it is the call
|
|
2536
|
+
* `approval doctor` and `approval setup channel telegram` already make for
|
|
2537
|
+
* the same reason: it mutates nothing, sends nothing, and acknowledges
|
|
2538
|
+
* nothing. In particular it is NOT `getUpdates`, so asking who this bot is
|
|
2539
|
+
* consumes no update and steals no pending tap from a listener that is
|
|
2540
|
+
* already running — which matters here above all, because the answer is
|
|
2541
|
+
* what decides whether this process is allowed to poll at all.
|
|
2542
|
+
*
|
|
2543
|
+
* Throws {@link TelegramApiError} like every other call; the caller decides
|
|
2544
|
+
* whether an unreachable Bot API is a refusal or a shrug.
|
|
2545
|
+
*/
|
|
2546
|
+
async identify() {
|
|
2547
|
+
const result = await this.call("getMe", {});
|
|
2548
|
+
const id = result["id"];
|
|
2549
|
+
const username = result["username"];
|
|
2550
|
+
return {
|
|
2551
|
+
id: typeof id === "number" || typeof id === "string" ? String(id) : "",
|
|
2552
|
+
username: typeof username === "string" ? `@${username}` : "the bot",
|
|
2553
|
+
};
|
|
2554
|
+
}
|
|
2555
|
+
// -------------------------------------------------------------------------
|
|
2258
2556
|
// Long polling
|
|
2259
2557
|
// -------------------------------------------------------------------------
|
|
2260
2558
|
/**
|
|
@@ -2275,8 +2573,19 @@ export class TelegramChannel {
|
|
|
2275
2573
|
* decision", not "deliver once at startup, then wait forever".
|
|
2276
2574
|
*/
|
|
2277
2575
|
async listen(options = {}) {
|
|
2576
|
+
this.claimOrThrow("poll");
|
|
2278
2577
|
this.stopped = false;
|
|
2279
2578
|
let backoff = this.backoffMs;
|
|
2579
|
+
/**
|
|
2580
|
+
* APRV-390. The last failure reported, and how many identical ones have
|
|
2581
|
+
* been swallowed since. A 409 does not recover: the other poller is not
|
|
2582
|
+
* going to stop because this one asked again, so the pre-APRV-390 loop
|
|
2583
|
+
* printed the same sentence every few seconds until somebody read the
|
|
2584
|
+
* terminal. The retry itself stays — a listener that stops listening is
|
|
2585
|
+
* the failure this loop rules out — and only the COMPLAINING is collapsed.
|
|
2586
|
+
*/
|
|
2587
|
+
let lastComplaint = null;
|
|
2588
|
+
let repeats = 0;
|
|
2280
2589
|
while (!this.stopped) {
|
|
2281
2590
|
try {
|
|
2282
2591
|
if (options.beforePoll !== undefined) {
|
|
@@ -2286,6 +2595,11 @@ export class TelegramChannel {
|
|
|
2286
2595
|
}
|
|
2287
2596
|
await this.pollOnce();
|
|
2288
2597
|
backoff = this.backoffMs;
|
|
2598
|
+
if (repeats > 0) {
|
|
2599
|
+
this.complain(`approval: telegram getUpdates recovered after ${String(repeats)} further identical failure(s)`);
|
|
2600
|
+
}
|
|
2601
|
+
lastComplaint = null;
|
|
2602
|
+
repeats = 0;
|
|
2289
2603
|
if (options.once === true)
|
|
2290
2604
|
return;
|
|
2291
2605
|
}
|
|
@@ -2293,7 +2607,26 @@ export class TelegramChannel {
|
|
|
2293
2607
|
if (this.stopped)
|
|
2294
2608
|
return;
|
|
2295
2609
|
this.counters.pollErrors += 1;
|
|
2296
|
-
|
|
2610
|
+
const conflict = isPollConflict(cause);
|
|
2611
|
+
// The 409's own sentence, said once. It names the fact ("another
|
|
2612
|
+
// process is polling this bot") rather than the HTTP status, because
|
|
2613
|
+
// the status is what a reader was already staring at, and it carries
|
|
2614
|
+
// whatever the caller knows about which two gates are involved.
|
|
2615
|
+
const advice = conflict ? (options.conflictAdvice?.() ?? null) : null;
|
|
2616
|
+
const message = conflict
|
|
2617
|
+
? `approval: telegram getUpdates: another process is polling this bot${advice === null ? "" : ` — ${advice}`}; this listener keeps retrying and will not receive a tap until the other one stops`
|
|
2618
|
+
: `approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`;
|
|
2619
|
+
if (message === lastComplaint) {
|
|
2620
|
+
repeats += 1;
|
|
2621
|
+
}
|
|
2622
|
+
else {
|
|
2623
|
+
if (repeats > 0) {
|
|
2624
|
+
this.complain(`approval: telegram getUpdates: the previous line repeated ${String(repeats)} more time(s)`);
|
|
2625
|
+
}
|
|
2626
|
+
this.complain(message);
|
|
2627
|
+
lastComplaint = message;
|
|
2628
|
+
repeats = 0;
|
|
2629
|
+
}
|
|
2297
2630
|
await sleep(backoff);
|
|
2298
2631
|
backoff = Math.min(backoff * 2, this.maxBackoffMs);
|
|
2299
2632
|
}
|
|
@@ -2309,6 +2642,7 @@ export class TelegramChannel {
|
|
|
2309
2642
|
* what {@link listen} catches and retries.
|
|
2310
2643
|
*/
|
|
2311
2644
|
async pollOnce() {
|
|
2645
|
+
this.claimOrThrow("poll");
|
|
2312
2646
|
// APRV-135. Before the long poll, not after: this is where the loop is
|
|
2313
2647
|
// about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
|
|
2314
2648
|
// the block would be a sweep that never runs on a quiet chat. Rate-limited
|
|
@@ -2320,35 +2654,163 @@ export class TelegramChannel {
|
|
|
2320
2654
|
const updates = await this.call("getUpdates", {
|
|
2321
2655
|
offset: this.offset,
|
|
2322
2656
|
timeout: this.pollTimeoutSeconds,
|
|
2323
|
-
|
|
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"],
|
|
2657
|
+
allowed_updates: this.allowedUpdates(),
|
|
2333
2658
|
}, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
|
|
2334
|
-
const result =
|
|
2335
|
-
|
|
2336
|
-
|
|
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
|
-
}
|
|
2659
|
+
const result = emptyPollResult();
|
|
2660
|
+
for (const raw of updates)
|
|
2661
|
+
await this.intake(raw, result);
|
|
2350
2662
|
return result;
|
|
2351
2663
|
}
|
|
2664
|
+
/**
|
|
2665
|
+
* Which update types this channel asks for, on either transport (APRV-424).
|
|
2666
|
+
*
|
|
2667
|
+
* APRV-216. `message` is asked for only while a command handler is
|
|
2668
|
+
* registered: see {@link onCommand} for why a listener that reads messages
|
|
2669
|
+
* nobody asked for would break `approval setup channel telegram`'s chat
|
|
2670
|
+
* discovery. APRV-299 adds the second reason to read messages: a `loved` or
|
|
2671
|
+
* `disliked` on a review card collects the human's words as a REPLY, because
|
|
2672
|
+
* an inline keyboard has no text input.
|
|
2673
|
+
*
|
|
2674
|
+
* One function since APRV-424, because `setWebhook` takes the same list as
|
|
2675
|
+
* `getUpdates` and two spellings of it would be two answers to "what does
|
|
2676
|
+
* this listener read" — with the webhook's answer frozen at registration
|
|
2677
|
+
* time, where nobody would look for it.
|
|
2678
|
+
*/
|
|
2679
|
+
allowedUpdates() {
|
|
2680
|
+
return this.commandHandler === null && this.reviewHandler === null
|
|
2681
|
+
? ["callback_query"]
|
|
2682
|
+
: ["callback_query", "message"];
|
|
2683
|
+
}
|
|
2684
|
+
/**
|
|
2685
|
+
* One update, from whichever transport carried it (APRV-424).
|
|
2686
|
+
*
|
|
2687
|
+
* The whole of what the webhook adds is the ARRIVAL. Everything a tap then
|
|
2688
|
+
* passes through — the chat check, `senderOf`, the checkpoint and review
|
|
2689
|
+
* parsers, the resolution ladder, the early ack, `this.handler` and the
|
|
2690
|
+
* annotation written from the record the gate appended — is
|
|
2691
|
+
* {@link handleUpdate}, called from here and from {@link pollOnce}, and
|
|
2692
|
+
* there is no second copy of any of it. `tests/telegram-webhook.test.ts`
|
|
2693
|
+
* drives one callback through both entry points and compares the records the
|
|
2694
|
+
* log ends up holding.
|
|
2695
|
+
*
|
|
2696
|
+
* The sweep is `pollOnce`'s, for `pollOnce`'s reason: this is the moment the
|
|
2697
|
+
* process is about to be idle, and a channel that only ever wakes for taps
|
|
2698
|
+
* would otherwise never run one.
|
|
2699
|
+
*/
|
|
2700
|
+
async deliverUpdate(update) {
|
|
2701
|
+
this.claimOrThrow("webhook");
|
|
2702
|
+
const nowMs = this.now();
|
|
2703
|
+
if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
|
|
2704
|
+
this.sweep(nowMs);
|
|
2705
|
+
const result = emptyPollResult();
|
|
2706
|
+
await this.intake(update, result);
|
|
2707
|
+
return result;
|
|
2708
|
+
}
|
|
2709
|
+
/** The per-update bookkeeping both transports do, before `handleUpdate`. */
|
|
2710
|
+
async intake(raw, result) {
|
|
2711
|
+
const update = (raw ?? {});
|
|
2712
|
+
const id = update["update_id"];
|
|
2713
|
+
// Meaningless on the webhook, where Telegram acknowledges by the HTTP
|
|
2714
|
+
// response, and harmless: the offset is read by `getUpdates` alone, and a
|
|
2715
|
+
// channel that has claimed the webhook transport never issues one.
|
|
2716
|
+
if (typeof id === "number")
|
|
2717
|
+
this.offset = Math.max(this.offset, id + 1);
|
|
2718
|
+
this.counters.updates += 1;
|
|
2719
|
+
result.updates += 1;
|
|
2720
|
+
await this.handleUpdate(update, result);
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Bind this channel to one transport, or say which one already holds it
|
|
2724
|
+
* (APRV-424).
|
|
2725
|
+
*
|
|
2726
|
+
* Idempotent for the transport already claimed, so a webhook server that
|
|
2727
|
+
* claims at start-up and then delivers, or a driver calling `pollOnce` in a
|
|
2728
|
+
* loop, pays nothing. Not released by {@link stop}: a process that stopped
|
|
2729
|
+
* polling has not stopped being a poller, and re-pointing a live channel's
|
|
2730
|
+
* delivery maps at a second arrival path is the state this exists to refuse.
|
|
2731
|
+
*/
|
|
2732
|
+
claimTransport(transport) {
|
|
2733
|
+
if (this.transport === null || this.transport === transport) {
|
|
2734
|
+
this.transport = transport;
|
|
2735
|
+
return { ok: true, transport };
|
|
2736
|
+
}
|
|
2737
|
+
return {
|
|
2738
|
+
ok: false,
|
|
2739
|
+
code: "transport-conflict",
|
|
2740
|
+
message: `this channel is already receiving updates by ${this.transport}, so it cannot also receive them by ${transport}. Long polling and a webhook are alternatives per bot: the Bot API refuses getUpdates outright while a webhook is set, and a process running both would answer a tap from whichever half reached it first`,
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
/** {@link claimTransport}, for the paths where a refusal is a caller's bug. */
|
|
2744
|
+
claimOrThrow(transport) {
|
|
2745
|
+
const claim = this.claimTransport(transport);
|
|
2746
|
+
if (!claim.ok)
|
|
2747
|
+
throw new TelegramTransportError(claim.message);
|
|
2748
|
+
}
|
|
2749
|
+
// -------------------------------------------------------------------------
|
|
2750
|
+
// Webhook registration (APRV-424)
|
|
2751
|
+
// -------------------------------------------------------------------------
|
|
2752
|
+
/**
|
|
2753
|
+
* Point this bot's updates at `url`, and require `secret` on every post.
|
|
2754
|
+
*
|
|
2755
|
+
* Three properties, and each is a refusal to be clever:
|
|
2756
|
+
*
|
|
2757
|
+
* - **The secret is Telegram's own mechanism**, `secret_token`, echoed back
|
|
2758
|
+
* on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header. The
|
|
2759
|
+
* receiver compares it and refuses anything else, so a URL that leaks (a
|
|
2760
|
+
* proxy log, a referrer, a screenshot) is a URL that still decides
|
|
2761
|
+
* nothing.
|
|
2762
|
+
* - **It is redacted from this moment on.** The value is added to the
|
|
2763
|
+
* channel's secret list BEFORE the call, so a Bot API error that quotes
|
|
2764
|
+
* the request cannot print it.
|
|
2765
|
+
* - **Pending updates are kept.** `drop_pending_updates` is left false:
|
|
2766
|
+
* taps that arrived while the process was down are the approver's answers,
|
|
2767
|
+
* and the gate is what decides whether they are still honourable.
|
|
2768
|
+
*/
|
|
2769
|
+
async registerWebhook(url, secret) {
|
|
2770
|
+
if (secret.length > 0 && !this.secrets.some((held) => held.value === secret)) {
|
|
2771
|
+
this.secrets.push({ value: secret, label: "<webhook secret redacted>" });
|
|
2772
|
+
}
|
|
2773
|
+
await this.call("setWebhook", {
|
|
2774
|
+
url,
|
|
2775
|
+
secret_token: secret,
|
|
2776
|
+
allowed_updates: this.allowedUpdates(),
|
|
2777
|
+
drop_pending_updates: false,
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2780
|
+
/**
|
|
2781
|
+
* Take this bot's webhook away, so `getUpdates` works again.
|
|
2782
|
+
*
|
|
2783
|
+
* Called on a clean stop of the webhook runner. Pending updates are kept for
|
|
2784
|
+
* {@link registerWebhook}'s reason: the next process to start — a poller or
|
|
2785
|
+
* another webhook — is entitled to the taps that arrived in between.
|
|
2786
|
+
*
|
|
2787
|
+
* `timeoutMs` is what the stop path passes, and it is shorter than this
|
|
2788
|
+
* channel's ordinary request timeout on purpose (APRV-424, second review,
|
|
2789
|
+
* note 6). A stop already waits for the receiver to drain, and an operator
|
|
2790
|
+
* pressing Ctrl-C a second time is asking for the process to end: half a
|
|
2791
|
+
* minute of an unreachable Bot API on top of that reads as a hang, and the
|
|
2792
|
+
* webhook it failed to remove is reported rather than silently retried.
|
|
2793
|
+
*/
|
|
2794
|
+
async deleteWebhook(timeoutMs) {
|
|
2795
|
+
await this.call("deleteWebhook", { drop_pending_updates: false }, ...(timeoutMs === undefined ? [] : [timeoutMs]));
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* What the Bot API says is receiving this bot's updates right now.
|
|
2799
|
+
*
|
|
2800
|
+
* The one authority on the question, and the reason it is asked at start-up
|
|
2801
|
+
* by both transports: the ownership registry knows about processes on THIS
|
|
2802
|
+
* machine, and a webhook registered from a laptop in another timezone is
|
|
2803
|
+
* still a webhook swallowing every tap this poller is waiting for.
|
|
2804
|
+
*/
|
|
2805
|
+
async webhookInfo() {
|
|
2806
|
+
const result = await this.call("getWebhookInfo", {});
|
|
2807
|
+
const url = result["url"];
|
|
2808
|
+
const pending = result["pending_update_count"];
|
|
2809
|
+
return {
|
|
2810
|
+
url: typeof url === "string" ? url : "",
|
|
2811
|
+
pendingUpdateCount: typeof pending === "number" ? pending : 0,
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2352
2814
|
/**
|
|
2353
2815
|
* Exactly one `answerCallbackQuery` per callback query, on every path
|
|
2354
2816
|
* (APRV-196).
|
|
@@ -2478,17 +2940,37 @@ export class TelegramChannel {
|
|
|
2478
2940
|
const chat = (message["chat"] ?? {});
|
|
2479
2941
|
const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
|
|
2480
2942
|
// (a) Not our chat. Counted, answered, never decided, never logged.
|
|
2943
|
+
//
|
|
2944
|
+
// APRV-324 leaves this check exactly where it is, FIRST and ahead of the
|
|
2945
|
+
// sender, on purpose: the mapping widens WHO may decide and never WHERE
|
|
2946
|
+
// from. A callback carrying a perfectly mapped `from.id` but arriving in a
|
|
2947
|
+
// chat this listener does not answer to is ignored here, before anything
|
|
2948
|
+
// reads a sender, because a bot that accepted it would be one an operator
|
|
2949
|
+
// could not scope by conversation.
|
|
2481
2950
|
if (chatId !== this.chatId) {
|
|
2482
2951
|
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
2952
|
return;
|
|
2484
2953
|
}
|
|
2954
|
+
// APRV-324. The one field on this update that identifies a PERSON rather
|
|
2955
|
+
// than a conversation, read once, here, directly after the chat check and
|
|
2956
|
+
// BEFORE every branch below. Its position is the fix for the follow-up
|
|
2957
|
+
// review: read later, three callback families — checkpoint signatures,
|
|
2958
|
+
// review cards and the decision ladder — would each have had their own
|
|
2959
|
+
// chance to keep the pre-mapping behaviour, and two of them are privileged
|
|
2960
|
+
// gestures. `callback_query.from` is the Bot API's own attribution of the
|
|
2961
|
+
// tap to an account; nothing inside `message`, `data` or any text is
|
|
2962
|
+
// consulted, because those are what the sender says about themselves. It is
|
|
2963
|
+
// an observation and nothing more: this channel resolves no identity and
|
|
2964
|
+
// chooses no actor, it reports what it saw and the runtime's handlers
|
|
2965
|
+
// resolve it against the attested policy.
|
|
2966
|
+
const sender = senderOf(query, this.name);
|
|
2485
2967
|
// APRV-257, before the decision vocabulary and in a parser of its own. A
|
|
2486
2968
|
// checkpoint button decides no request, so it must never reach the ladder
|
|
2487
2969
|
// below — where an unresolved nonce falls back to an action reference, and
|
|
2488
2970
|
// a signature gesture would start looking for something to approve.
|
|
2489
2971
|
const checkpoint = parseCheckpointCallback(query["data"]);
|
|
2490
2972
|
if (checkpoint !== null) {
|
|
2491
|
-
await this.handleCheckpointTap(checkpoint, callbackId, result);
|
|
2973
|
+
await this.handleCheckpointTap(checkpoint, callbackId, result, sender);
|
|
2492
2974
|
return;
|
|
2493
2975
|
}
|
|
2494
2976
|
// APRV-299, before the decision vocabulary and in a parser of its own, for
|
|
@@ -2498,7 +2980,7 @@ export class TelegramChannel {
|
|
|
2498
2980
|
// happened would start looking for something to approve.
|
|
2499
2981
|
const review = parseReviewCallback(query["data"]);
|
|
2500
2982
|
if (review !== null) {
|
|
2501
|
-
await this.handleReviewTap(review, callbackId, result);
|
|
2983
|
+
await this.handleReviewTap(review, callbackId, result, sender);
|
|
2502
2984
|
return;
|
|
2503
2985
|
}
|
|
2504
2986
|
const parsed = parseCallbackData(query["data"]);
|
|
@@ -2510,7 +2992,7 @@ export class TelegramChannel {
|
|
|
2510
2992
|
// decides is whatever is still open on that delivery right now, which this
|
|
2511
2993
|
// process knows and the callback bytes deliberately do not say.
|
|
2512
2994
|
if (parsed.scope === "all") {
|
|
2513
|
-
await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
|
|
2995
|
+
await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result, sender);
|
|
2514
2996
|
return;
|
|
2515
2997
|
}
|
|
2516
2998
|
// The resolution ladder (APRV-196). A tap is answered by the nonce when
|
|
@@ -2518,7 +3000,12 @@ export class TelegramChannel {
|
|
|
2518
3000
|
// the log when neither is holding the action open.
|
|
2519
3001
|
let delivery = this.deliveries.get(parsed.nonce);
|
|
2520
3002
|
let viaStaleCopy = false;
|
|
2521
|
-
|
|
3003
|
+
// APRV-442. A card a refused tap disarmed stays dead: its bytes take no
|
|
3004
|
+
// fallback to the fresh card the listener sends for the same request, so
|
|
3005
|
+
// a redelivered or replayed refused tap reaches neither the gate nor the
|
|
3006
|
+
// new card, exactly as it did before that card existed.
|
|
3007
|
+
const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
|
|
3008
|
+
if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
|
|
2522
3009
|
// The pre-restart copy. Its nonce died with the process that issued it,
|
|
2523
3010
|
// but the request it names is one THIS process has since re-delivered, so
|
|
2524
3011
|
// the tap decides that request — on the live copy's message, which is
|
|
@@ -2529,18 +3016,21 @@ export class TelegramChannel {
|
|
|
2529
3016
|
viaStaleCopy = delivery !== undefined;
|
|
2530
3017
|
}
|
|
2531
3018
|
if (delivery === undefined) {
|
|
2532
|
-
if (parsed.actionRef !== null) {
|
|
3019
|
+
if (parsed.actionRef !== null && !refusedCard) {
|
|
2533
3020
|
// Nothing open here for that action. Say what the log says, which is
|
|
2534
3021
|
// the only thing that knows: decided, lapsed, withdrawn, or unknown.
|
|
2535
3022
|
const described = this.describeAction?.(parsed.actionRef) ?? null;
|
|
2536
3023
|
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
3024
|
return;
|
|
2538
3025
|
}
|
|
2539
|
-
await this.ignore(result, callbackId, "unknown-callback",
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
//
|
|
2543
|
-
//
|
|
3026
|
+
await this.ignore(result, callbackId, "unknown-callback", refusedCard
|
|
3027
|
+
? `nonce ${JSON.stringify(parsed.nonce)} is on a card a refused tap disarmed (APRV-442); the request, if still pending, is on a newer card`
|
|
3028
|
+
: `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
|
|
3029
|
+
// Three ways to get here, and the reply has to serve all of them: a
|
|
3030
|
+
// button this process never issued (a restart forgot it), a button on a
|
|
3031
|
+
// message this process has already annotated (APRV-113 forgets the
|
|
3032
|
+
// nonce with the edit), and a button on a card a refused tap disarmed
|
|
3033
|
+
// (APRV-442). Either way the message text is the thing to read.
|
|
2544
3034
|
"This button is no longer live — read the message for the outcome, or the newest message for the request.");
|
|
2545
3035
|
return;
|
|
2546
3036
|
}
|
|
@@ -2552,6 +3042,7 @@ export class TelegramChannel {
|
|
|
2552
3042
|
action_key: delivery.actionKey,
|
|
2553
3043
|
decision: parsed.decision,
|
|
2554
3044
|
deliveryId: delivery.deliveryId,
|
|
3045
|
+
...(sender === undefined ? {} : { sender }),
|
|
2555
3046
|
...(delivery.batchDeliveryId === undefined
|
|
2556
3047
|
? {}
|
|
2557
3048
|
: { batchDeliveryId: delivery.batchDeliveryId }),
|
|
@@ -2580,6 +3071,9 @@ export class TelegramChannel {
|
|
|
2580
3071
|
outcome = this.handler(decision);
|
|
2581
3072
|
}
|
|
2582
3073
|
catch (cause) {
|
|
3074
|
+
// APRV-442: the card is about to be disarmed and nothing settled the
|
|
3075
|
+
// request. Marked BEFORE the annotation, which forgets the nonces.
|
|
3076
|
+
this.release(delivery, parsed.nonce);
|
|
2583
3077
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2584
3078
|
TELEGRAM_HANDLER_FAILED,
|
|
2585
3079
|
]);
|
|
@@ -2604,6 +3098,18 @@ export class TelegramChannel {
|
|
|
2604
3098
|
// message, which is right in both directions: a terminal request has no
|
|
2605
3099
|
// decision left to collect, and a still-pending one is re-delivered as a
|
|
2606
3100
|
// fresh prompt by the next dispatch cycle.
|
|
3101
|
+
//
|
|
3102
|
+
// APRV-442. That re-delivery used to be a promise nothing kept: the
|
|
3103
|
+
// disarm forgets the card's nonces, but the listener's own bookkeeping
|
|
3104
|
+
// went on saying the request was on the phone, so the cycle never sent
|
|
3105
|
+
// it again and the original card's action-reference fallback found
|
|
3106
|
+
// nothing live. A wrong account's tap made the prompt dead until a
|
|
3107
|
+
// restart. Releasing names this CARD as gone and nothing else; the cycle
|
|
3108
|
+
// re-derives from the verified log whether the request is still pending
|
|
3109
|
+
// before it offers a new one. Marked BEFORE the annotation, which forgets
|
|
3110
|
+
// the nonces it records. The refusal itself, its record and its wording
|
|
3111
|
+
// are untouched.
|
|
3112
|
+
this.release(delivery, parsed.nonce);
|
|
2607
3113
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2608
3114
|
this.answerFor(outcome),
|
|
2609
3115
|
]);
|
|
@@ -2643,7 +3149,11 @@ export class TelegramChannel {
|
|
|
2643
3149
|
* the same message would show the approver their own decisions arriving one
|
|
2644
3150
|
* at a time, and would spend N Bot API calls to end in the same place.
|
|
2645
3151
|
*/
|
|
2646
|
-
async handleDigestAll(decision, nonce, callbackId, result
|
|
3152
|
+
async handleDigestAll(decision, nonce, callbackId, result,
|
|
3153
|
+
// APRV-324: one gesture, one sender, on every member it decides. An "all"
|
|
3154
|
+
// tap is N decisions by one person, so each of the N carries the account
|
|
3155
|
+
// the one tap came from.
|
|
3156
|
+
sender) {
|
|
2647
3157
|
const deliveryId = this.allNonces.get(nonce);
|
|
2648
3158
|
const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
|
|
2649
3159
|
if (digest === undefined) {
|
|
@@ -2667,6 +3177,7 @@ export class TelegramChannel {
|
|
|
2667
3177
|
decision,
|
|
2668
3178
|
deliveryId: digest.deliveryId,
|
|
2669
3179
|
batchDeliveryId: digest.batchDeliveryId,
|
|
3180
|
+
...(sender === undefined ? {} : { sender }),
|
|
2670
3181
|
...(decision === "reject"
|
|
2671
3182
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
|
|
2672
3183
|
: {}),
|
|
@@ -2771,7 +3282,11 @@ export class TelegramChannel {
|
|
|
2771
3282
|
* and appends to a log, and a spinner that lasted a decision long is what
|
|
2772
3283
|
* that task removed.
|
|
2773
3284
|
*/
|
|
2774
|
-
async handleCheckpointTap(tap, callbackId, result
|
|
3285
|
+
async handleCheckpointTap(tap, callbackId, result,
|
|
3286
|
+
// APRV-324 follow-up. A checkpoint signature is a human-only act and says
|
|
3287
|
+
// this log's head is what this person saw, so it carries the account the
|
|
3288
|
+
// tap came from exactly as a decision does.
|
|
3289
|
+
sender) {
|
|
2775
3290
|
const held = this.checkpointNonces.get(tap.nonce);
|
|
2776
3291
|
if (held === undefined) {
|
|
2777
3292
|
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.");
|
|
@@ -2784,7 +3299,11 @@ export class TelegramChannel {
|
|
|
2784
3299
|
return;
|
|
2785
3300
|
}
|
|
2786
3301
|
await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
|
|
2787
|
-
const response = await handler({
|
|
3302
|
+
const response = await handler({
|
|
3303
|
+
sign: tap.sign,
|
|
3304
|
+
head: held.head,
|
|
3305
|
+
...(sender === undefined ? {} : { sender }),
|
|
3306
|
+
});
|
|
2788
3307
|
// Edited here rather than through `annotate`, which renders an action key
|
|
2789
3308
|
// under its headline. A checkpoint has none, and an empty `<code></code>`
|
|
2790
3309
|
// where a request's key belongs would be this channel implying a request.
|
|
@@ -2817,7 +3336,7 @@ export class TelegramChannel {
|
|
|
2817
3336
|
* re-implement the rule. The one thing this method owns is the ARMING, which
|
|
2818
3337
|
* is process memory that appends nothing.
|
|
2819
3338
|
*/
|
|
2820
|
-
async handleReviewTap(tap, callbackId, result) {
|
|
3339
|
+
async handleReviewTap(tap, callbackId, result, sender) {
|
|
2821
3340
|
const deliveryId = this.reviewNonces.get(tap.nonce);
|
|
2822
3341
|
const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
|
|
2823
3342
|
if (state === undefined || state.settled !== null) {
|
|
@@ -2845,7 +3364,11 @@ export class TelegramChannel {
|
|
|
2845
3364
|
const chosen = tap.choice === "deny" ? "denied" : "ok";
|
|
2846
3365
|
state.denyArmed = chosen === "denied";
|
|
2847
3366
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2848
|
-
await this.recordReview(state, {
|
|
3367
|
+
await this.recordReview(state, {
|
|
3368
|
+
sampleSeq: state.card.sampleSeq,
|
|
3369
|
+
verdict: chosen,
|
|
3370
|
+
...(sender === undefined ? {} : { sender }),
|
|
3371
|
+
}, result);
|
|
2849
3372
|
return;
|
|
2850
3373
|
}
|
|
2851
3374
|
const reaction = tap.choice;
|
|
@@ -2858,11 +3381,16 @@ export class TelegramChannel {
|
|
|
2858
3381
|
const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
|
|
2859
3382
|
if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
|
|
2860
3383
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
|
|
2861
|
-
await this.askForNote(state, verdict, reaction);
|
|
3384
|
+
await this.askForNote(state, verdict, reaction, sender);
|
|
2862
3385
|
return;
|
|
2863
3386
|
}
|
|
2864
3387
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2865
|
-
await this.recordReview(state, {
|
|
3388
|
+
await this.recordReview(state, {
|
|
3389
|
+
sampleSeq: state.card.sampleSeq,
|
|
3390
|
+
verdict,
|
|
3391
|
+
reaction,
|
|
3392
|
+
...(sender === undefined ? {} : { sender }),
|
|
3393
|
+
}, result);
|
|
2866
3394
|
}
|
|
2867
3395
|
/**
|
|
2868
3396
|
* Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
|
|
@@ -2879,7 +3407,7 @@ export class TelegramChannel {
|
|
|
2879
3407
|
* one card, and the older prompt stops resolving so a late reply to it lands
|
|
2880
3408
|
* nowhere rather than recording a grade the human moved on from.
|
|
2881
3409
|
*/
|
|
2882
|
-
async askForNote(state, verdict, reaction) {
|
|
3410
|
+
async askForNote(state, verdict, reaction, sender) {
|
|
2883
3411
|
if (state.awaitingNote !== null)
|
|
2884
3412
|
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
2885
3413
|
const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
|
|
@@ -2893,7 +3421,7 @@ export class TelegramChannel {
|
|
|
2893
3421
|
reply_markup: { force_reply: true },
|
|
2894
3422
|
});
|
|
2895
3423
|
const promptId = String(sent.message_id);
|
|
2896
|
-
state.awaitingNote = { promptId, verdict, reaction };
|
|
3424
|
+
state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
|
|
2897
3425
|
this.reviewNotePrompts.set(promptId, state.deliveryId);
|
|
2898
3426
|
}
|
|
2899
3427
|
/**
|
|
@@ -2928,6 +3456,19 @@ export class TelegramChannel {
|
|
|
2928
3456
|
}
|
|
2929
3457
|
const state = this.reviewCards.get(deliveryId);
|
|
2930
3458
|
const pending = state?.awaitingNote ?? null;
|
|
3459
|
+
// APRV-324 follow-up, and checked BEFORE the prompt is forgotten, so a
|
|
3460
|
+
// reply from the wrong account costs the right one nothing. A note prompt
|
|
3461
|
+
// is a ForceReply addressed to the person who armed the grade, and its
|
|
3462
|
+
// words are recorded as that person's; a different account replying to it
|
|
3463
|
+
// is answering a question nobody asked them.
|
|
3464
|
+
const sender = senderOf(message, this.name);
|
|
3465
|
+
if (pending !== null &&
|
|
3466
|
+
pending.promptId === promptId &&
|
|
3467
|
+
pending.sender !== undefined &&
|
|
3468
|
+
(sender === undefined || sender.id !== pending.sender.id)) {
|
|
3469
|
+
this.complain("approval: telegram ignored a review note: it replied to a prompt issued for a different account, and the prompt is still open for the account that armed it");
|
|
3470
|
+
return true;
|
|
3471
|
+
}
|
|
2931
3472
|
this.reviewNotePrompts.delete(promptId);
|
|
2932
3473
|
if (state === undefined || pending === null || pending.promptId !== promptId)
|
|
2933
3474
|
return true;
|
|
@@ -2937,6 +3478,7 @@ export class TelegramChannel {
|
|
|
2937
3478
|
verdict: pending.verdict,
|
|
2938
3479
|
reaction: pending.reaction,
|
|
2939
3480
|
note: text,
|
|
3481
|
+
...(sender === undefined ? {} : { sender }),
|
|
2940
3482
|
}, result);
|
|
2941
3483
|
return true;
|
|
2942
3484
|
}
|
|
@@ -3079,9 +3621,20 @@ export class TelegramChannel {
|
|
|
3079
3621
|
// -------------------------------------------------------------------------
|
|
3080
3622
|
// Transport
|
|
3081
3623
|
// -------------------------------------------------------------------------
|
|
3082
|
-
/**
|
|
3624
|
+
/**
|
|
3625
|
+
* Replace every secret this channel holds with a placeholder in `text`.
|
|
3626
|
+
*
|
|
3627
|
+
* The bot token always, and since APRV-424 the webhook `secret_token` too,
|
|
3628
|
+
* from the moment {@link registerWebhook} is handed it. Both are values that
|
|
3629
|
+
* only ever travel TO the Bot API, so anything coming back that quotes one
|
|
3630
|
+
* is a diagnostic about to print a credential.
|
|
3631
|
+
*/
|
|
3083
3632
|
redact(text) {
|
|
3084
|
-
|
|
3633
|
+
let redacted = text;
|
|
3634
|
+
for (const secret of this.secrets) {
|
|
3635
|
+
redacted = redacted.split(secret.value).join(secret.label);
|
|
3636
|
+
}
|
|
3637
|
+
return redacted;
|
|
3085
3638
|
}
|
|
3086
3639
|
describe(cause) {
|
|
3087
3640
|
return this.redact(cause instanceof Error ? cause.message : String(cause));
|