approval-md 0.3.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 +83 -914
- package/SPEC.md +98 -13
- 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 +19 -0
- package/dist/src/channels/contract.js +105 -1
- 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 +271 -1
- package/dist/src/channels/telegram.js +403 -45
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +350 -4
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- 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 +272 -26
- package/dist/src/cli/channel-telegram.js +478 -41
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +341 -20
- 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 +9 -1
- package/dist/src/cli/help.js +246 -38
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +371 -3
- package/dist/src/cli/hook.js +1926 -44
- package/dist/src/cli/hook.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 +51 -0
- 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/scaffold.js +6 -0
- 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/up.js +21 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +337 -39
- 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/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 +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +55 -3
- package/dist/src/core/audit.js +52 -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/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +760 -8
- package/dist/src/core/command-class.js.map +1 -1
- 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/gate.d.ts +306 -5
- package/dist/src/core/gate.js +772 -24
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -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/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 +50 -21
- 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 +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +23 -0
- package/dist/src/core/policy-proposal.js +8 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- 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/sender-identity.d.ts +35 -10
- package/dist/src/core/sender-identity.js +48 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- 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/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/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 +298 -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 +22 -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 +1633 -43
- package/package.json +4 -2
- package/schema/event.schema.json +59 -2
- 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/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-requested-harness-cap.json +18 -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/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -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/policy.schema.json +50 -6
|
@@ -34,6 +34,24 @@
|
|
|
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
|
|
@@ -389,6 +407,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
|
389
407
|
export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
390
408
|
/** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
|
|
391
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;
|
|
392
417
|
// ---------------------------------------------------------------------------
|
|
393
418
|
// Anomalies
|
|
394
419
|
// ---------------------------------------------------------------------------
|
|
@@ -1572,6 +1597,37 @@ export function isPollConflict(cause) {
|
|
|
1572
1597
|
cause.method === "getUpdates" &&
|
|
1573
1598
|
(cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
|
|
1574
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
|
+
}
|
|
1575
1631
|
export class TelegramChannel {
|
|
1576
1632
|
name = "telegram";
|
|
1577
1633
|
token;
|
|
@@ -1647,11 +1703,60 @@ export class TelegramChannel {
|
|
|
1647
1703
|
digests = new Map();
|
|
1648
1704
|
/** "All" nonce -> the digest message it was issued for. */
|
|
1649
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();
|
|
1650
1740
|
rendered = [];
|
|
1651
1741
|
offset = 0;
|
|
1652
1742
|
counter = 0;
|
|
1653
1743
|
stopped = false;
|
|
1654
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;
|
|
1655
1760
|
counters = {
|
|
1656
1761
|
notified: 0,
|
|
1657
1762
|
updates: 0,
|
|
@@ -1671,6 +1776,8 @@ export class TelegramChannel {
|
|
|
1671
1776
|
};
|
|
1672
1777
|
constructor(config) {
|
|
1673
1778
|
this.token = config.token;
|
|
1779
|
+
this.secrets =
|
|
1780
|
+
config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
|
|
1674
1781
|
this.chatId = String(config.chatId);
|
|
1675
1782
|
this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
|
|
1676
1783
|
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
@@ -1933,8 +2040,15 @@ export class TelegramChannel {
|
|
|
1933
2040
|
async deliverDigest(members, batchDeliveryId, stale = null) {
|
|
1934
2041
|
const allNonce = this.makeNonce();
|
|
1935
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));
|
|
1936
2049
|
const state = {
|
|
1937
2050
|
deliveredAtMs,
|
|
2051
|
+
windowMs,
|
|
1938
2052
|
// Assigned once the message exists; nothing consults it before then.
|
|
1939
2053
|
deliveryId: "",
|
|
1940
2054
|
batchDeliveryId,
|
|
@@ -1994,13 +2108,14 @@ export class TelegramChannel {
|
|
|
1994
2108
|
state.deliveryId = deliveryId;
|
|
1995
2109
|
// Armed only now, and all at once: until the message with the buttons on it
|
|
1996
2110
|
// exists there is nothing a callback could legitimately answer.
|
|
1997
|
-
for (const member of state.members) {
|
|
2111
|
+
for (const [index, member] of state.members.entries()) {
|
|
1998
2112
|
this.deliveries.set(member.nonce, {
|
|
1999
2113
|
actionKey: member.actionKey,
|
|
2000
2114
|
actionRef: actionRefOf(member.actionKey),
|
|
2001
2115
|
deliveryId,
|
|
2002
2116
|
batchDeliveryId,
|
|
2003
2117
|
deliveredAtMs,
|
|
2118
|
+
windowMs: windows[index] ?? null,
|
|
2004
2119
|
});
|
|
2005
2120
|
}
|
|
2006
2121
|
this.digests.set(deliveryId, state);
|
|
@@ -2092,6 +2207,7 @@ export class TelegramChannel {
|
|
|
2092
2207
|
actionRef: actionRefOf(actionKey),
|
|
2093
2208
|
deliveryId: sent.deliveryId,
|
|
2094
2209
|
deliveredAtMs: this.now(),
|
|
2210
|
+
windowMs: this.retentionWindowMs(request),
|
|
2095
2211
|
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2096
2212
|
});
|
|
2097
2213
|
return {
|
|
@@ -2132,6 +2248,31 @@ export class TelegramChannel {
|
|
|
2132
2248
|
}
|
|
2133
2249
|
return actionKey;
|
|
2134
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
|
+
}
|
|
2135
2276
|
/**
|
|
2136
2277
|
* Drop the delivery bookkeeping no callback can still be honoured against
|
|
2137
2278
|
* (APRV-135).
|
|
@@ -2166,17 +2307,23 @@ export class TelegramChannel {
|
|
|
2166
2307
|
*/
|
|
2167
2308
|
sweep(nowMs = this.now()) {
|
|
2168
2309
|
this.lastSweepMs = nowMs;
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
|
|
2174
|
-
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;
|
|
2175
2320
|
let digests = 0;
|
|
2176
2321
|
for (const [deliveryId, digest] of this.digests) {
|
|
2177
2322
|
const terminal = digest.members.every((member) => member.settled !== null);
|
|
2178
|
-
if (!(terminal || lapsed(digest.deliveredAtMs
|
|
2323
|
+
if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
|
|
2324
|
+
!expired(digest.deliveredAtMs, digest.windowMs)) {
|
|
2179
2325
|
continue;
|
|
2326
|
+
}
|
|
2180
2327
|
this.digests.delete(deliveryId);
|
|
2181
2328
|
this.allNonces.delete(digest.allNonce);
|
|
2182
2329
|
digests += 1;
|
|
@@ -2188,8 +2335,10 @@ export class TelegramChannel {
|
|
|
2188
2335
|
// decides, and it was just judged above.
|
|
2189
2336
|
if (this.digests.has(delivery.deliveryId))
|
|
2190
2337
|
continue;
|
|
2191
|
-
if (!lapsed(delivery.deliveredAtMs
|
|
2338
|
+
if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
|
|
2339
|
+
!expired(delivery.deliveredAtMs, delivery.windowMs)) {
|
|
2192
2340
|
continue;
|
|
2341
|
+
}
|
|
2193
2342
|
this.deliveries.delete(nonce);
|
|
2194
2343
|
deliveries += 1;
|
|
2195
2344
|
}
|
|
@@ -2201,7 +2350,7 @@ export class TelegramChannel {
|
|
|
2201
2350
|
// this map holds, so keeping the buttons alive costs a map entry and
|
|
2202
2351
|
// dropping them early would cost a human their thumb.
|
|
2203
2352
|
for (const [deliveryId, card] of this.reviewCards) {
|
|
2204
|
-
if (card.settled === null || !expired(card.deliveredAtMs))
|
|
2353
|
+
if (card.settled === null || !expired(card.deliveredAtMs, null))
|
|
2205
2354
|
continue;
|
|
2206
2355
|
this.reviewCards.delete(deliveryId);
|
|
2207
2356
|
this.reviewNonces.delete(card.nonce);
|
|
@@ -2311,6 +2460,51 @@ export class TelegramChannel {
|
|
|
2311
2460
|
async retract(deliveryId, reason, actionKey) {
|
|
2312
2461
|
await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
|
|
2313
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
|
+
}
|
|
2314
2508
|
/**
|
|
2315
2509
|
* Send one plain message that carries no question (APRV-196).
|
|
2316
2510
|
*
|
|
@@ -2379,6 +2573,7 @@ export class TelegramChannel {
|
|
|
2379
2573
|
* decision", not "deliver once at startup, then wait forever".
|
|
2380
2574
|
*/
|
|
2381
2575
|
async listen(options = {}) {
|
|
2576
|
+
this.claimOrThrow("poll");
|
|
2382
2577
|
this.stopped = false;
|
|
2383
2578
|
let backoff = this.backoffMs;
|
|
2384
2579
|
/**
|
|
@@ -2447,6 +2642,7 @@ export class TelegramChannel {
|
|
|
2447
2642
|
* what {@link listen} catches and retries.
|
|
2448
2643
|
*/
|
|
2449
2644
|
async pollOnce() {
|
|
2645
|
+
this.claimOrThrow("poll");
|
|
2450
2646
|
// APRV-135. Before the long poll, not after: this is where the loop is
|
|
2451
2647
|
// about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
|
|
2452
2648
|
// the block would be a sweep that never runs on a quiet chat. Rate-limited
|
|
@@ -2458,35 +2654,163 @@ export class TelegramChannel {
|
|
|
2458
2654
|
const updates = await this.call("getUpdates", {
|
|
2459
2655
|
offset: this.offset,
|
|
2460
2656
|
timeout: this.pollTimeoutSeconds,
|
|
2461
|
-
|
|
2462
|
-
// registered: see {@link onCommand} for why a listener that reads
|
|
2463
|
-
// messages nobody asked for would break `approval setup channel
|
|
2464
|
-
// telegram`'s chat discovery.
|
|
2465
|
-
// APRV-299 adds the second reason to read messages: a `loved` or
|
|
2466
|
-
// `disliked` on a review card collects the human's words as a REPLY,
|
|
2467
|
-
// because an inline keyboard has no text input.
|
|
2468
|
-
allowed_updates: this.commandHandler === null && this.reviewHandler === null
|
|
2469
|
-
? ["callback_query"]
|
|
2470
|
-
: ["callback_query", "message"],
|
|
2657
|
+
allowed_updates: this.allowedUpdates(),
|
|
2471
2658
|
}, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
|
|
2472
|
-
const result =
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
ignored: [],
|
|
2476
|
-
commands: [],
|
|
2477
|
-
reviews: [],
|
|
2478
|
-
};
|
|
2479
|
-
for (const raw of updates) {
|
|
2480
|
-
const update = (raw ?? {});
|
|
2481
|
-
const id = update["update_id"];
|
|
2482
|
-
if (typeof id === "number")
|
|
2483
|
-
this.offset = Math.max(this.offset, id + 1);
|
|
2484
|
-
this.counters.updates += 1;
|
|
2485
|
-
result.updates += 1;
|
|
2486
|
-
await this.handleUpdate(update, result);
|
|
2487
|
-
}
|
|
2659
|
+
const result = emptyPollResult();
|
|
2660
|
+
for (const raw of updates)
|
|
2661
|
+
await this.intake(raw, result);
|
|
2488
2662
|
return result;
|
|
2489
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
|
+
}
|
|
2490
2814
|
/**
|
|
2491
2815
|
* Exactly one `answerCallbackQuery` per callback query, on every path
|
|
2492
2816
|
* (APRV-196).
|
|
@@ -2676,7 +3000,12 @@ export class TelegramChannel {
|
|
|
2676
3000
|
// the log when neither is holding the action open.
|
|
2677
3001
|
let delivery = this.deliveries.get(parsed.nonce);
|
|
2678
3002
|
let viaStaleCopy = false;
|
|
2679
|
-
|
|
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) {
|
|
2680
3009
|
// The pre-restart copy. Its nonce died with the process that issued it,
|
|
2681
3010
|
// but the request it names is one THIS process has since re-delivered, so
|
|
2682
3011
|
// the tap decides that request — on the live copy's message, which is
|
|
@@ -2687,18 +3016,21 @@ export class TelegramChannel {
|
|
|
2687
3016
|
viaStaleCopy = delivery !== undefined;
|
|
2688
3017
|
}
|
|
2689
3018
|
if (delivery === undefined) {
|
|
2690
|
-
if (parsed.actionRef !== null) {
|
|
3019
|
+
if (parsed.actionRef !== null && !refusedCard) {
|
|
2691
3020
|
// Nothing open here for that action. Say what the log says, which is
|
|
2692
3021
|
// the only thing that knows: decided, lapsed, withdrawn, or unknown.
|
|
2693
3022
|
const described = this.describeAction?.(parsed.actionRef) ?? null;
|
|
2694
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);
|
|
2695
3024
|
return;
|
|
2696
3025
|
}
|
|
2697
|
-
await this.ignore(result, callbackId, "unknown-callback",
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
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.
|
|
2702
3034
|
"This button is no longer live — read the message for the outcome, or the newest message for the request.");
|
|
2703
3035
|
return;
|
|
2704
3036
|
}
|
|
@@ -2739,6 +3071,9 @@ export class TelegramChannel {
|
|
|
2739
3071
|
outcome = this.handler(decision);
|
|
2740
3072
|
}
|
|
2741
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);
|
|
2742
3077
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2743
3078
|
TELEGRAM_HANDLER_FAILED,
|
|
2744
3079
|
]);
|
|
@@ -2763,6 +3098,18 @@ export class TelegramChannel {
|
|
|
2763
3098
|
// message, which is right in both directions: a terminal request has no
|
|
2764
3099
|
// decision left to collect, and a still-pending one is re-delivered as a
|
|
2765
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);
|
|
2766
3113
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2767
3114
|
this.answerFor(outcome),
|
|
2768
3115
|
]);
|
|
@@ -3274,9 +3621,20 @@ export class TelegramChannel {
|
|
|
3274
3621
|
// -------------------------------------------------------------------------
|
|
3275
3622
|
// Transport
|
|
3276
3623
|
// -------------------------------------------------------------------------
|
|
3277
|
-
/**
|
|
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
|
+
*/
|
|
3278
3632
|
redact(text) {
|
|
3279
|
-
|
|
3633
|
+
let redacted = text;
|
|
3634
|
+
for (const secret of this.secrets) {
|
|
3635
|
+
redacted = redacted.split(secret.value).join(secret.label);
|
|
3636
|
+
}
|
|
3637
|
+
return redacted;
|
|
3280
3638
|
}
|
|
3281
3639
|
describe(cause) {
|
|
3282
3640
|
return this.redact(cause instanceof Error ? cause.message : String(cause));
|