approval-md 0.3.0 → 0.4.1
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 +101 -13
- package/cli.js +134 -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 +72 -0
- package/dist/src/channels/contract.js +161 -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.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- 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 +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- 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/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.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 +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -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 +648 -56
- 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 +29 -1
- package/dist/src/cli/execute.js +387 -21
- 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 +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- 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/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- 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 +26 -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 +338 -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 +153 -12
- package/dist/src/core/audit.js +415 -20
- 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 +766 -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 +364 -6
- package/dist/src/core/gate.js +802 -27
- 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-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- 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 +130 -22
- package/dist/src/core/log.js +473 -50
- 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 +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.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 +59 -11
- package/dist/src/core/sender-identity.js +79 -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/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.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 +166 -0
- package/dist/src/serve/archive.js +363 -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 +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -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/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/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -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 +102 -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
|
|
@@ -201,15 +219,16 @@
|
|
|
201
219
|
* process memory and nothing else: no event is appended, no message is edited,
|
|
202
220
|
* and the log is not opened.
|
|
203
221
|
*/
|
|
204
|
-
import { createHash } from "node:crypto";
|
|
222
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
205
223
|
import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
|
|
206
224
|
// APRV-299. The reaction vocabulary and the verdict type, imported for the
|
|
207
225
|
// reason `core/audit.ts` states where it exports them: they are shown here and
|
|
208
226
|
// decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
|
|
209
227
|
// §11.1 invariant 10's guard scans the modules that decide, which this is not.
|
|
210
228
|
import { REACTIONS } from "../core/audit.js";
|
|
211
|
-
import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
229
|
+
import { DEFAULT_PROMPT_STYLE, TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
212
230
|
import { commandPayloadView, payloadRegionText } from "./payload-view.js";
|
|
231
|
+
import { MINIMAL_DENY_LABEL, renderTelegramMinimal } from "./telegram-minimal.js";
|
|
213
232
|
// ---------------------------------------------------------------------------
|
|
214
233
|
// Configuration
|
|
215
234
|
// ---------------------------------------------------------------------------
|
|
@@ -389,6 +408,13 @@ const DEFAULT_MAX_BACKOFF_MS = 30_000;
|
|
|
389
408
|
export const TELEGRAM_DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
390
409
|
/** Least time between two sweeps. A sweep is O(map); once a minute is plenty. */
|
|
391
410
|
export const TELEGRAM_SWEEP_INTERVAL_MS = 60_000;
|
|
411
|
+
/**
|
|
412
|
+
* How many refused cards' nonces a channel remembers as dead (APRV-442).
|
|
413
|
+
*
|
|
414
|
+
* A bound on memory and nothing else: a nonce that falls off the end reaches
|
|
415
|
+
* the action-reference fallback, and the gate judges that tap in full.
|
|
416
|
+
*/
|
|
417
|
+
export const TELEGRAM_REFUSED_NONCE_CAP = 1024;
|
|
392
418
|
// ---------------------------------------------------------------------------
|
|
393
419
|
// Anomalies
|
|
394
420
|
// ---------------------------------------------------------------------------
|
|
@@ -740,6 +766,81 @@ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layou
|
|
|
740
766
|
: `${payload.truncated ? `--- payload TRUNCATED at render (sha256 ${payload.hash}) — no canonical rendering exists; do not grant on this ---\n` : ""}${payloadRegionText(payload, request.class.value)}`,
|
|
741
767
|
};
|
|
742
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* The settle edit of a minimal card (APRV-489): the outcome, the card's
|
|
771
|
+
* headline, the detail lines, and the collapsed block WHOLE.
|
|
772
|
+
*
|
|
773
|
+
* The edit replaces the only message that holds the canonical rendering, so
|
|
774
|
+
* the rendering is never what gives way to fit: the runtime's own detail lines
|
|
775
|
+
* are shortened instead, with a marker, and the outcome headline always stays.
|
|
776
|
+
* The card was at most 3800 characters with its quote box and
|
|
777
|
+
* deadline line, which the edit drops, so headline plus collapsed block always
|
|
778
|
+
* fit under {@link TELEGRAM_SETTLE_BUDGET} (the card budget is `MINIMAL_MESSAGE_BUDGET` in `telegram-minimal.ts`).
|
|
779
|
+
*/
|
|
780
|
+
export function minimalSettleText(outcome, detail, card) {
|
|
781
|
+
const head = [`<b>${escapeHtml(outcome)}</b>`, card.headline];
|
|
782
|
+
// One unit throughout (fix round 2, S3): UTF-16 code units of the HTML,
|
|
783
|
+
// which is what `.length` counts and never less than the parsed text
|
|
784
|
+
// Telegram limits. The cut is taken between grapheme clusters of the
|
|
785
|
+
// UNESCAPED text, so it never splits a surrogate pair, a cluster or an entity.
|
|
786
|
+
const fixed = [...head, card.details].join("\n").length + 1;
|
|
787
|
+
const room = Math.max(0, TELEGRAM_SETTLE_BUDGET - fixed);
|
|
788
|
+
const raw = detail.join("\n");
|
|
789
|
+
let lines = escapeHtml(raw);
|
|
790
|
+
if (lines.length > room)
|
|
791
|
+
lines = shortenHtml(raw, room, SETTLE_SHORTENED_MARK);
|
|
792
|
+
return [...head, ...(lines.length === 0 ? [] : [lines]), card.details].join("\n");
|
|
793
|
+
}
|
|
794
|
+
/** What a shortened run of detail lines ends with. */
|
|
795
|
+
const SETTLE_SHORTENED_MARK = "… (shortened; the log holds the full record)";
|
|
796
|
+
/** Grapheme clusters, for cuts that never split one. */
|
|
797
|
+
const SETTLE_GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
798
|
+
/** `raw` escaped and cut between clusters so the result, marker included, is at most `budget` UTF-16 units. */
|
|
799
|
+
function shortenHtml(raw, budget, marker) {
|
|
800
|
+
const limit = Math.max(0, budget - marker.length);
|
|
801
|
+
let kept = "";
|
|
802
|
+
for (const { segment } of SETTLE_GRAPHEMES.segment(raw)) {
|
|
803
|
+
const escaped = escapeHtml(segment);
|
|
804
|
+
if (kept.length + escaped.length > limit)
|
|
805
|
+
break;
|
|
806
|
+
kept += escaped;
|
|
807
|
+
}
|
|
808
|
+
return `${kept}${marker}`;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The settle edit of last resort (fix round 2, S3): sent when Telegram refused
|
|
812
|
+
* the full settle edit of a minimal card, so a decided card never keeps looking
|
|
813
|
+
* undecided. The outcome, the request's key, the first detail line (who, when,
|
|
814
|
+
* which record) cut to fit, and where the rest is. Always under 1000 units.
|
|
815
|
+
*/
|
|
816
|
+
export function shortSettleText(outcome, actionKey, detail) {
|
|
817
|
+
const first = detail[0] ?? "";
|
|
818
|
+
return [
|
|
819
|
+
`<b>${escapeHtml(outcome)}</b>`,
|
|
820
|
+
`<code>${shortenHtml(actionKey, 200, "")}</code>`,
|
|
821
|
+
"",
|
|
822
|
+
...(first.length === 0 ? [] : [escapeHtml(first).length <= 400 ? escapeHtml(first) : shortenHtml(first, 400, "…")]),
|
|
823
|
+
"The full record is in the approval log (approval log, approval queue).",
|
|
824
|
+
].join("\n");
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Whether a Bot API failure is a refusal of THIS message rather than a passing
|
|
828
|
+
* failure (fix round 2, S2): any 4xx but 429. A 429, a 5xx and a transport
|
|
829
|
+
* failure are retried by the dispatch cycle as before; a refusal never will
|
|
830
|
+
* succeed with the same text, so the minimal card gives way to the technical one.
|
|
831
|
+
*/
|
|
832
|
+
export function isMessageRefusal(cause) {
|
|
833
|
+
return (cause instanceof TelegramApiError &&
|
|
834
|
+
cause.status !== null &&
|
|
835
|
+
cause.status >= 400 &&
|
|
836
|
+
cause.status < 500 &&
|
|
837
|
+
cause.status !== 429);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* The longest settle edit of a minimal card, in characters of HTML: under
|
|
841
|
+
* Telegram's 4096 with room for a relay's `Agent: <name>` line.
|
|
842
|
+
*/
|
|
843
|
+
export const TELEGRAM_SETTLE_BUDGET = 3990;
|
|
743
844
|
/**
|
|
744
845
|
* Split `text` so every chunk survives HTML escaping inside the message limit.
|
|
745
846
|
*
|
|
@@ -1311,6 +1412,15 @@ export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
|
|
|
1311
1412
|
* been recorded.
|
|
1312
1413
|
*/
|
|
1313
1414
|
export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
|
|
1415
|
+
/**
|
|
1416
|
+
* The headline a card wears while a grade is held and no verdict has been
|
|
1417
|
+
* given (APRV-482). A grade is not a verdict: tapping one with nothing armed
|
|
1418
|
+
* records nothing, and the card keeps the grade so the OK or the Deny that
|
|
1419
|
+
* follows records it.
|
|
1420
|
+
*/
|
|
1421
|
+
export function telegramReviewGradeHeld(reaction) {
|
|
1422
|
+
return `GRADE ${reaction.toUpperCase()} HELD — nothing is recorded until you tap OK or Deny`;
|
|
1423
|
+
}
|
|
1314
1424
|
/**
|
|
1315
1425
|
* What a review tap's single answer says (APRV-302).
|
|
1316
1426
|
*
|
|
@@ -1326,8 +1436,29 @@ export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
|
|
|
1326
1436
|
export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
|
|
1327
1437
|
/** The toast a first Deny tap gets: it says plainly that nothing was written. */
|
|
1328
1438
|
export const TELEGRAM_REVIEW_ARM_TOAST = "Deny armed — nothing recorded. Tap Deny again to record it, or a reaction to record it with a grade.";
|
|
1439
|
+
/**
|
|
1440
|
+
* The card line and toast for a tap that would finish another account's
|
|
1441
|
+
* half-finished review (PR #614 refutation F3): an armed Deny or a held grade
|
|
1442
|
+
* belongs to the account that tapped it, and nothing is recorded. A held grade
|
|
1443
|
+
* is replaced by the tapper's own grade (a grade finishes nothing), which the
|
|
1444
|
+
* line says (PR #614 recheck NF-1, minor). Under 200 characters, the Bot API's
|
|
1445
|
+
* limit for a callback answer.
|
|
1446
|
+
*/
|
|
1447
|
+
export const TELEGRAM_REVIEW_OTHER_SENDER = "Another account's unfinished review (armed Deny or held grade) is on this card. Nothing was recorded. Tap your own grade to replace a held one; an armed Deny is theirs to finish.";
|
|
1329
1448
|
/** The toast a reaction that needs the human's own words gets. */
|
|
1330
1449
|
export const TELEGRAM_REVIEW_NOTE_TOAST = "Heard — reply to the prompt with why. Nothing is recorded until it arrives.";
|
|
1450
|
+
/**
|
|
1451
|
+
* The card line for a note prompt that could not be sent (APRV-492). The toast
|
|
1452
|
+
* went out before the send and told the human to reply to a prompt, so the
|
|
1453
|
+
* card says the prompt is not coming and how to be asked again: a grade tap is
|
|
1454
|
+
* what the relay licenses a prompt for. With Deny armed the grade alone asks;
|
|
1455
|
+
* otherwise the grade is held and OK asks.
|
|
1456
|
+
*/
|
|
1457
|
+
export function telegramReviewNoteUnasked(verdict) {
|
|
1458
|
+
return verdict === "denied"
|
|
1459
|
+
? "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again to be asked."
|
|
1460
|
+
: "The prompt asking why could not be sent, so nothing was recorded. Tap your grade again, then OK, to be asked.";
|
|
1461
|
+
}
|
|
1331
1462
|
/**
|
|
1332
1463
|
* What the ForceReply prompt asks for.
|
|
1333
1464
|
*
|
|
@@ -1420,6 +1551,255 @@ function trimNotice(text) {
|
|
|
1420
1551
|
? text
|
|
1421
1552
|
: `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
|
|
1422
1553
|
}
|
|
1554
|
+
/**
|
|
1555
|
+
* How much payload, in ESCAPED characters, a review card carries whole
|
|
1556
|
+
* (APRV-480).
|
|
1557
|
+
*
|
|
1558
|
+
* A card is one message edited in place, so it cannot spill the payload over
|
|
1559
|
+
* several messages the way a prompt does: the edit that settles it replaces one
|
|
1560
|
+
* message, and a second message holding the rest of the bytes would outlive the
|
|
1561
|
+
* card it belonged to. So the bytes are shown whole or not at all. Never cut:
|
|
1562
|
+
* a review that recorded a payload hash over bytes the reviewer saw half of
|
|
1563
|
+
* would claim more than they read. This is the CEILING; the budget a given
|
|
1564
|
+
* card gets is {@link reviewPayloadBudget}, which is this or the headroom the
|
|
1565
|
+
* card's own rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS}, whichever is
|
|
1566
|
+
* smaller (PR #614 refutation F4).
|
|
1567
|
+
*/
|
|
1568
|
+
export const REVIEW_PAYLOAD_BUDGET = 2000;
|
|
1569
|
+
/**
|
|
1570
|
+
* The longest agent-written summary a review card shows, in characters (PR
|
|
1571
|
+
* #614 refutation F4). The summary is the requesting party's own claim and its
|
|
1572
|
+
* schema sets no length, so before this cap an agent could write a summary
|
|
1573
|
+
* that pushed the card past Telegram's limit, which failed the send and held
|
|
1574
|
+
* the review queue. Longer text is cut with a marker saying how much is not
|
|
1575
|
+
* shown; the registration record keeps it whole.
|
|
1576
|
+
*/
|
|
1577
|
+
export const REVIEW_SUMMARY_MAX = 400;
|
|
1578
|
+
/**
|
|
1579
|
+
* How much of each row a review card shows, in characters (PR #614 refutation
|
|
1580
|
+
* F4), tried in order until the card fits one message with the longest
|
|
1581
|
+
* heading, the hash-only payload region and the largest notice a tap can add.
|
|
1582
|
+
* `row` caps the key, class, task, command breakdown, gloss, run time and
|
|
1583
|
+
* outcome; `summary` caps the agent's summary (never above
|
|
1584
|
+
* {@link REVIEW_SUMMARY_MAX}); `origin` caps an author or source label. An
|
|
1585
|
+
* ordinary card fits at the first level and shows its rows as they are; only a
|
|
1586
|
+
* card whose rows are pathologically long is cut further, and the last level
|
|
1587
|
+
* fits whatever the rows hold (pinned by the worst-case test in
|
|
1588
|
+
* `tests/channels-telegram.test.ts`).
|
|
1589
|
+
*/
|
|
1590
|
+
const REVIEW_CAP_LEVELS = [
|
|
1591
|
+
{ row: 300, summary: REVIEW_SUMMARY_MAX, origin: 60 },
|
|
1592
|
+
{ row: 160, summary: 240, origin: 40 },
|
|
1593
|
+
{ row: 80, summary: 120, origin: 30 },
|
|
1594
|
+
{ row: 40, summary: 60, origin: 20 },
|
|
1595
|
+
];
|
|
1596
|
+
/** The longest notice headline a review card shows (F4). Headlines are constants. */
|
|
1597
|
+
const REVIEW_NOTICE_HEADLINE_MAX = 100;
|
|
1598
|
+
/**
|
|
1599
|
+
* The most characters a review card's notice region (a refusal, or the arming
|
|
1600
|
+
* line) shows, all its lines together (PR #614 refutation F4). Each line was
|
|
1601
|
+
* already cut at {@link REVIEW_NOTICE_MAX}; the region had no total, so a
|
|
1602
|
+
* refusal with several long lines could take the card over the limit and make
|
|
1603
|
+
* the redraw that carries it fail. The whole refusal is on the listener's
|
|
1604
|
+
* stderr.
|
|
1605
|
+
*/
|
|
1606
|
+
export const REVIEW_NOTICE_REGION_MAX = 600;
|
|
1607
|
+
/** The most characters a settled card's detail lines show together (F4). */
|
|
1608
|
+
const REVIEW_SETTLED_REGION_MAX = 3000;
|
|
1609
|
+
/**
|
|
1610
|
+
* The visible length of HTML this channel wrote: tags removed and each entity
|
|
1611
|
+
* {@link escapeHtml} produces counted as the one character it stands for.
|
|
1612
|
+
* Telegram measures its limit after entity parsing, so this is the number the
|
|
1613
|
+
* limit applies to.
|
|
1614
|
+
*/
|
|
1615
|
+
export function telegramVisibleLength(html) {
|
|
1616
|
+
return html.replace(/<[^>]*>/gu, "").replace(/&(?:amp|lt|gt);/gu, "x").length;
|
|
1617
|
+
}
|
|
1618
|
+
/** `text` cut at `max` characters with a marker saying how much is not shown. */
|
|
1619
|
+
function capReviewText(text, max) {
|
|
1620
|
+
if (text.length <= max)
|
|
1621
|
+
return text;
|
|
1622
|
+
let cut = text.slice(0, max);
|
|
1623
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
1624
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
1625
|
+
cut = cut.slice(0, -1);
|
|
1626
|
+
return `${cut}${reviewCutMarker(text.length - cut.length)}`;
|
|
1627
|
+
}
|
|
1628
|
+
/** The marker a cut row ends with. */
|
|
1629
|
+
function reviewCutMarker(hidden) {
|
|
1630
|
+
return `… [+${String(hidden)} chars not shown]`;
|
|
1631
|
+
}
|
|
1632
|
+
/** The longest {@link reviewCutMarker}: a JavaScript string is under 2^53 long. */
|
|
1633
|
+
const REVIEW_CUT_MARKER_MAX = reviewCutMarker(Number.MAX_SAFE_INTEGER).length;
|
|
1634
|
+
/** The line a region ends with when lines were left out. */
|
|
1635
|
+
const REVIEW_REGION_OVERFLOW = "… [more on the listener's stderr]";
|
|
1636
|
+
/**
|
|
1637
|
+
* Lines cut so that together, counting the newline before each, they show at
|
|
1638
|
+
* most `max` characters plus one cut marker and the overflow line.
|
|
1639
|
+
*/
|
|
1640
|
+
function capReviewRegion(lines, max) {
|
|
1641
|
+
const out = [];
|
|
1642
|
+
let used = 0;
|
|
1643
|
+
for (const entry of lines) {
|
|
1644
|
+
const room = max - used - 1;
|
|
1645
|
+
if (room <= 0) {
|
|
1646
|
+
out.push(REVIEW_REGION_OVERFLOW);
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
const shown = capReviewText(entry, room);
|
|
1650
|
+
out.push(shown);
|
|
1651
|
+
used += shown.length + 1;
|
|
1652
|
+
}
|
|
1653
|
+
return out;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* The longest payload text, in characters, a review card will even try to
|
|
1657
|
+
* render (APRV-480 refutation: review-delivery resource cap).
|
|
1658
|
+
*
|
|
1659
|
+
* Supervised payloads carry no size cap of their own (`PROPOSE_PAYLOAD_MAX_BYTES`
|
|
1660
|
+
* binds proposals only), and the party under review writes them. Without this
|
|
1661
|
+
* bound every render of a card, every redraw after a tap, and every tap's
|
|
1662
|
+
* {@link reviewPayloadView} would run the canonical renderer and HTML escaping
|
|
1663
|
+
* over the whole payload only to conclude "too long", so an agent could make
|
|
1664
|
+
* each review gesture cost work proportional to bytes it chose. Text past this
|
|
1665
|
+
* length is `too-long` without being rendered at all; the card builder
|
|
1666
|
+
* (`cli/audit-card.ts`) keeps no more than this much text in the card it holds.
|
|
1667
|
+
* Eight times the card budget, because a canonical rendering is never that much
|
|
1668
|
+
* shorter than the JSON it renders, and erring here only ever shows the hash.
|
|
1669
|
+
*/
|
|
1670
|
+
export const REVIEW_RENDER_INPUT_MAX = REVIEW_PAYLOAD_BUDGET * 8;
|
|
1671
|
+
/** The heading over a payload the card shows whole (APRV-480). */
|
|
1672
|
+
export const TELEGRAM_REVIEW_PAYLOAD_BYTES = "PAYLOAD — the bytes that ran, shown whole; this review covers them";
|
|
1673
|
+
/** The heading over a payload the card names by hash only (APRV-480). */
|
|
1674
|
+
export const TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY = "PAYLOAD — NOT SHOWN, hash only; this review does not cover the bytes";
|
|
1675
|
+
/** The heading on a card whose execution recorded no payload binding (APRV-480). */
|
|
1676
|
+
export const TELEGRAM_REVIEW_PAYLOAD_NONE = "PAYLOAD — none recorded; the execution bound to no payload hash";
|
|
1677
|
+
export function reviewPayloadView(card) {
|
|
1678
|
+
const rendering = card.fields.fullPayload.value;
|
|
1679
|
+
const bound = card.fields.payload_hash?.value ?? rendering?.hash ?? null;
|
|
1680
|
+
if (rendering !== null && (bound === null || bound === rendering.hash)) {
|
|
1681
|
+
// Bounded before any rendering work: see REVIEW_RENDER_INPUT_MAX.
|
|
1682
|
+
if (rendering.truncated || rendering.text.length > REVIEW_RENDER_INPUT_MAX) {
|
|
1683
|
+
return { kind: "hash", hash: rendering.hash, reason: "too-long" };
|
|
1684
|
+
}
|
|
1685
|
+
const text = payloadRegionText(rendering, card.fields.class.value);
|
|
1686
|
+
if (escapeHtml(text).length <= reviewPayloadBudget(card)) {
|
|
1687
|
+
return { kind: "bytes", hash: rendering.hash, text };
|
|
1688
|
+
}
|
|
1689
|
+
return { kind: "hash", hash: rendering.hash, reason: "too-long" };
|
|
1690
|
+
}
|
|
1691
|
+
if (bound === null)
|
|
1692
|
+
return { kind: "none" };
|
|
1693
|
+
return { kind: "hash", hash: bound, reason: "unavailable" };
|
|
1694
|
+
}
|
|
1695
|
+
/** The payload region of a review card, as HTML lines (APRV-480). */
|
|
1696
|
+
function reviewPayloadLines(view) {
|
|
1697
|
+
switch (view.kind) {
|
|
1698
|
+
case "bytes":
|
|
1699
|
+
return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_BYTES)}</b>`, `<pre>${escapeHtml(view.text)}</pre>`];
|
|
1700
|
+
case "hash":
|
|
1701
|
+
return [
|
|
1702
|
+
`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_HASH_ONLY)}</b>`,
|
|
1703
|
+
escapeHtml(view.reason === "too-long"
|
|
1704
|
+
? `The bytes are longer than one card carries, so none of them are shown. sha256 ${view.hash}`
|
|
1705
|
+
: `This runtime does not hold the bytes the execution bound to. sha256 ${view.hash}`),
|
|
1706
|
+
];
|
|
1707
|
+
default:
|
|
1708
|
+
return [`<b>${escapeHtml(TELEGRAM_REVIEW_PAYLOAD_NONE)}</b>`];
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* A review card's HTML lines around the payload region, every row cut to its
|
|
1713
|
+
* cap (PR #614 refutation F4): `top` is the key and the computed rows, `bottom`
|
|
1714
|
+
* the claimed ones. Pure over the card, so the payload budget computed from it
|
|
1715
|
+
* and the card drawn from it cannot disagree.
|
|
1716
|
+
*/
|
|
1717
|
+
function reviewCardFrame(card) {
|
|
1718
|
+
let frame = reviewCardFrameAt(card, REVIEW_CAP_LEVELS[0]);
|
|
1719
|
+
for (const level of REVIEW_CAP_LEVELS) {
|
|
1720
|
+
frame = reviewCardFrameAt(card, level);
|
|
1721
|
+
if (reviewFixedLength(frame) + REVIEW_HASH_REGION_MAX <= TELEGRAM_MAX_MESSAGE_CHARS)
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
return frame;
|
|
1725
|
+
}
|
|
1726
|
+
function reviewCardFrameAt(card, caps) {
|
|
1727
|
+
const computedLines = [];
|
|
1728
|
+
const claimedLines = [];
|
|
1729
|
+
for (const row of REVIEW_CARD_ROWS) {
|
|
1730
|
+
const candidate = reviewRow(card.fields, row);
|
|
1731
|
+
if (candidate === null)
|
|
1732
|
+
continue;
|
|
1733
|
+
if (candidate.line.kind === "computed")
|
|
1734
|
+
computedLines.push(candidate.line);
|
|
1735
|
+
else
|
|
1736
|
+
claimedLines.push(candidate.line);
|
|
1737
|
+
}
|
|
1738
|
+
computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
|
|
1739
|
+
computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
|
|
1740
|
+
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(capReviewText(entry.text, entry.field === "summary" ? caps.summary : caps.row))} <i>(${escapeHtml(capReviewText(entry.origin, caps.origin))})</i>`;
|
|
1741
|
+
const author = capReviewText(originOf(card.fields.summary), caps.origin);
|
|
1742
|
+
return {
|
|
1743
|
+
top: [
|
|
1744
|
+
`<code>${escapeHtml(capReviewText(card.fields.action_key.value, caps.row))}</code>`,
|
|
1745
|
+
"",
|
|
1746
|
+
"<b>COMPUTED — derived by the runtime from the log, the policy and the payload bytes</b>",
|
|
1747
|
+
...computedLines.map(render),
|
|
1748
|
+
"",
|
|
1749
|
+
],
|
|
1750
|
+
bottom: [
|
|
1751
|
+
"",
|
|
1752
|
+
`<b>CLAIMED — authored by ${escapeHtml(author)}, NOT verified by the runtime</b>`,
|
|
1753
|
+
...claimedLines.map(render),
|
|
1754
|
+
],
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
/** The longest heading a live card can wear: Deny armed and a grade held. */
|
|
1758
|
+
const REVIEW_HEADING_MAX = Math.max(...REACTIONS.map((reaction) => [TELEGRAM_REVIEW_HEADING, TELEGRAM_REVIEW_ARMED, telegramReviewGradeHeld(reaction)].join(" — ")
|
|
1759
|
+
.length));
|
|
1760
|
+
/**
|
|
1761
|
+
* What a notice can add to a card at most: the blank line and the headline,
|
|
1762
|
+
* each after a newline, then the region (newlines counted), one cut marker and
|
|
1763
|
+
* the overflow line after its newline.
|
|
1764
|
+
*/
|
|
1765
|
+
const REVIEW_NOTICE_RESERVE = 2 +
|
|
1766
|
+
REVIEW_NOTICE_HEADLINE_MAX +
|
|
1767
|
+
REVIEW_CUT_MARKER_MAX +
|
|
1768
|
+
REVIEW_NOTICE_REGION_MAX +
|
|
1769
|
+
REVIEW_CUT_MARKER_MAX +
|
|
1770
|
+
1 +
|
|
1771
|
+
REVIEW_REGION_OVERFLOW.length;
|
|
1772
|
+
/** The longest hash-only or none payload region, with the newlines around it. */
|
|
1773
|
+
const REVIEW_HASH_REGION_MAX = Math.max(...[
|
|
1774
|
+
{ kind: "hash", hash: "0".repeat(64), reason: "too-long" },
|
|
1775
|
+
{ kind: "hash", hash: "0".repeat(64), reason: "unavailable" },
|
|
1776
|
+
{ kind: "none" },
|
|
1777
|
+
].map((view) => telegramVisibleLength(reviewPayloadLines(view).join("\n")) + 2));
|
|
1778
|
+
/**
|
|
1779
|
+
* Everything a live card can hold except the payload region: the longest
|
|
1780
|
+
* heading, the frame, the largest notice, and the newlines joining them.
|
|
1781
|
+
*/
|
|
1782
|
+
function reviewFixedLength(frame) {
|
|
1783
|
+
return (REVIEW_HEADING_MAX +
|
|
1784
|
+
1 +
|
|
1785
|
+
telegramVisibleLength([...frame.top, ...frame.bottom].join("\n")) +
|
|
1786
|
+
REVIEW_NOTICE_RESERVE);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* How many escaped characters of payload this card can show whole (PR #614
|
|
1790
|
+
* refutation F4): {@link REVIEW_PAYLOAD_BUDGET}, or the room the card's own
|
|
1791
|
+
* rows leave under {@link TELEGRAM_MAX_MESSAGE_CHARS} after the longest heading,
|
|
1792
|
+
* the payload heading and the largest notice a tap can add, whichever is
|
|
1793
|
+
* smaller. Pure over the card, so the view a card is drawn with and the view a
|
|
1794
|
+
* tap records are the same view.
|
|
1795
|
+
*/
|
|
1796
|
+
export function reviewPayloadBudget(card) {
|
|
1797
|
+
const fixed = reviewFixedLength(reviewCardFrame(card)) +
|
|
1798
|
+
// The bytes heading and the newlines around the heading and the payload.
|
|
1799
|
+
TELEGRAM_REVIEW_PAYLOAD_BYTES.length +
|
|
1800
|
+
3;
|
|
1801
|
+
return Math.max(0, Math.min(REVIEW_PAYLOAD_BUDGET, TELEGRAM_MAX_MESSAGE_CHARS - fixed));
|
|
1802
|
+
}
|
|
1423
1803
|
/**
|
|
1424
1804
|
* The card's message: the rows, whatever notice the last tap produced, and the
|
|
1425
1805
|
* keyboard.
|
|
@@ -1432,13 +1812,15 @@ function trimNotice(text) {
|
|
|
1432
1812
|
* every card said the same thing to a reader who had already read them once, and
|
|
1433
1813
|
* pushed the rows a review is actually about off the first screen.
|
|
1434
1814
|
*
|
|
1435
|
-
* Pure.
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1815
|
+
* Pure. It carries no approve button: the action ran, and a card that offered
|
|
1816
|
+
* an approve would be presenting a settled fact as a live authorization. A
|
|
1817
|
+
* sample is never delivered as an approval request and never accepts a token.
|
|
1818
|
+
*
|
|
1819
|
+
* It DOES carry a payload region since APRV-480, between the computed rows and
|
|
1820
|
+
* the claimed ones, exactly where a prompt puts its own. Under supervised-retro
|
|
1821
|
+
* the review is the individual approval, so the reviewer reads the bytes that
|
|
1822
|
+
* ran, or is told plainly that the card shows only their hash and why
|
|
1823
|
+
* ({@link reviewPayloadView}). The heading says which, every time.
|
|
1442
1824
|
*/
|
|
1443
1825
|
export function renderReviewCard(state) {
|
|
1444
1826
|
const card = state.card;
|
|
@@ -1446,41 +1828,27 @@ export function renderReviewCard(state) {
|
|
|
1446
1828
|
if (state.settled !== null) {
|
|
1447
1829
|
return {
|
|
1448
1830
|
text: [
|
|
1449
|
-
`<b>${escapeHtml(state.settled.headline)}</b>`,
|
|
1450
|
-
`<code>${escapeHtml(key)}</code>`,
|
|
1831
|
+
`<b>${escapeHtml(capReviewText(state.settled.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`,
|
|
1832
|
+
`<code>${escapeHtml(capReviewText(key, REVIEW_CAP_LEVELS[0].row))}</code>`,
|
|
1451
1833
|
"",
|
|
1452
|
-
...state.settled.detail.map((entry) => escapeHtml(
|
|
1834
|
+
...capReviewRegion(state.settled.detail.map(trimNotice), REVIEW_SETTLED_REGION_MAX).map((entry) => escapeHtml(entry)),
|
|
1453
1835
|
].join("\n"),
|
|
1454
1836
|
keyboard: null,
|
|
1455
1837
|
};
|
|
1456
1838
|
}
|
|
1457
|
-
const
|
|
1458
|
-
const claimedLines = [];
|
|
1459
|
-
for (const row of REVIEW_CARD_ROWS) {
|
|
1460
|
-
const candidate = reviewRow(card.fields, row);
|
|
1461
|
-
if (candidate === null)
|
|
1462
|
-
continue;
|
|
1463
|
-
if (candidate.line.kind === "computed")
|
|
1464
|
-
computedLines.push(candidate.line);
|
|
1465
|
-
else
|
|
1466
|
-
claimedLines.push(candidate.line);
|
|
1467
|
-
}
|
|
1468
|
-
computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
|
|
1469
|
-
computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
|
|
1470
|
-
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
|
|
1471
|
-
const author = originOf(card.fields.summary);
|
|
1839
|
+
const frame = reviewCardFrame(card);
|
|
1472
1840
|
const lines = [
|
|
1473
|
-
`<b>${escapeHtml(
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
...
|
|
1841
|
+
`<b>${escapeHtml([
|
|
1842
|
+
TELEGRAM_REVIEW_HEADING,
|
|
1843
|
+
...(state.denyArmed ? [TELEGRAM_REVIEW_ARMED] : []),
|
|
1844
|
+
...(state.heldReaction === null ? [] : [telegramReviewGradeHeld(state.heldReaction)]),
|
|
1845
|
+
].join(" — "))}</b>`,
|
|
1846
|
+
...frame.top,
|
|
1847
|
+
...reviewPayloadLines(reviewPayloadView(card)),
|
|
1848
|
+
...frame.bottom,
|
|
1481
1849
|
];
|
|
1482
1850
|
if (state.notice !== null) {
|
|
1483
|
-
lines.push("", `<b>${escapeHtml(state.notice.headline)}</b>`, ...state.notice.lines.map((entry) => escapeHtml(
|
|
1851
|
+
lines.push("", `<b>${escapeHtml(capReviewText(state.notice.headline, REVIEW_NOTICE_HEADLINE_MAX))}</b>`, ...capReviewRegion(state.notice.lines.map(trimNotice), REVIEW_NOTICE_REGION_MAX).map((entry) => escapeHtml(entry)));
|
|
1484
1852
|
}
|
|
1485
1853
|
const button = (choice) => ({
|
|
1486
1854
|
text: REVIEW_BUTTON_LABELS[choice],
|
|
@@ -1504,6 +1872,7 @@ export class TelegramApiError extends Error {
|
|
|
1504
1872
|
method;
|
|
1505
1873
|
status;
|
|
1506
1874
|
description;
|
|
1875
|
+
retryAfterSeconds;
|
|
1507
1876
|
constructor(message, method,
|
|
1508
1877
|
/**
|
|
1509
1878
|
* The HTTP status, when the failure was an HTTP one. `null` for a transport
|
|
@@ -1516,14 +1885,84 @@ export class TelegramApiError extends Error {
|
|
|
1516
1885
|
* error body carried one. `null` when the body was absent, unreadable, not
|
|
1517
1886
|
* JSON, or carried no description.
|
|
1518
1887
|
*/
|
|
1519
|
-
description = null
|
|
1888
|
+
description = null,
|
|
1889
|
+
/**
|
|
1890
|
+
* The Bot API's `parameters.retry_after`, in seconds, when the failure
|
|
1891
|
+
* carried one (PR #614 recheck NF-2). Telegram sends it with 429 "Too Many
|
|
1892
|
+
* Requests" and means it: a send before it elapses is refused again.
|
|
1893
|
+
* `null` when the body named none.
|
|
1894
|
+
*/
|
|
1895
|
+
retryAfterSeconds = null) {
|
|
1520
1896
|
super(message);
|
|
1521
1897
|
this.method = method;
|
|
1522
1898
|
this.status = status;
|
|
1523
1899
|
this.description = description;
|
|
1900
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
1524
1901
|
this.name = "TelegramApiError";
|
|
1525
1902
|
}
|
|
1526
1903
|
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The Bot API refusals that are a fact about the message itself (PR #614
|
|
1906
|
+
* recheck NF-2). Each is a 400 whose `description` says the text, its markup
|
|
1907
|
+
* or its keyboard is unacceptable, so resending the same card meets the same
|
|
1908
|
+
* refusal however long the sender waits:
|
|
1909
|
+
*
|
|
1910
|
+
* - `message is too long`: the text is over 4096 characters after entities;
|
|
1911
|
+
* - `can't parse entities`: the HTML the card was drawn with does not parse;
|
|
1912
|
+
* - `message text is empty` / `text must be non-empty`: nothing to send;
|
|
1913
|
+
* - `reply markup is too long`: the inline keyboard is over its byte limit;
|
|
1914
|
+
* - `BUTTON_DATA_INVALID`: a button's callback data is over 64 bytes or empty;
|
|
1915
|
+
* - `ENTITIES_TOO_LONG` / `entities too long`: too many formatting entities.
|
|
1916
|
+
*
|
|
1917
|
+
* Deliberately absent, because the card cannot cause them and a retry can
|
|
1918
|
+
* clear them: `chat not found`, `bot was blocked`, `not enough rights` (the
|
|
1919
|
+
* chat's state, which a human repairs), every 429, every 5xx, every timeout and
|
|
1920
|
+
* network failure.
|
|
1921
|
+
*/
|
|
1922
|
+
const TELEGRAM_DETERMINISTIC_SEND_REFUSALS = [
|
|
1923
|
+
/message is too long/iu,
|
|
1924
|
+
/can't parse entities/iu,
|
|
1925
|
+
/message text is empty|text must be non-empty/iu,
|
|
1926
|
+
/reply markup is too long/iu,
|
|
1927
|
+
/BUTTON_DATA_INVALID/u,
|
|
1928
|
+
/ENTITIES_TOO_LONG|entities too long/iu,
|
|
1929
|
+
];
|
|
1930
|
+
/**
|
|
1931
|
+
* Whether a failed send is the Bot API refusing the message itself, so that
|
|
1932
|
+
* sending the same message again can only fail again (PR #614 recheck NF-2).
|
|
1933
|
+
*
|
|
1934
|
+
* Only an HTTP 400 whose description matches
|
|
1935
|
+
* {@link TELEGRAM_DETERMINISTIC_SEND_REFUSALS} qualifies. Anything else, an
|
|
1936
|
+
* error that is not a {@link TelegramApiError} included, is treated as
|
|
1937
|
+
* transient, which is the direction that keeps a card in front of the
|
|
1938
|
+
* approver: a transient failure misread as deterministic hides a sample from
|
|
1939
|
+
* the phone, and the converse costs a bounded number of retries.
|
|
1940
|
+
*/
|
|
1941
|
+
export function isDeterministicSendRefusal(cause) {
|
|
1942
|
+
return (cause instanceof TelegramApiError &&
|
|
1943
|
+
cause.status === 400 &&
|
|
1944
|
+
cause.description !== null &&
|
|
1945
|
+
TELEGRAM_DETERMINISTIC_SEND_REFUSALS.some((pattern) => pattern.test(cause.description)));
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* The wait, in milliseconds, a failed call's `retry_after` asks for, or `null`
|
|
1949
|
+
* when it named none (PR #614 recheck NF-2).
|
|
1950
|
+
*/
|
|
1951
|
+
export function retryAfterMsOf(cause) {
|
|
1952
|
+
if (!(cause instanceof TelegramApiError) || cause.retryAfterSeconds === null)
|
|
1953
|
+
return null;
|
|
1954
|
+
return cause.retryAfterSeconds * 1000;
|
|
1955
|
+
}
|
|
1956
|
+
/** `parameters.retry_after` from a parsed Bot API body, when it is a positive integer. */
|
|
1957
|
+
function retryAfterFrom(parsed) {
|
|
1958
|
+
if (parsed === null || typeof parsed !== "object")
|
|
1959
|
+
return null;
|
|
1960
|
+
const parameters = parsed["parameters"];
|
|
1961
|
+
if (parameters === null || typeof parameters !== "object")
|
|
1962
|
+
return null;
|
|
1963
|
+
const value = parameters["retry_after"];
|
|
1964
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
1965
|
+
}
|
|
1527
1966
|
/**
|
|
1528
1967
|
* Telegram's wording for "that edit would have changed nothing" (APRV-277).
|
|
1529
1968
|
*
|
|
@@ -1572,6 +2011,37 @@ export function isPollConflict(cause) {
|
|
|
1572
2011
|
cause.method === "getUpdates" &&
|
|
1573
2012
|
(cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
|
|
1574
2013
|
}
|
|
2014
|
+
/** A fresh, empty batch report. One shape for both transports (APRV-424). */
|
|
2015
|
+
function emptyPollResult() {
|
|
2016
|
+
return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [], refused: [] };
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
|
|
2020
|
+
*
|
|
2021
|
+
* One code, because there is one fact: this channel is already receiving
|
|
2022
|
+
* updates by the other transport.
|
|
2023
|
+
*/
|
|
2024
|
+
export const TELEGRAM_TRANSPORT_REFUSAL_CODES = ["transport-conflict"];
|
|
2025
|
+
/**
|
|
2026
|
+
* The in-process half of "long-poll and webhook are mutually exclusive"
|
|
2027
|
+
* (APRV-424).
|
|
2028
|
+
*
|
|
2029
|
+
* It is a belt on the braces and says so. The exclusion that matters is per
|
|
2030
|
+
* BOT and lives above this class, where a process can ask the Bot API
|
|
2031
|
+
* (`getWebhookInfo`) and the per-machine ownership registry who else is
|
|
2032
|
+
* receiving this bot's updates; a channel object knows only about itself.
|
|
2033
|
+
* What this rules out is the one failure a caller could cause here and
|
|
2034
|
+
* nowhere else: feeding one channel instance from both a poll loop and an
|
|
2035
|
+
* HTTP handler, where `handleUpdate`'s single ack slot and the delivery maps
|
|
2036
|
+
* would be written by two arrivals nobody ordered.
|
|
2037
|
+
*/
|
|
2038
|
+
export class TelegramTransportError extends Error {
|
|
2039
|
+
code = "transport-conflict";
|
|
2040
|
+
constructor(message) {
|
|
2041
|
+
super(message);
|
|
2042
|
+
this.name = "TelegramTransportError";
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
1575
2045
|
export class TelegramChannel {
|
|
1576
2046
|
name = "telegram";
|
|
1577
2047
|
token;
|
|
@@ -1591,6 +2061,12 @@ export class TelegramChannel {
|
|
|
1591
2061
|
describeAction;
|
|
1592
2062
|
/** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
|
|
1593
2063
|
layout;
|
|
2064
|
+
/** The policy's prompt style for this channel (APRV-489). */
|
|
2065
|
+
promptStyle;
|
|
2066
|
+
/** The policy's `say` declarations (APRV-489). Pure input to the minimal renderer. */
|
|
2067
|
+
say;
|
|
2068
|
+
/** The per-delivery resolver (fix round 2, S4), or null for the fixed values above. */
|
|
2069
|
+
promptFor;
|
|
1594
2070
|
/** When {@link sweep} last ran, so the poll loop can call it every cycle. */
|
|
1595
2071
|
lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
1596
2072
|
/**
|
|
@@ -1600,6 +2076,25 @@ export class TelegramChannel {
|
|
|
1600
2076
|
*/
|
|
1601
2077
|
ack = null;
|
|
1602
2078
|
handler = null;
|
|
2079
|
+
/**
|
|
2080
|
+
* APRV-196's stale-copy fallback, on or off (APRV-456). Fixed at
|
|
2081
|
+
* construction from {@link TelegramConfig.staleCopy}; nothing an update
|
|
2082
|
+
* carries can change it.
|
|
2083
|
+
*/
|
|
2084
|
+
staleCopy;
|
|
2085
|
+
/**
|
|
2086
|
+
* Where a `nonce-not-issued` refusal is recorded (APRV-456). The runtime's,
|
|
2087
|
+
* like every other handler here; absent, the refusal still happens and only
|
|
2088
|
+
* the record is missing.
|
|
2089
|
+
*/
|
|
2090
|
+
refusedDecisionHandler = null;
|
|
2091
|
+
/**
|
|
2092
|
+
* Nonces already refused `nonce-not-issued` (APRV-456), oldest first, capped
|
|
2093
|
+
* at {@link TELEGRAM_REFUSED_NONCE_CAP}. A redelivered or replayed tap on the
|
|
2094
|
+
* same button is refused again with the same toast and records nothing, so
|
|
2095
|
+
* one button costs the log at most one record.
|
|
2096
|
+
*/
|
|
2097
|
+
unissuedRefused = new Set();
|
|
1603
2098
|
/**
|
|
1604
2099
|
* What to do with a bot command (APRV-216). Absent unless the runtime asked
|
|
1605
2100
|
* for commands, and its absence is what keeps `message` out of
|
|
@@ -1647,11 +2142,60 @@ export class TelegramChannel {
|
|
|
1647
2142
|
digests = new Map();
|
|
1648
2143
|
/** "All" nonce -> the digest message it was issued for. */
|
|
1649
2144
|
allNonces = new Map();
|
|
2145
|
+
/**
|
|
2146
|
+
* Action keys whose card this process disarmed on a tap the gate did NOT
|
|
2147
|
+
* settle (APRV-442): a refusal (`sender-unmapped`, a budget, an unattested
|
|
2148
|
+
* policy, anything `ok: false`) or a handler that threw. Drained by
|
|
2149
|
+
* {@link takeReleased}.
|
|
2150
|
+
*
|
|
2151
|
+
* The disarm is right and stays, and {@link refusedNonces} keeps it final:
|
|
2152
|
+
* the refused card's bytes resolve to nothing, not even through the
|
|
2153
|
+
* action-reference fallback to the card that replaces it. What the disarm
|
|
2154
|
+
* must not do is leave the listener believing the REQUEST is on the
|
|
2155
|
+
* approver's phone, which is what `DispatchState.delivered` goes on saying
|
|
2156
|
+
* until something tells it otherwise. This set is that something: it names
|
|
2157
|
+
* a card, never an outcome, and the listener re-derives from the verified
|
|
2158
|
+
* log whether the request is still pending before it offers a new card.
|
|
2159
|
+
* Nothing here decides, and losing it to a restart costs nothing, because a
|
|
2160
|
+
* restarted listener re-sends everything pending anyway.
|
|
2161
|
+
*/
|
|
2162
|
+
released = new Set();
|
|
2163
|
+
/**
|
|
2164
|
+
* Nonces of cards a refused or failed tap disarmed (APRV-442), oldest first,
|
|
2165
|
+
* capped at {@link TELEGRAM_REFUSED_NONCE_CAP}.
|
|
2166
|
+
*
|
|
2167
|
+
* Without this, a released request's fresh card would give the refused
|
|
2168
|
+
* card's bytes somewhere to go: the nonce misses, the action reference
|
|
2169
|
+
* resolves to the new live card, and a Telegram redelivery of the refused
|
|
2170
|
+
* tap (a webhook retry) or a client replaying the removed buttons would be
|
|
2171
|
+
* refused again, append again, disarm the new card and cause another
|
|
2172
|
+
* re-send, once per replay. A nonce in this set never takes the fallback, so
|
|
2173
|
+
* a refused card stays exactly as dead as it was before APRV-442 and only
|
|
2174
|
+
* the REQUEST is offered again. Forgetting an entry past the cap degrades to
|
|
2175
|
+
* the fallback, whose tap the gate still judges in full: volume, never a
|
|
2176
|
+
* decision.
|
|
2177
|
+
*/
|
|
2178
|
+
refusedNonces = new Set();
|
|
1650
2179
|
rendered = [];
|
|
1651
2180
|
offset = 0;
|
|
1652
2181
|
counter = 0;
|
|
1653
2182
|
stopped = false;
|
|
1654
2183
|
inFlight = null;
|
|
2184
|
+
/**
|
|
2185
|
+
* The transport this channel has started receiving updates by, or `null`
|
|
2186
|
+
* before the first one (APRV-424). Sticky for the life of the object: a
|
|
2187
|
+
* process that stopped polling has not stopped being a poller, and the
|
|
2188
|
+
* question this answers is which arrival path owns the maps below.
|
|
2189
|
+
*/
|
|
2190
|
+
transport = null;
|
|
2191
|
+
/**
|
|
2192
|
+
* Everything {@link redact} scrubs out of anything that leaves this class.
|
|
2193
|
+
*
|
|
2194
|
+
* The bot token from construction, plus any secret a later call was handed:
|
|
2195
|
+
* {@link registerWebhook} adds the `secret_token` before it sends it, so a
|
|
2196
|
+
* Bot API error quoting the request cannot put it on an operator's terminal.
|
|
2197
|
+
*/
|
|
2198
|
+
secrets;
|
|
1655
2199
|
counters = {
|
|
1656
2200
|
notified: 0,
|
|
1657
2201
|
updates: 0,
|
|
@@ -1666,11 +2210,14 @@ export class TelegramChannel {
|
|
|
1666
2210
|
"unknown-command": 0,
|
|
1667
2211
|
},
|
|
1668
2212
|
staleCopyDecisions: 0,
|
|
2213
|
+
staleCopyRefusals: 0,
|
|
1669
2214
|
commands: 0,
|
|
1670
2215
|
reviews: 0,
|
|
1671
2216
|
};
|
|
1672
2217
|
constructor(config) {
|
|
1673
2218
|
this.token = config.token;
|
|
2219
|
+
this.secrets =
|
|
2220
|
+
config.token.length === 0 ? [] : [{ value: config.token, label: "<token redacted>" }];
|
|
1674
2221
|
this.chatId = String(config.chatId);
|
|
1675
2222
|
this.apiBase = (config.apiBase ?? TELEGRAM_DEFAULT_API_BASE).replace(/\/+$/u, "");
|
|
1676
2223
|
this.fetchImpl = config.fetch ?? globalThis.fetch;
|
|
@@ -1687,11 +2234,22 @@ export class TelegramChannel {
|
|
|
1687
2234
|
this.now = config.now ?? (() => Date.now());
|
|
1688
2235
|
this.describeAction = config.describeAction ?? null;
|
|
1689
2236
|
this.layout = config.layout ?? TELEGRAM_PROMPT_LAYOUT;
|
|
2237
|
+
this.promptStyle = config.promptStyle ?? DEFAULT_PROMPT_STYLE;
|
|
2238
|
+
this.say = config.say ?? {};
|
|
2239
|
+
this.promptFor = config.promptFor ?? null;
|
|
2240
|
+
// APRV-456. Only an explicit `true` or an absent key leaves the fallback on;
|
|
2241
|
+
// anything else a caller passes turns it off, the stricter direction.
|
|
2242
|
+
this.staleCopy = config.staleCopy === undefined || config.staleCopy === true;
|
|
1690
2243
|
this.makeNonce =
|
|
1691
2244
|
config.nonce ??
|
|
1692
2245
|
(() => {
|
|
1693
2246
|
this.counter += 1;
|
|
1694
|
-
|
|
2247
|
+
// APRV-456 refutation S2. On a relayed channel the nonce is the only
|
|
2248
|
+
// thing binding a button to this process, so it comes from the CSPRNG
|
|
2249
|
+
// (72 bits) rather than `Math.random`, whose state a reader of a few old
|
|
2250
|
+
// buttons could in principle recover. base64url carries no `:`, and the
|
|
2251
|
+
// whole `<verb>:<nonce>:<ref>` stays well inside the 64-byte cap.
|
|
2252
|
+
return `${this.counter.toString(36)}${randomBytes(9).toString("base64url")}`;
|
|
1695
2253
|
});
|
|
1696
2254
|
}
|
|
1697
2255
|
// -------------------------------------------------------------------------
|
|
@@ -1700,6 +2258,25 @@ export class TelegramChannel {
|
|
|
1700
2258
|
onDecision(handler) {
|
|
1701
2259
|
this.handler = handler;
|
|
1702
2260
|
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Register where a tap this channel refused on its own is recorded
|
|
2263
|
+
* (APRV-456).
|
|
2264
|
+
*
|
|
2265
|
+
* Separate from {@link onDecision} on purpose: that handler's job is to call
|
|
2266
|
+
* the gate, and a `nonce-not-issued` tap must never reach the gate. The
|
|
2267
|
+
* runtime wires this to an audit-tier append that grants nothing.
|
|
2268
|
+
*/
|
|
2269
|
+
onRefusedDecision(handler) {
|
|
2270
|
+
this.refusedDecisionHandler = handler;
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Whether APRV-196's stale-copy fallback is on (APRV-456). Read by the
|
|
2274
|
+
* listener for the restart banner, which must not promise that earlier
|
|
2275
|
+
* copies still decide when they do not.
|
|
2276
|
+
*/
|
|
2277
|
+
staleCopyEnabled() {
|
|
2278
|
+
return this.staleCopy;
|
|
2279
|
+
}
|
|
1703
2280
|
/**
|
|
1704
2281
|
* Register what to do with a checkpoint tap (APRV-257).
|
|
1705
2282
|
*
|
|
@@ -1770,8 +2347,9 @@ export class TelegramChannel {
|
|
|
1770
2347
|
* A unit like a checkpoint prompt: one thing to read, never grouped into a
|
|
1771
2348
|
* digest, and never delivered through {@link notify} — a digest is a set of
|
|
1772
2349
|
* similar pending REQUESTS decided together, and a sample is neither pending
|
|
1773
|
-
* nor a request. It sends ONE message:
|
|
1774
|
-
*
|
|
2350
|
+
* nor a request. It sends ONE message: the rows, the payload whole or by hash
|
|
2351
|
+
* (APRV-480, never split across messages), and a keyboard whose six buttons
|
|
2352
|
+
* collect a verdict and a grade and mint nothing.
|
|
1775
2353
|
*
|
|
1776
2354
|
* Refuses when no handler is registered, rather than sending a dead button.
|
|
1777
2355
|
*/
|
|
@@ -1786,6 +2364,9 @@ export class TelegramChannel {
|
|
|
1786
2364
|
card,
|
|
1787
2365
|
nonce,
|
|
1788
2366
|
denyArmed: false,
|
|
2367
|
+
heldReaction: null,
|
|
2368
|
+
armedBy: null,
|
|
2369
|
+
heldBy: null,
|
|
1789
2370
|
settled: null,
|
|
1790
2371
|
notice: null,
|
|
1791
2372
|
awaitingNote: null,
|
|
@@ -1933,8 +2514,15 @@ export class TelegramChannel {
|
|
|
1933
2514
|
async deliverDigest(members, batchDeliveryId, stale = null) {
|
|
1934
2515
|
const allNonce = this.makeNonce();
|
|
1935
2516
|
const deliveredAtMs = this.now();
|
|
2517
|
+
// The digest's window is the widest member's: it may be forgotten only when
|
|
2518
|
+
// every member is past deciding, and a member nothing bounds keeps it.
|
|
2519
|
+
const windows = members.map((member) => this.retentionWindowMs(member));
|
|
2520
|
+
const windowMs = windows.includes(null)
|
|
2521
|
+
? null
|
|
2522
|
+
: Math.max(...windows.filter((window) => window !== null));
|
|
1936
2523
|
const state = {
|
|
1937
2524
|
deliveredAtMs,
|
|
2525
|
+
windowMs,
|
|
1938
2526
|
// Assigned once the message exists; nothing consults it before then.
|
|
1939
2527
|
deliveryId: "",
|
|
1940
2528
|
batchDeliveryId,
|
|
@@ -1994,13 +2582,16 @@ export class TelegramChannel {
|
|
|
1994
2582
|
state.deliveryId = deliveryId;
|
|
1995
2583
|
// Armed only now, and all at once: until the message with the buttons on it
|
|
1996
2584
|
// exists there is nothing a callback could legitimately answer.
|
|
1997
|
-
|
|
2585
|
+
const rendering = this.digestRendering(stale !== null);
|
|
2586
|
+
for (const [index, member] of state.members.entries()) {
|
|
1998
2587
|
this.deliveries.set(member.nonce, {
|
|
1999
2588
|
actionKey: member.actionKey,
|
|
2000
2589
|
actionRef: actionRefOf(member.actionKey),
|
|
2001
2590
|
deliveryId,
|
|
2002
2591
|
batchDeliveryId,
|
|
2003
2592
|
deliveredAtMs,
|
|
2593
|
+
windowMs: windows[index] ?? null,
|
|
2594
|
+
...(rendering === undefined ? {} : { rendering }),
|
|
2004
2595
|
});
|
|
2005
2596
|
}
|
|
2006
2597
|
this.digests.set(deliveryId, state);
|
|
@@ -2085,14 +2676,49 @@ export class TelegramChannel {
|
|
|
2085
2676
|
],
|
|
2086
2677
|
],
|
|
2087
2678
|
};
|
|
2088
|
-
|
|
2679
|
+
// APRV-489. The minimal card is tried only when the policy asked for it,
|
|
2680
|
+
// and anything it will not draw honestly is sent as the technical card,
|
|
2681
|
+
// exactly as a technical policy sends it, with the reason kept for the
|
|
2682
|
+
// decision record and said on stderr.
|
|
2683
|
+
let sent;
|
|
2684
|
+
let rendering;
|
|
2685
|
+
let card;
|
|
2686
|
+
const prompt = this.currentPrompt();
|
|
2687
|
+
// Fix round 2, B2: a request delivered as part of a batch is always the
|
|
2688
|
+
// technical card, as SPEC §10.3 (amended) says of a grouped delivery.
|
|
2689
|
+
const minimal = prompt.fallback !== undefined
|
|
2690
|
+
? { ok: false, reason: prompt.fallback }
|
|
2691
|
+
: prompt.style !== "minimal"
|
|
2692
|
+
? null
|
|
2693
|
+
: batchDeliveryId !== undefined
|
|
2694
|
+
? { ok: false, reason: "batch" }
|
|
2695
|
+
: await this.sendMinimal(request, nonce, prompt.say);
|
|
2696
|
+
if (minimal !== null && minimal.ok) {
|
|
2697
|
+
sent = minimal.sent;
|
|
2698
|
+
// R3-S1: the record notes a `say` phrase the runtime overrode with its own.
|
|
2699
|
+
rendering = minimal.sayDoesIgnored ? { style: "minimal", say_does_ignored: true } : { style: "minimal" };
|
|
2700
|
+
card = minimal.card;
|
|
2701
|
+
if (minimal.sayDoesIgnored) {
|
|
2702
|
+
this.complain(`approval: telegram drew its own phrase for ${actionKey}, not the say entry's does: the runtime reads this payload itself`);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
else {
|
|
2706
|
+
if (minimal !== null) {
|
|
2707
|
+
rendering = { style: "technical", fallback: minimal.reason };
|
|
2708
|
+
this.complain(`approval: telegram sent the technical card for ${actionKey}, not the minimal one: ${minimal.reason}`);
|
|
2709
|
+
}
|
|
2710
|
+
sent = await this.sendPrompt(request, TELEGRAM_PROMPT_HEADING, keyboard);
|
|
2711
|
+
}
|
|
2089
2712
|
this.counters.notified += 1;
|
|
2090
2713
|
this.deliveries.set(nonce, {
|
|
2091
2714
|
actionKey,
|
|
2092
2715
|
actionRef: actionRefOf(actionKey),
|
|
2093
2716
|
deliveryId: sent.deliveryId,
|
|
2094
2717
|
deliveredAtMs: this.now(),
|
|
2718
|
+
windowMs: this.retentionWindowMs(request),
|
|
2095
2719
|
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2720
|
+
...(rendering === undefined ? {} : { rendering }),
|
|
2721
|
+
...(card === undefined ? {} : { card }),
|
|
2096
2722
|
});
|
|
2097
2723
|
return {
|
|
2098
2724
|
deliveryId: sent.deliveryId,
|
|
@@ -2102,6 +2728,95 @@ export class TelegramChannel {
|
|
|
2102
2728
|
},
|
|
2103
2729
|
};
|
|
2104
2730
|
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Send `request` as ONE minimal card (APRV-489), or report why it was not.
|
|
2733
|
+
*
|
|
2734
|
+
* The card is drawn from the same `renderTelegram` output the technical card
|
|
2735
|
+
* would send, under the same layout, so the collapsed block IS the technical
|
|
2736
|
+
* card. The buttons carry the same callback data as the technical card's; only
|
|
2737
|
+
* the reject label reads "Deny" (R7). Nothing is sent when the card is refused,
|
|
2738
|
+
* so the caller's technical send is the only send.
|
|
2739
|
+
*/
|
|
2740
|
+
async sendMinimal(request, nonce, say) {
|
|
2741
|
+
const actionKey = request.action_key.value;
|
|
2742
|
+
const rendering = renderTelegram(request, TELEGRAM_PROMPT_HEADING, this.layout);
|
|
2743
|
+
const drawn = renderTelegramMinimal(request, {
|
|
2744
|
+
header: rendering.header,
|
|
2745
|
+
payloadText: rendering.payloadText,
|
|
2746
|
+
claimedText: rendering.claimedText,
|
|
2747
|
+
anomalous: rendering.lines.some((entry) => entry.label.startsWith(TELEGRAM_ANOMALY_MARK)),
|
|
2748
|
+
payloadLabel: `<b>${PAYLOAD_CHUNK_LABEL}</b>`,
|
|
2749
|
+
}, say);
|
|
2750
|
+
if (!drawn.ok)
|
|
2751
|
+
return { ok: false, reason: drawn.reason };
|
|
2752
|
+
let result;
|
|
2753
|
+
try {
|
|
2754
|
+
result = await this.call("sendMessage", {
|
|
2755
|
+
chat_id: this.chatId,
|
|
2756
|
+
text: drawn.text,
|
|
2757
|
+
parse_mode: "HTML",
|
|
2758
|
+
disable_web_page_preview: true,
|
|
2759
|
+
reply_markup: {
|
|
2760
|
+
inline_keyboard: [
|
|
2761
|
+
[
|
|
2762
|
+
{ text: "✅ Approve", callback_data: callbackData("g", nonce, actionKey) },
|
|
2763
|
+
{ text: MINIMAL_DENY_LABEL, callback_data: callbackData("r", nonce, actionKey) },
|
|
2764
|
+
],
|
|
2765
|
+
],
|
|
2766
|
+
},
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
catch (cause) {
|
|
2770
|
+
// Fix round 2, S2: a refusal of the card itself (a Bot API that cannot
|
|
2771
|
+
// parse the expandable block, an entity limit, a relay rule) would refuse
|
|
2772
|
+
// the same card on every cycle and leave the approver with nothing. The
|
|
2773
|
+
// technical card goes instead, once, and the record says why. A passing
|
|
2774
|
+
// failure (429, 5xx, network) is thrown and retried as before.
|
|
2775
|
+
if (isMessageRefusal(cause))
|
|
2776
|
+
return { ok: false, reason: "send-refused" };
|
|
2777
|
+
throw cause;
|
|
2778
|
+
}
|
|
2779
|
+
const fields = rendering.lines.map((entry) => ({
|
|
2780
|
+
field: entry.field,
|
|
2781
|
+
kind: entry.kind,
|
|
2782
|
+
text: entry.text,
|
|
2783
|
+
}));
|
|
2784
|
+
return {
|
|
2785
|
+
ok: true,
|
|
2786
|
+
sent: {
|
|
2787
|
+
deliveryId: String(result.message_id),
|
|
2788
|
+
rendered: { action_key: actionKey, fields, fullPayloadText: rendering.payloadText },
|
|
2789
|
+
},
|
|
2790
|
+
card: { headline: drawn.headline, details: drawn.details },
|
|
2791
|
+
sayDoesIgnored: drawn.sayDoesIgnored === true,
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* The style and `say` map in force for the delivery about to be drawn (fix
|
|
2796
|
+
* round 2, S4): the resolver's answer when the verb gave one, else the fixed
|
|
2797
|
+
* configuration. A resolver that throws is `technical`, the side that shows more.
|
|
2798
|
+
*/
|
|
2799
|
+
currentPrompt() {
|
|
2800
|
+
if (this.promptFor === null)
|
|
2801
|
+
return { style: this.promptStyle, say: this.say };
|
|
2802
|
+
try {
|
|
2803
|
+
return this.promptFor();
|
|
2804
|
+
}
|
|
2805
|
+
catch (cause) {
|
|
2806
|
+
this.complain(`approval: telegram could not read the prompt style; drawing technical: ${this.describe(cause)}`);
|
|
2807
|
+
return { style: DEFAULT_PROMPT_STYLE, say: {} };
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* What a decision on a digest records about its layout (APRV-489): nothing
|
|
2812
|
+
* under a technical policy, and the technical card with its reason under a
|
|
2813
|
+
* minimal one, since a digest is always drawn technical.
|
|
2814
|
+
*/
|
|
2815
|
+
digestRendering(stale) {
|
|
2816
|
+
if (this.currentPrompt().style === "technical")
|
|
2817
|
+
return undefined;
|
|
2818
|
+
return { style: "technical", fallback: stale ? "stale-summary" : "digest" };
|
|
2819
|
+
}
|
|
2105
2820
|
/**
|
|
2106
2821
|
* Forget every nonce issued for `deliveryId`, and report the action key it
|
|
2107
2822
|
* was issued for.
|
|
@@ -2116,6 +2831,7 @@ export class TelegramChannel {
|
|
|
2116
2831
|
*/
|
|
2117
2832
|
disarm(deliveryId) {
|
|
2118
2833
|
let actionKey = "";
|
|
2834
|
+
let card;
|
|
2119
2835
|
// Deleting the current entry mid-iteration is defined behaviour for a Map,
|
|
2120
2836
|
// and every nonce for this message id goes — a re-notify of the same
|
|
2121
2837
|
// message would otherwise leave an older nonce still resolving.
|
|
@@ -2123,6 +2839,7 @@ export class TelegramChannel {
|
|
|
2123
2839
|
if (delivery.deliveryId !== deliveryId)
|
|
2124
2840
|
continue;
|
|
2125
2841
|
actionKey = delivery.actionKey;
|
|
2842
|
+
card = delivery.card ?? card;
|
|
2126
2843
|
this.deliveries.delete(nonce);
|
|
2127
2844
|
}
|
|
2128
2845
|
const digest = this.digests.get(deliveryId);
|
|
@@ -2130,7 +2847,32 @@ export class TelegramChannel {
|
|
|
2130
2847
|
this.allNonces.delete(digest.allNonce);
|
|
2131
2848
|
this.digests.delete(deliveryId);
|
|
2132
2849
|
}
|
|
2133
|
-
return actionKey;
|
|
2850
|
+
return { actionKey, card };
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* How long after delivery the request behind a prompt can still be decided,
|
|
2854
|
+
* on this process's clock, or `null` when nothing bounds it (APRV-423, second
|
|
2855
|
+
* review pass).
|
|
2856
|
+
*
|
|
2857
|
+
* The SHORTER of two bounds, each of which is at or past the gate's own
|
|
2858
|
+
* lapse, so the sweep never forgets a button a decision could still reach:
|
|
2859
|
+
*
|
|
2860
|
+
* - the request's `ttl_remaining_ms`, computed by the listener from the
|
|
2861
|
+
* verified log against the window the gate judges by (the policy's TTL
|
|
2862
|
+
* narrowed by the harness cap the requesting hook declared). It was
|
|
2863
|
+
* measured at tagging time, and delivery is at or after tagging, so
|
|
2864
|
+
* counting it from delivery ends at or after the real lapse;
|
|
2865
|
+
* - the policy's own TTL ({@link TelegramConfig.approvalTtlMs}), counted from
|
|
2866
|
+
* delivery, which is at or after the `approval.requested` it really runs
|
|
2867
|
+
* from. This was the only bound before APRV-423 and it stays as one.
|
|
2868
|
+
*
|
|
2869
|
+
* A request neither bounds is decidable forever, and its entry is dropped
|
|
2870
|
+
* only on an observed settlement. This channel still reads no policy and no
|
|
2871
|
+
* log: both numbers arrive computed, by the verb and by the tagger.
|
|
2872
|
+
*/
|
|
2873
|
+
retentionWindowMs(request) {
|
|
2874
|
+
const bounds = [request.ttl_remaining_ms.value, this.approvalTtlMs].filter((bound) => bound !== null);
|
|
2875
|
+
return bounds.length === 0 ? null : Math.min(...bounds);
|
|
2134
2876
|
}
|
|
2135
2877
|
/**
|
|
2136
2878
|
* Drop the delivery bookkeeping no callback can still be honoured against
|
|
@@ -2166,17 +2908,23 @@ export class TelegramChannel {
|
|
|
2166
2908
|
*/
|
|
2167
2909
|
sweep(nowMs = this.now()) {
|
|
2168
2910
|
this.lastSweepMs = nowMs;
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
|
|
2174
|
-
const
|
|
2911
|
+
// APRV-423 (second review pass): both halves read the ENTRY's own window,
|
|
2912
|
+
// which is the effective one the gate judges its request by, and fall back
|
|
2913
|
+
// to the policy's TTL and then the default only for an entry that carries
|
|
2914
|
+
// none (a review card, which has no request).
|
|
2915
|
+
const retentionOf = (windowMs) => windowMs ?? this.approvalTtlMs ?? TELEGRAM_DEFAULT_RETENTION_MS;
|
|
2916
|
+
const expired = (deliveredAtMs, windowMs) => nowMs - deliveredAtMs >= retentionOf(windowMs);
|
|
2917
|
+
// Past its window the gate refuses every decision on the request, so it is
|
|
2918
|
+
// terminal whether or not this process saw it settle. With nothing bounding
|
|
2919
|
+
// it nothing expires, and only an observed settlement makes it droppable.
|
|
2920
|
+
const lapsed = (deliveredAtMs, windowMs) => windowMs !== null && nowMs - deliveredAtMs >= windowMs;
|
|
2175
2921
|
let digests = 0;
|
|
2176
2922
|
for (const [deliveryId, digest] of this.digests) {
|
|
2177
2923
|
const terminal = digest.members.every((member) => member.settled !== null);
|
|
2178
|
-
if (!(terminal || lapsed(digest.deliveredAtMs
|
|
2924
|
+
if (!(terminal || lapsed(digest.deliveredAtMs, digest.windowMs)) ||
|
|
2925
|
+
!expired(digest.deliveredAtMs, digest.windowMs)) {
|
|
2179
2926
|
continue;
|
|
2927
|
+
}
|
|
2180
2928
|
this.digests.delete(deliveryId);
|
|
2181
2929
|
this.allNonces.delete(digest.allNonce);
|
|
2182
2930
|
digests += 1;
|
|
@@ -2188,8 +2936,10 @@ export class TelegramChannel {
|
|
|
2188
2936
|
// decides, and it was just judged above.
|
|
2189
2937
|
if (this.digests.has(delivery.deliveryId))
|
|
2190
2938
|
continue;
|
|
2191
|
-
if (!lapsed(delivery.deliveredAtMs
|
|
2939
|
+
if (!lapsed(delivery.deliveredAtMs, delivery.windowMs) ||
|
|
2940
|
+
!expired(delivery.deliveredAtMs, delivery.windowMs)) {
|
|
2192
2941
|
continue;
|
|
2942
|
+
}
|
|
2193
2943
|
this.deliveries.delete(nonce);
|
|
2194
2944
|
deliveries += 1;
|
|
2195
2945
|
}
|
|
@@ -2201,7 +2951,7 @@ export class TelegramChannel {
|
|
|
2201
2951
|
// this map holds, so keeping the buttons alive costs a map entry and
|
|
2202
2952
|
// dropping them early would cost a human their thumb.
|
|
2203
2953
|
for (const [deliveryId, card] of this.reviewCards) {
|
|
2204
|
-
if (card.settled === null || !expired(card.deliveredAtMs))
|
|
2954
|
+
if (card.settled === null || !expired(card.deliveredAtMs, null))
|
|
2205
2955
|
continue;
|
|
2206
2956
|
this.reviewCards.delete(deliveryId);
|
|
2207
2957
|
this.reviewNonces.delete(card.nonce);
|
|
@@ -2289,20 +3039,41 @@ export class TelegramChannel {
|
|
|
2289
3039
|
await this.settleMember(digest, actionKey, outcome, detail);
|
|
2290
3040
|
return;
|
|
2291
3041
|
}
|
|
2292
|
-
const
|
|
2293
|
-
const
|
|
3042
|
+
const settled = this.disarm(deliveryId);
|
|
3043
|
+
const technicalText = [
|
|
2294
3044
|
`<b>${escapeHtml(outcome)}</b>`,
|
|
2295
|
-
`<code>${escapeHtml(actionKey ??
|
|
3045
|
+
`<code>${escapeHtml(actionKey ?? settled.actionKey)}</code>`,
|
|
2296
3046
|
"",
|
|
2297
3047
|
...detail.map((entry) => escapeHtml(entry)),
|
|
2298
3048
|
].join("\n");
|
|
2299
|
-
|
|
3049
|
+
// APRV-489. A minimal card keeps its headline and its collapsed details
|
|
3050
|
+
// under the outcome, so the settled message still holds the canonical
|
|
3051
|
+
// rendering the approver answered on. Should that not fit, the technical
|
|
3052
|
+
// annotation is the fallback: the outcome is what the edit must carry.
|
|
3053
|
+
const text = settled.card === undefined ? technicalText : minimalSettleText(outcome, detail, settled.card);
|
|
3054
|
+
const edit = (body) => this.call("editMessageText", {
|
|
2300
3055
|
chat_id: this.chatId,
|
|
2301
3056
|
message_id: Number(deliveryId),
|
|
2302
|
-
text,
|
|
3057
|
+
text: body,
|
|
2303
3058
|
parse_mode: "HTML",
|
|
2304
3059
|
disable_web_page_preview: true,
|
|
2305
3060
|
});
|
|
3061
|
+
if (settled.card === undefined) {
|
|
3062
|
+
await edit(text);
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
// Fix round 2, S3: a minimal card is the only message holding its request,
|
|
3066
|
+
// so a refused settle edit would leave a live-looking card on a decided
|
|
3067
|
+
// request. The short settle text always fits and always says the outcome.
|
|
3068
|
+
try {
|
|
3069
|
+
await edit(text);
|
|
3070
|
+
}
|
|
3071
|
+
catch (cause) {
|
|
3072
|
+
if (!isMessageRefusal(cause) || isMessageNotModified(cause))
|
|
3073
|
+
throw cause;
|
|
3074
|
+
this.complain(`approval: telegram refused the settle edit of ${settled.actionKey}; sending the short form`);
|
|
3075
|
+
await edit(shortSettleText(outcome, actionKey ?? settled.actionKey, detail));
|
|
3076
|
+
}
|
|
2306
3077
|
}
|
|
2307
3078
|
/**
|
|
2308
3079
|
* The withdrawal case of {@link annotate} (APRV-106), and the one the
|
|
@@ -2311,6 +3082,51 @@ export class TelegramChannel {
|
|
|
2311
3082
|
async retract(deliveryId, reason, actionKey) {
|
|
2312
3083
|
await this.annotate(deliveryId, TELEGRAM_TERMINAL_HEADLINES.withdrawn, [reason], actionKey);
|
|
2313
3084
|
}
|
|
3085
|
+
/**
|
|
3086
|
+
* The action keys whose card a refused or failed tap disarmed since the last
|
|
3087
|
+
* call, and forget them (APRV-442).
|
|
3088
|
+
*
|
|
3089
|
+
* The listener's dispatch cycle calls this once per cycle and offers a fresh
|
|
3090
|
+
* card for every key the verified log still calls pending. It is a statement
|
|
3091
|
+
* about this process's cards and nothing else: it carries no decision, no
|
|
3092
|
+
* sender and no outcome, and a key in it that the log has since settled is
|
|
3093
|
+
* simply not offered.
|
|
3094
|
+
*/
|
|
3095
|
+
takeReleased() {
|
|
3096
|
+
const keys = [...this.released];
|
|
3097
|
+
this.released.clear();
|
|
3098
|
+
return keys;
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Mark `delivery`'s card dead and its request released, BEFORE the
|
|
3102
|
+
* annotation that disarms it (APRV-442).
|
|
3103
|
+
*
|
|
3104
|
+
* Every nonce still armed for this request on this message is recorded in
|
|
3105
|
+
* {@link refusedNonces}, together with the nonce the tap carried (which
|
|
3106
|
+
* differs when the tap came from an earlier copy through the fallback), so
|
|
3107
|
+
* none of those bytes can later resolve to the fresh card. On a digest only
|
|
3108
|
+
* this member's nonce is taken: its siblings stay armed on the digest.
|
|
3109
|
+
*/
|
|
3110
|
+
release(delivery, tapNonce) {
|
|
3111
|
+
const dead = [tapNonce];
|
|
3112
|
+
for (const [nonce, entry] of this.deliveries) {
|
|
3113
|
+
if (entry.deliveryId === delivery.deliveryId && entry.actionKey === delivery.actionKey) {
|
|
3114
|
+
dead.push(nonce);
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
for (const nonce of dead) {
|
|
3118
|
+
// Re-inserted so a nonce marked again counts as the newest.
|
|
3119
|
+
this.refusedNonces.delete(nonce);
|
|
3120
|
+
this.refusedNonces.add(nonce);
|
|
3121
|
+
}
|
|
3122
|
+
while (this.refusedNonces.size > TELEGRAM_REFUSED_NONCE_CAP) {
|
|
3123
|
+
const oldest = this.refusedNonces.values().next().value;
|
|
3124
|
+
if (oldest === undefined)
|
|
3125
|
+
break;
|
|
3126
|
+
this.refusedNonces.delete(oldest);
|
|
3127
|
+
}
|
|
3128
|
+
this.released.add(delivery.actionKey);
|
|
3129
|
+
}
|
|
2314
3130
|
/**
|
|
2315
3131
|
* Send one plain message that carries no question (APRV-196).
|
|
2316
3132
|
*
|
|
@@ -2379,6 +3195,7 @@ export class TelegramChannel {
|
|
|
2379
3195
|
* decision", not "deliver once at startup, then wait forever".
|
|
2380
3196
|
*/
|
|
2381
3197
|
async listen(options = {}) {
|
|
3198
|
+
this.claimOrThrow("poll");
|
|
2382
3199
|
this.stopped = false;
|
|
2383
3200
|
let backoff = this.backoffMs;
|
|
2384
3201
|
/**
|
|
@@ -2447,6 +3264,7 @@ export class TelegramChannel {
|
|
|
2447
3264
|
* what {@link listen} catches and retries.
|
|
2448
3265
|
*/
|
|
2449
3266
|
async pollOnce() {
|
|
3267
|
+
this.claimOrThrow("poll");
|
|
2450
3268
|
// APRV-135. Before the long poll, not after: this is where the loop is
|
|
2451
3269
|
// about to block for up to `pollTimeoutSeconds`, and a sweep that ran after
|
|
2452
3270
|
// the block would be a sweep that never runs on a quiet chat. Rate-limited
|
|
@@ -2458,34 +3276,162 @@ export class TelegramChannel {
|
|
|
2458
3276
|
const updates = await this.call("getUpdates", {
|
|
2459
3277
|
offset: this.offset,
|
|
2460
3278
|
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"],
|
|
3279
|
+
allowed_updates: this.allowedUpdates(),
|
|
2471
3280
|
}, this.requestTimeoutMs ?? (this.pollTimeoutSeconds + 10) * 1000);
|
|
2472
|
-
const result =
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
3281
|
+
const result = emptyPollResult();
|
|
3282
|
+
for (const raw of updates)
|
|
3283
|
+
await this.intake(raw, result);
|
|
3284
|
+
return result;
|
|
3285
|
+
}
|
|
3286
|
+
/**
|
|
3287
|
+
* Which update types this channel asks for, on either transport (APRV-424).
|
|
3288
|
+
*
|
|
3289
|
+
* APRV-216. `message` is asked for only while a command handler is
|
|
3290
|
+
* registered: see {@link onCommand} for why a listener that reads messages
|
|
3291
|
+
* nobody asked for would break `approval setup channel telegram`'s chat
|
|
3292
|
+
* discovery. APRV-299 adds the second reason to read messages: a `loved` or
|
|
3293
|
+
* `disliked` on a review card collects the human's words as a REPLY, because
|
|
3294
|
+
* an inline keyboard has no text input.
|
|
3295
|
+
*
|
|
3296
|
+
* One function since APRV-424, because `setWebhook` takes the same list as
|
|
3297
|
+
* `getUpdates` and two spellings of it would be two answers to "what does
|
|
3298
|
+
* this listener read" — with the webhook's answer frozen at registration
|
|
3299
|
+
* time, where nobody would look for it.
|
|
3300
|
+
*/
|
|
3301
|
+
allowedUpdates() {
|
|
3302
|
+
return this.commandHandler === null && this.reviewHandler === null
|
|
3303
|
+
? ["callback_query"]
|
|
3304
|
+
: ["callback_query", "message"];
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* One update, from whichever transport carried it (APRV-424).
|
|
3308
|
+
*
|
|
3309
|
+
* The whole of what the webhook adds is the ARRIVAL. Everything a tap then
|
|
3310
|
+
* passes through — the chat check, `senderOf`, the checkpoint and review
|
|
3311
|
+
* parsers, the resolution ladder, the early ack, `this.handler` and the
|
|
3312
|
+
* annotation written from the record the gate appended — is
|
|
3313
|
+
* {@link handleUpdate}, called from here and from {@link pollOnce}, and
|
|
3314
|
+
* there is no second copy of any of it. `tests/telegram-webhook.test.ts`
|
|
3315
|
+
* drives one callback through both entry points and compares the records the
|
|
3316
|
+
* log ends up holding.
|
|
3317
|
+
*
|
|
3318
|
+
* The sweep is `pollOnce`'s, for `pollOnce`'s reason: this is the moment the
|
|
3319
|
+
* process is about to be idle, and a channel that only ever wakes for taps
|
|
3320
|
+
* would otherwise never run one.
|
|
3321
|
+
*/
|
|
3322
|
+
async deliverUpdate(update) {
|
|
3323
|
+
this.claimOrThrow("webhook");
|
|
3324
|
+
const nowMs = this.now();
|
|
3325
|
+
if (nowMs - this.lastSweepMs >= TELEGRAM_SWEEP_INTERVAL_MS)
|
|
3326
|
+
this.sweep(nowMs);
|
|
3327
|
+
const result = emptyPollResult();
|
|
3328
|
+
await this.intake(update, result);
|
|
3329
|
+
return result;
|
|
3330
|
+
}
|
|
3331
|
+
/** The per-update bookkeeping both transports do, before `handleUpdate`. */
|
|
3332
|
+
async intake(raw, result) {
|
|
3333
|
+
const update = (raw ?? {});
|
|
3334
|
+
const id = update["update_id"];
|
|
3335
|
+
// Meaningless on the webhook, where Telegram acknowledges by the HTTP
|
|
3336
|
+
// response, and harmless: the offset is read by `getUpdates` alone, and a
|
|
3337
|
+
// channel that has claimed the webhook transport never issues one.
|
|
3338
|
+
if (typeof id === "number")
|
|
3339
|
+
this.offset = Math.max(this.offset, id + 1);
|
|
3340
|
+
this.counters.updates += 1;
|
|
3341
|
+
result.updates += 1;
|
|
3342
|
+
await this.handleUpdate(update, result);
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Bind this channel to one transport, or say which one already holds it
|
|
3346
|
+
* (APRV-424).
|
|
3347
|
+
*
|
|
3348
|
+
* Idempotent for the transport already claimed, so a webhook server that
|
|
3349
|
+
* claims at start-up and then delivers, or a driver calling `pollOnce` in a
|
|
3350
|
+
* loop, pays nothing. Not released by {@link stop}: a process that stopped
|
|
3351
|
+
* polling has not stopped being a poller, and re-pointing a live channel's
|
|
3352
|
+
* delivery maps at a second arrival path is the state this exists to refuse.
|
|
3353
|
+
*/
|
|
3354
|
+
claimTransport(transport) {
|
|
3355
|
+
if (this.transport === null || this.transport === transport) {
|
|
3356
|
+
this.transport = transport;
|
|
3357
|
+
return { ok: true, transport };
|
|
3358
|
+
}
|
|
3359
|
+
return {
|
|
3360
|
+
ok: false,
|
|
3361
|
+
code: "transport-conflict",
|
|
3362
|
+
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`,
|
|
2478
3363
|
};
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
3364
|
+
}
|
|
3365
|
+
/** {@link claimTransport}, for the paths where a refusal is a caller's bug. */
|
|
3366
|
+
claimOrThrow(transport) {
|
|
3367
|
+
const claim = this.claimTransport(transport);
|
|
3368
|
+
if (!claim.ok)
|
|
3369
|
+
throw new TelegramTransportError(claim.message);
|
|
3370
|
+
}
|
|
3371
|
+
// -------------------------------------------------------------------------
|
|
3372
|
+
// Webhook registration (APRV-424)
|
|
3373
|
+
// -------------------------------------------------------------------------
|
|
3374
|
+
/**
|
|
3375
|
+
* Point this bot's updates at `url`, and require `secret` on every post.
|
|
3376
|
+
*
|
|
3377
|
+
* Three properties, and each is a refusal to be clever:
|
|
3378
|
+
*
|
|
3379
|
+
* - **The secret is Telegram's own mechanism**, `secret_token`, echoed back
|
|
3380
|
+
* on every delivery in the `X-Telegram-Bot-Api-Secret-Token` header. The
|
|
3381
|
+
* receiver compares it and refuses anything else, so a URL that leaks (a
|
|
3382
|
+
* proxy log, a referrer, a screenshot) is a URL that still decides
|
|
3383
|
+
* nothing.
|
|
3384
|
+
* - **It is redacted from this moment on.** The value is added to the
|
|
3385
|
+
* channel's secret list BEFORE the call, so a Bot API error that quotes
|
|
3386
|
+
* the request cannot print it.
|
|
3387
|
+
* - **Pending updates are kept.** `drop_pending_updates` is left false:
|
|
3388
|
+
* taps that arrived while the process was down are the approver's answers,
|
|
3389
|
+
* and the gate is what decides whether they are still honourable.
|
|
3390
|
+
*/
|
|
3391
|
+
async registerWebhook(url, secret) {
|
|
3392
|
+
if (secret.length > 0 && !this.secrets.some((held) => held.value === secret)) {
|
|
3393
|
+
this.secrets.push({ value: secret, label: "<webhook secret redacted>" });
|
|
2487
3394
|
}
|
|
2488
|
-
|
|
3395
|
+
await this.call("setWebhook", {
|
|
3396
|
+
url,
|
|
3397
|
+
secret_token: secret,
|
|
3398
|
+
allowed_updates: this.allowedUpdates(),
|
|
3399
|
+
drop_pending_updates: false,
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Take this bot's webhook away, so `getUpdates` works again.
|
|
3404
|
+
*
|
|
3405
|
+
* Called on a clean stop of the webhook runner. Pending updates are kept for
|
|
3406
|
+
* {@link registerWebhook}'s reason: the next process to start — a poller or
|
|
3407
|
+
* another webhook — is entitled to the taps that arrived in between.
|
|
3408
|
+
*
|
|
3409
|
+
* `timeoutMs` is what the stop path passes, and it is shorter than this
|
|
3410
|
+
* channel's ordinary request timeout on purpose (APRV-424, second review,
|
|
3411
|
+
* note 6). A stop already waits for the receiver to drain, and an operator
|
|
3412
|
+
* pressing Ctrl-C a second time is asking for the process to end: half a
|
|
3413
|
+
* minute of an unreachable Bot API on top of that reads as a hang, and the
|
|
3414
|
+
* webhook it failed to remove is reported rather than silently retried.
|
|
3415
|
+
*/
|
|
3416
|
+
async deleteWebhook(timeoutMs) {
|
|
3417
|
+
await this.call("deleteWebhook", { drop_pending_updates: false }, ...(timeoutMs === undefined ? [] : [timeoutMs]));
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* What the Bot API says is receiving this bot's updates right now.
|
|
3421
|
+
*
|
|
3422
|
+
* The one authority on the question, and the reason it is asked at start-up
|
|
3423
|
+
* by both transports: the ownership registry knows about processes on THIS
|
|
3424
|
+
* machine, and a webhook registered from a laptop in another timezone is
|
|
3425
|
+
* still a webhook swallowing every tap this poller is waiting for.
|
|
3426
|
+
*/
|
|
3427
|
+
async webhookInfo() {
|
|
3428
|
+
const result = await this.call("getWebhookInfo", {});
|
|
3429
|
+
const url = result["url"];
|
|
3430
|
+
const pending = result["pending_update_count"];
|
|
3431
|
+
return {
|
|
3432
|
+
url: typeof url === "string" ? url : "",
|
|
3433
|
+
pendingUpdateCount: typeof pending === "number" ? pending : 0,
|
|
3434
|
+
};
|
|
2489
3435
|
}
|
|
2490
3436
|
/**
|
|
2491
3437
|
* Exactly one `answerCallbackQuery` per callback query, on every path
|
|
@@ -2676,7 +3622,21 @@ export class TelegramChannel {
|
|
|
2676
3622
|
// the log when neither is holding the action open.
|
|
2677
3623
|
let delivery = this.deliveries.get(parsed.nonce);
|
|
2678
3624
|
let viaStaleCopy = false;
|
|
2679
|
-
|
|
3625
|
+
// APRV-442. A card a refused tap disarmed stays dead: its bytes take no
|
|
3626
|
+
// fallback to the fresh card the listener sends for the same request, so
|
|
3627
|
+
// a redelivered or replayed refused tap reaches neither the gate nor the
|
|
3628
|
+
// new card, exactly as it did before that card existed.
|
|
3629
|
+
const refusedCard = delivery === undefined && this.refusedNonces.has(parsed.nonce);
|
|
3630
|
+
// APRV-456. With the fallback off, a nonce this process is not holding is
|
|
3631
|
+
// the end of the road: no action-reference lookup, no log probe, and never
|
|
3632
|
+
// the decision handler. Behind a relay, a button carrying a real action's
|
|
3633
|
+
// reference may sit under text this gate never wrote, and the approver's
|
|
3634
|
+
// genuine tap would otherwise pass the chat check and the sender mapping.
|
|
3635
|
+
if (delivery === undefined && !refusedCard && !this.staleCopy) {
|
|
3636
|
+
await this.refuseUnissued(parsed, callbackId, result, sender);
|
|
3637
|
+
return;
|
|
3638
|
+
}
|
|
3639
|
+
if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
|
|
2680
3640
|
// The pre-restart copy. Its nonce died with the process that issued it,
|
|
2681
3641
|
// but the request it names is one THIS process has since re-delivered, so
|
|
2682
3642
|
// the tap decides that request — on the live copy's message, which is
|
|
@@ -2687,18 +3647,21 @@ export class TelegramChannel {
|
|
|
2687
3647
|
viaStaleCopy = delivery !== undefined;
|
|
2688
3648
|
}
|
|
2689
3649
|
if (delivery === undefined) {
|
|
2690
|
-
if (parsed.actionRef !== null) {
|
|
3650
|
+
if (parsed.actionRef !== null && !refusedCard) {
|
|
2691
3651
|
// Nothing open here for that action. Say what the log says, which is
|
|
2692
3652
|
// the only thing that knows: decided, lapsed, withdrawn, or unknown.
|
|
2693
3653
|
const described = this.describeAction?.(parsed.actionRef) ?? null;
|
|
2694
3654
|
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
3655
|
return;
|
|
2696
3656
|
}
|
|
2697
|
-
await this.ignore(result, callbackId, "unknown-callback",
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
//
|
|
2701
|
-
//
|
|
3657
|
+
await this.ignore(result, callbackId, "unknown-callback", refusedCard
|
|
3658
|
+
? `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`
|
|
3659
|
+
: `no delivery for nonce ${JSON.stringify(parsed.nonce)} (a restarted listener forgets its buttons; the pending queue is re-sent on start)`,
|
|
3660
|
+
// Three ways to get here, and the reply has to serve all of them: a
|
|
3661
|
+
// button this process never issued (a restart forgot it), a button on a
|
|
3662
|
+
// message this process has already annotated (APRV-113 forgets the
|
|
3663
|
+
// nonce with the edit), and a button on a card a refused tap disarmed
|
|
3664
|
+
// (APRV-442). Either way the message text is the thing to read.
|
|
2702
3665
|
"This button is no longer live — read the message for the outcome, or the newest message for the request.");
|
|
2703
3666
|
return;
|
|
2704
3667
|
}
|
|
@@ -2714,6 +3677,9 @@ export class TelegramChannel {
|
|
|
2714
3677
|
...(delivery.batchDeliveryId === undefined
|
|
2715
3678
|
? {}
|
|
2716
3679
|
: { batchDeliveryId: delivery.batchDeliveryId }),
|
|
3680
|
+
// APRV-489. The layout THIS card was drawn in, from this process's own
|
|
3681
|
+
// record of what it sent; nothing in the tap chooses it.
|
|
3682
|
+
...(delivery.rendering === undefined ? {} : { rendering: delivery.rendering }),
|
|
2717
3683
|
...(parsed.decision === "reject"
|
|
2718
3684
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
|
|
2719
3685
|
: {}),
|
|
@@ -2739,6 +3705,9 @@ export class TelegramChannel {
|
|
|
2739
3705
|
outcome = this.handler(decision);
|
|
2740
3706
|
}
|
|
2741
3707
|
catch (cause) {
|
|
3708
|
+
// APRV-442: the card is about to be disarmed and nothing settled the
|
|
3709
|
+
// request. Marked BEFORE the annotation, which forgets the nonces.
|
|
3710
|
+
this.release(delivery, parsed.nonce);
|
|
2742
3711
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2743
3712
|
TELEGRAM_HANDLER_FAILED,
|
|
2744
3713
|
]);
|
|
@@ -2763,6 +3732,18 @@ export class TelegramChannel {
|
|
|
2763
3732
|
// message, which is right in both directions: a terminal request has no
|
|
2764
3733
|
// decision left to collect, and a still-pending one is re-delivered as a
|
|
2765
3734
|
// fresh prompt by the next dispatch cycle.
|
|
3735
|
+
//
|
|
3736
|
+
// APRV-442. That re-delivery used to be a promise nothing kept: the
|
|
3737
|
+
// disarm forgets the card's nonces, but the listener's own bookkeeping
|
|
3738
|
+
// went on saying the request was on the phone, so the cycle never sent
|
|
3739
|
+
// it again and the original card's action-reference fallback found
|
|
3740
|
+
// nothing live. A wrong account's tap made the prompt dead until a
|
|
3741
|
+
// restart. Releasing names this CARD as gone and nothing else; the cycle
|
|
3742
|
+
// re-derives from the verified log whether the request is still pending
|
|
3743
|
+
// before it offers a new one. Marked BEFORE the annotation, which forgets
|
|
3744
|
+
// the nonces it records. The refusal itself, its record and its wording
|
|
3745
|
+
// are untouched.
|
|
3746
|
+
this.release(delivery, parsed.nonce);
|
|
2766
3747
|
await this.annotateQuietly(delivery.deliveryId, delivery.actionKey, TELEGRAM_NOT_RECORDED, [
|
|
2767
3748
|
this.answerFor(outcome),
|
|
2768
3749
|
]);
|
|
@@ -2825,12 +3806,14 @@ export class TelegramChannel {
|
|
|
2825
3806
|
let landed = 0;
|
|
2826
3807
|
const refusals = [];
|
|
2827
3808
|
for (const member of open) {
|
|
3809
|
+
const allRendering = this.digestRendering(digest.stale !== null);
|
|
2828
3810
|
const one = {
|
|
2829
3811
|
action_key: member.actionKey,
|
|
2830
3812
|
decision,
|
|
2831
3813
|
deliveryId: digest.deliveryId,
|
|
2832
3814
|
batchDeliveryId: digest.batchDeliveryId,
|
|
2833
3815
|
...(sender === undefined ? {} : { sender }),
|
|
3816
|
+
...(allRendering === undefined ? {} : { rendering: allRendering }),
|
|
2834
3817
|
...(decision === "reject"
|
|
2835
3818
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
|
|
2836
3819
|
: {}),
|
|
@@ -3000,31 +3983,88 @@ export class TelegramChannel {
|
|
|
3000
3983
|
await this.ignore(result, callbackId, "unknown-callback", "a review tap arrived before the runtime registered a review handler", "The runtime is not ready to record reviews.");
|
|
3001
3984
|
return;
|
|
3002
3985
|
}
|
|
3986
|
+
// PR #614 refutation F3. An armed Deny and a held grade are one account's
|
|
3987
|
+
// unfinished review. A tap that would finish either (any verdict, or a
|
|
3988
|
+
// grade landing on an armed Deny) from a different account is refused on
|
|
3989
|
+
// the card, so a record never carries a grade or a denial its reviewer did
|
|
3990
|
+
// not tap. A grade tapped with nothing armed only replaces the held grade
|
|
3991
|
+
// and is not refused: it finishes nothing, and its new holder is the tapper.
|
|
3992
|
+
const tapper = sender === undefined ? null : sender.id;
|
|
3993
|
+
const finishes = tap.choice === "ok" || tap.choice === "deny" || state.denyArmed;
|
|
3994
|
+
const othersArm = state.denyArmed && state.armedBy !== tapper;
|
|
3995
|
+
const othersGrade = state.heldReaction !== null && state.heldBy !== tapper;
|
|
3996
|
+
if (finishes && (othersArm || (othersGrade && (tap.choice === "ok" || tap.choice === "deny")))) {
|
|
3997
|
+
state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_REVIEW_OTHER_SENDER] };
|
|
3998
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_OTHER_SENDER);
|
|
3999
|
+
await this.redrawReview(state);
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
3003
4002
|
// The first Deny tap arms and writes nothing. Stated on the card, so the
|
|
3004
4003
|
// approver reads the state rather than inferring it from a toast.
|
|
4004
|
+
//
|
|
4005
|
+
// PR #614 recheck NF-1: arming is earned the way a held grade is. The tap
|
|
4006
|
+
// goes to the runtime with NO verdict, and the card arms only when the
|
|
4007
|
+
// answer is `verdict-required`, which the runtime gives only after the
|
|
4008
|
+
// sender and the class's roster have passed. An unmapped or off-roster
|
|
4009
|
+
// account's Deny is refused on the card with its own code and arms
|
|
4010
|
+
// nothing, so it cannot leave a half-finished denial that blocks the
|
|
4011
|
+
// approvers' taps.
|
|
3005
4012
|
if (tap.choice === "deny" && !state.denyArmed) {
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
4013
|
+
const answered = await this.recordReview(state, {
|
|
4014
|
+
sampleSeq: state.card.sampleSeq,
|
|
4015
|
+
...(sender === undefined ? {} : { sender }),
|
|
4016
|
+
}, result, { kind: "deny", by: tapper });
|
|
4017
|
+
await this.safeAnswer(callbackId, state.denyArmed ? TELEGRAM_REVIEW_ARM_TOAST : answered?.toast ?? TELEGRAM_NOT_RECORDED);
|
|
3010
4018
|
return;
|
|
3011
4019
|
}
|
|
3012
|
-
const verdict = state.denyArmed ? "denied" : "ok";
|
|
3013
4020
|
if (tap.choice === "ok" || tap.choice === "deny") {
|
|
3014
4021
|
// `ok` with deny armed is a correction, and it disarms: a human who
|
|
3015
4022
|
// reached for Deny and then chose OK meant OK, and nothing was written in
|
|
3016
4023
|
// between for the change of mind to contradict.
|
|
3017
4024
|
const chosen = tap.choice === "deny" ? "denied" : "ok";
|
|
3018
4025
|
state.denyArmed = chosen === "denied";
|
|
4026
|
+
if (!state.denyArmed)
|
|
4027
|
+
state.armedBy = null;
|
|
4028
|
+
// APRV-482: a grade held from before the verdict rides with it, and a
|
|
4029
|
+
// grade that wants words asks for them now, exactly as a grade tapped
|
|
4030
|
+
// after an armed Deny always has. The pair core refuses outright is sent
|
|
4031
|
+
// straight through, so no note is collected for a record that will not
|
|
4032
|
+
// exist.
|
|
4033
|
+
const held = state.heldReaction;
|
|
4034
|
+
if (held !== null) {
|
|
4035
|
+
const conflicts = chosen === "denied" && (held === "liked" || held === "loved");
|
|
4036
|
+
if (!conflicts && (held === "loved" || held === "disliked")) {
|
|
4037
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
|
|
4038
|
+
await this.askForNote(state, chosen, held, sender);
|
|
4039
|
+
return;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
3019
4042
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
3020
4043
|
await this.recordReview(state, {
|
|
3021
4044
|
sampleSeq: state.card.sampleSeq,
|
|
3022
4045
|
verdict: chosen,
|
|
4046
|
+
...(held === null ? {} : { reaction: held }),
|
|
3023
4047
|
...(sender === undefined ? {} : { sender }),
|
|
3024
4048
|
}, result);
|
|
3025
4049
|
return;
|
|
3026
4050
|
}
|
|
3027
4051
|
const reaction = tap.choice;
|
|
4052
|
+
if (!state.denyArmed) {
|
|
4053
|
+
// APRV-482. A grade with no verdict is not an approval. The card holds
|
|
4054
|
+
// the grade (process memory, stated in its heading) and the tap still
|
|
4055
|
+
// goes to the runtime with NO verdict, so the refusal on the card is the
|
|
4056
|
+
// core's own `verdict-required` and its words, and the operator's stderr
|
|
4057
|
+
// says the same. No note is asked for: words for a record that is about
|
|
4058
|
+
// to be refused would be attention spent on nothing.
|
|
4059
|
+
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
4060
|
+
await this.recordReview(state, {
|
|
4061
|
+
sampleSeq: state.card.sampleSeq,
|
|
4062
|
+
reaction,
|
|
4063
|
+
...(sender === undefined ? {} : { sender }),
|
|
4064
|
+
}, result, { kind: "grade", reaction, by: tapper });
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
4067
|
+
const verdict = "denied";
|
|
3028
4068
|
// The two grades that require the human's own words ask for them FIRST, and
|
|
3029
4069
|
// nothing is appended until the reply arrives. The exception is the pair
|
|
3030
4070
|
// `core/audit.ts` refuses outright: a denied review that says liked or
|
|
@@ -3056,26 +4096,75 @@ export class TelegramChannel {
|
|
|
3056
4096
|
* stays open, and a fresh card is offered — and can never cost a record
|
|
3057
4097
|
* nobody asked for.
|
|
3058
4098
|
*
|
|
3059
|
-
*
|
|
3060
|
-
*
|
|
3061
|
-
* nowhere rather than recording a grade the human moved on from.
|
|
4099
|
+
* Only one grade can be outstanding on one card, so a second prompt that is
|
|
4100
|
+
* SENT replaces the first, and the older prompt stops resolving: a late reply
|
|
4101
|
+
* to it lands nowhere rather than recording a grade the human moved on from.
|
|
4102
|
+
*
|
|
4103
|
+
* APRV-492, and the relay licenses one prompt per GRADE tap, so which taps
|
|
4104
|
+
* get a prompt sent is not this method's choice alone. Three rules:
|
|
4105
|
+
*
|
|
4106
|
+
* - A tap that would ask the SAME question again (same verdict, same grade,
|
|
4107
|
+
* same account: a doubled OK or a doubled second Deny) sends nothing. The
|
|
4108
|
+
* prompt on screen already collects those words, and a second send would
|
|
4109
|
+
* be refused.
|
|
4110
|
+
* - A prompt for a DIFFERENT VERDICT retires the old one BEFORE the send. A
|
|
4111
|
+
* verdict change is not a grade tap, so the relay refuses that send, and an
|
|
4112
|
+
* old prompt left live would record the verdict the human just changed. A
|
|
4113
|
+
* note may be lost this way; a wrong verdict is never recorded.
|
|
4114
|
+
* - A prompt for the same verdict and a different grade follows a grade tap,
|
|
4115
|
+
* which the relay licenses, and retires the old one only once the new send
|
|
4116
|
+
* has succeeded. A send that fails anyway (the network, not the relay)
|
|
4117
|
+
* leaves the old prompt live, and a reply to it records the grade the old
|
|
4118
|
+
* prompt names in its own text, not the one the card now holds: an
|
|
4119
|
+
* accepted residual (PR #619 refutation S2).
|
|
4120
|
+
*
|
|
4121
|
+
* A send that fails says so on the card, because the toast already told the
|
|
4122
|
+
* human to reply to a prompt: the note was not asked for, nothing was
|
|
4123
|
+
* recorded, and a grade tap asks again. Then it throws, so the operator hears
|
|
4124
|
+
* of it. A later prompt that is sent clears that line.
|
|
3062
4125
|
*/
|
|
3063
4126
|
async askForNote(state, verdict, reaction, sender) {
|
|
3064
|
-
|
|
3065
|
-
|
|
4127
|
+
const waiting = state.awaitingNote;
|
|
4128
|
+
if (waiting !== null &&
|
|
4129
|
+
waiting.verdict === verdict &&
|
|
4130
|
+
waiting.reaction === reaction &&
|
|
4131
|
+
waiting.sender?.id === sender?.id &&
|
|
4132
|
+
this.reviewNotePrompts.get(waiting.promptId) === state.deliveryId) {
|
|
4133
|
+
return;
|
|
4134
|
+
}
|
|
4135
|
+
if (waiting !== null && waiting.verdict !== verdict) {
|
|
4136
|
+
this.reviewNotePrompts.delete(waiting.promptId);
|
|
4137
|
+
state.awaitingNote = null;
|
|
4138
|
+
}
|
|
3066
4139
|
const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
.
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
4140
|
+
let sent;
|
|
4141
|
+
try {
|
|
4142
|
+
sent = await this.call("sendMessage", {
|
|
4143
|
+
chat_id: this.chatId,
|
|
4144
|
+
text: lines
|
|
4145
|
+
.map((entry, index) => (index === 0 ? `<b>${escapeHtml(entry)}</b>` : escapeHtml(entry)))
|
|
4146
|
+
.join("\n"),
|
|
4147
|
+
parse_mode: "HTML",
|
|
4148
|
+
disable_web_page_preview: true,
|
|
4149
|
+
reply_markup: { force_reply: true },
|
|
4150
|
+
});
|
|
4151
|
+
}
|
|
4152
|
+
catch (cause) {
|
|
4153
|
+
state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [telegramReviewNoteUnasked(verdict)] };
|
|
4154
|
+
await this.redrawReview(state);
|
|
4155
|
+
throw cause;
|
|
4156
|
+
}
|
|
3076
4157
|
const promptId = String(sent.message_id);
|
|
4158
|
+
if (state.awaitingNote !== null)
|
|
4159
|
+
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
3077
4160
|
state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
|
|
3078
4161
|
this.reviewNotePrompts.set(promptId, state.deliveryId);
|
|
4162
|
+
const unasked = state.notice?.lines;
|
|
4163
|
+
if (unasked?.length === 1 &&
|
|
4164
|
+
(unasked[0] === telegramReviewNoteUnasked("ok") || unasked[0] === telegramReviewNoteUnasked("denied"))) {
|
|
4165
|
+
state.notice = null;
|
|
4166
|
+
await this.redrawReview(state);
|
|
4167
|
+
}
|
|
3079
4168
|
}
|
|
3080
4169
|
/**
|
|
3081
4170
|
* A message replying to an outstanding note prompt (APRV-299).
|
|
@@ -3147,13 +4236,25 @@ export class TelegramChannel {
|
|
|
3147
4236
|
* to say which half they meant, and `note-required` asks for words — so the
|
|
3148
4237
|
* buttons stay, with the refusal rendered above them and the arming intact.
|
|
3149
4238
|
*/
|
|
3150
|
-
async recordReview(state, tap, result
|
|
4239
|
+
async recordReview(state, tap, result,
|
|
4240
|
+
/**
|
|
4241
|
+
* A lone grade to hold, or a Deny to arm, if the runtime answers
|
|
4242
|
+
* `verdict-required` (PR #614 refutation N6/F3, recheck NF-1): only then
|
|
4243
|
+
* has the tapper passed the sender and roster checks, so only then is the
|
|
4244
|
+
* half-finished review theirs to finish.
|
|
4245
|
+
*/
|
|
4246
|
+
hold) {
|
|
3151
4247
|
const handler = this.reviewHandler;
|
|
3152
4248
|
if (handler === null)
|
|
3153
|
-
return;
|
|
4249
|
+
return null;
|
|
4250
|
+
// APRV-481: whether the bytes were on this card is the card's own fact,
|
|
4251
|
+
// decided by the same pure view that drew it, so the record's payload hash
|
|
4252
|
+
// can never name more than the reviewer was shown.
|
|
4253
|
+
const view = reviewPayloadView(state.card);
|
|
4254
|
+
const sent = view.kind === "bytes" ? { ...tap, payloadHash: view.hash } : tap;
|
|
3154
4255
|
let response;
|
|
3155
4256
|
try {
|
|
3156
|
-
response = await handler(
|
|
4257
|
+
response = await handler(sent);
|
|
3157
4258
|
}
|
|
3158
4259
|
catch (cause) {
|
|
3159
4260
|
state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
|
|
@@ -3161,11 +4262,14 @@ export class TelegramChannel {
|
|
|
3161
4262
|
throw cause;
|
|
3162
4263
|
}
|
|
3163
4264
|
this.counters.reviews += 1;
|
|
3164
|
-
result.reviews.push({ tap, ok: response.ok });
|
|
4265
|
+
result.reviews.push({ tap: sent, ok: response.ok });
|
|
3165
4266
|
if (response.ok) {
|
|
3166
4267
|
state.settled = { headline: response.headline, detail: response.detail };
|
|
3167
4268
|
state.notice = null;
|
|
3168
4269
|
state.denyArmed = false;
|
|
4270
|
+
state.heldReaction = null;
|
|
4271
|
+
state.armedBy = null;
|
|
4272
|
+
state.heldBy = null;
|
|
3169
4273
|
this.reviewNonces.delete(state.nonce);
|
|
3170
4274
|
if (state.awaitingNote !== null) {
|
|
3171
4275
|
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
@@ -3174,8 +4278,23 @@ export class TelegramChannel {
|
|
|
3174
4278
|
}
|
|
3175
4279
|
else {
|
|
3176
4280
|
state.notice = { headline: response.headline, lines: response.detail };
|
|
4281
|
+
if (hold !== undefined && response.code === "verdict-required") {
|
|
4282
|
+
if (hold.kind === "grade") {
|
|
4283
|
+
state.heldReaction = hold.reaction;
|
|
4284
|
+
state.heldBy = hold.by;
|
|
4285
|
+
}
|
|
4286
|
+
else {
|
|
4287
|
+
// The armed state is the card's message; the refusal that earned it
|
|
4288
|
+
// is the runtime's word for "a verdict is still needed", which the
|
|
4289
|
+
// armed heading already says.
|
|
4290
|
+
state.denyArmed = true;
|
|
4291
|
+
state.armedBy = hold.by;
|
|
4292
|
+
state.notice = null;
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
3177
4295
|
}
|
|
3178
4296
|
await this.redrawReview(state);
|
|
4297
|
+
return response;
|
|
3179
4298
|
}
|
|
3180
4299
|
/** One `editMessageText` that replaces a review card's text and its keyboard. */
|
|
3181
4300
|
async redrawReview(state) {
|
|
@@ -3200,6 +4319,81 @@ export class TelegramChannel {
|
|
|
3200
4319
|
this.complain(`approval: telegram could not redraw the review card for sample seq ${String(state.card.sampleSeq)} (message ${state.deliveryId}): ${this.describe(cause)} — the log is what it is; only the message is stale`);
|
|
3201
4320
|
}
|
|
3202
4321
|
}
|
|
4322
|
+
/**
|
|
4323
|
+
* Refuse a decision tap whose nonce this process is not holding, with the
|
|
4324
|
+
* stale-copy fallback off (APRV-456).
|
|
4325
|
+
*
|
|
4326
|
+
* What it does, in order, and what it never does:
|
|
4327
|
+
*
|
|
4328
|
+
* - It never calls the decision handler, so the gate is never asked. That is
|
|
4329
|
+
* the property: a button this process did not put on the screen cannot
|
|
4330
|
+
* carry a human's tap to `decide()`, whatever reference it carries.
|
|
4331
|
+
* - It looks the reference up among the deliveries THIS process holds open,
|
|
4332
|
+
* and only to name the action on the audit record. A reference that names
|
|
4333
|
+
* nothing open here produces no record: there is no action key this
|
|
4334
|
+
* process could truthfully write, and inventing one from bytes that came
|
|
4335
|
+
* off the network is the self-report §11.1 invariant 4 forbids.
|
|
4336
|
+
* - It hands at most one record per nonce to {@link onRefusedDecision}: a
|
|
4337
|
+
* redelivered or replayed tap on the same button is refused with the same
|
|
4338
|
+
* toast and records nothing.
|
|
4339
|
+
* - It leaves the live card alone. The request stays pending, and the card
|
|
4340
|
+
* this process did send still decides it.
|
|
4341
|
+
*
|
|
4342
|
+
* "Not holding" covers a nonce this process never issued and one it issued
|
|
4343
|
+
* and has since forgotten (an annotated card, a swept delivery, a restart).
|
|
4344
|
+
* It does not try to tell them apart: a set of every nonce ever issued would
|
|
4345
|
+
* reopen the hole for anyone who has seen one genuine button's bytes, because
|
|
4346
|
+
* an old nonce with a new reference would pass it. The cost is that a tap on
|
|
4347
|
+
* an earlier copy after a restart is refused on this shape and the approver
|
|
4348
|
+
* taps the newest card, which the restart banner says.
|
|
4349
|
+
*/
|
|
4350
|
+
async refuseUnissued(parsed, callbackId, result, sender) {
|
|
4351
|
+
this.counters.staleCopyRefusals += 1;
|
|
4352
|
+
const live = parsed.actionRef === null ? undefined : this.liveDeliveryFor(parsed.actionRef);
|
|
4353
|
+
const repeat = this.unissuedRefused.has(parsed.nonce);
|
|
4354
|
+
// Re-inserted so a nonce refused again counts as the newest, and capped:
|
|
4355
|
+
// memory, never a decision. A nonce that falls off the end is refused again
|
|
4356
|
+
// and may record again, which is volume and nothing else.
|
|
4357
|
+
this.unissuedRefused.delete(parsed.nonce);
|
|
4358
|
+
this.unissuedRefused.add(parsed.nonce);
|
|
4359
|
+
while (this.unissuedRefused.size > TELEGRAM_REFUSED_NONCE_CAP) {
|
|
4360
|
+
const oldest = this.unissuedRefused.values().next().value;
|
|
4361
|
+
if (oldest === undefined)
|
|
4362
|
+
break;
|
|
4363
|
+
this.unissuedRefused.delete(oldest);
|
|
4364
|
+
}
|
|
4365
|
+
// APRV-456 refutation S4: the nonce is NOT quoted here. This message becomes
|
|
4366
|
+
// `payload.message` on the audit record, and the nonce is bytes whoever
|
|
4367
|
+
// made the card chose; quoting it would let a forger write text of their
|
|
4368
|
+
// choosing into the hash-chained log, one tap at a time.
|
|
4369
|
+
const message = `${parsed.decision} tap refused: its button carries a nonce this listener is not holding, and this listener's stale-copy fallback is off (a relayed channel, or --no-stale-copy). It was not carried to the gate by its action reference, because behind a relay a button with a real request's reference can sit under text this gate never sent. Nothing was decided; the request, if pending, is still open on the newest card this listener sent.`;
|
|
4370
|
+
const refusal = { ok: false, code: "nonce-not-issued", message };
|
|
4371
|
+
result.refused.push({
|
|
4372
|
+
code: refusal.code,
|
|
4373
|
+
action_key: live?.actionKey ?? null,
|
|
4374
|
+
detail: message,
|
|
4375
|
+
});
|
|
4376
|
+
this.complain(`approval: telegram refused a callback (nonce-not-issued): nonce ${JSON.stringify(parsed.nonce)}${live === undefined ? "" : ` for ${live.actionKey}`}${repeat ? " (again; not recorded twice)" : ""}`);
|
|
4377
|
+
// The toast first: it is the only answer this query gets, and the record
|
|
4378
|
+
// below changes nothing about what the person is told.
|
|
4379
|
+
await this.safeAnswer(callbackId, refusedDecisionLine(refusal.code));
|
|
4380
|
+
if (live === undefined || repeat || this.refusedDecisionHandler === null)
|
|
4381
|
+
return;
|
|
4382
|
+
const decision = {
|
|
4383
|
+
action_key: live.actionKey,
|
|
4384
|
+
decision: parsed.decision,
|
|
4385
|
+
deliveryId: live.deliveryId,
|
|
4386
|
+
...(sender === undefined ? {} : { sender }),
|
|
4387
|
+
};
|
|
4388
|
+
try {
|
|
4389
|
+
this.refusedDecisionHandler({ decision, refusal });
|
|
4390
|
+
}
|
|
4391
|
+
catch (cause) {
|
|
4392
|
+
// Best effort by contract: the refusal has happened and been told; only
|
|
4393
|
+
// the record is lost, and the operator hears that here.
|
|
4394
|
+
this.complain(`approval: telegram could not record a nonce-not-issued refusal for ${live.actionKey}: ${this.describe(cause)}`);
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
3203
4397
|
async ignore(result, callbackId, kind, detail, reply) {
|
|
3204
4398
|
this.counters.anomalies[kind] += 1;
|
|
3205
4399
|
result.ignored.push({ kind, detail });
|
|
@@ -3274,9 +4468,20 @@ export class TelegramChannel {
|
|
|
3274
4468
|
// -------------------------------------------------------------------------
|
|
3275
4469
|
// Transport
|
|
3276
4470
|
// -------------------------------------------------------------------------
|
|
3277
|
-
/**
|
|
4471
|
+
/**
|
|
4472
|
+
* Replace every secret this channel holds with a placeholder in `text`.
|
|
4473
|
+
*
|
|
4474
|
+
* The bot token always, and since APRV-424 the webhook `secret_token` too,
|
|
4475
|
+
* from the moment {@link registerWebhook} is handed it. Both are values that
|
|
4476
|
+
* only ever travel TO the Bot API, so anything coming back that quotes one
|
|
4477
|
+
* is a diagnostic about to print a credential.
|
|
4478
|
+
*/
|
|
3278
4479
|
redact(text) {
|
|
3279
|
-
|
|
4480
|
+
let redacted = text;
|
|
4481
|
+
for (const secret of this.secrets) {
|
|
4482
|
+
redacted = redacted.split(secret.value).join(secret.label);
|
|
4483
|
+
}
|
|
4484
|
+
return redacted;
|
|
3280
4485
|
}
|
|
3281
4486
|
describe(cause) {
|
|
3282
4487
|
return this.redact(cause instanceof Error ? cause.message : String(cause));
|
|
@@ -3291,26 +4496,30 @@ export class TelegramChannel {
|
|
|
3291
4496
|
* replace the real reason with a worse one.
|
|
3292
4497
|
*/
|
|
3293
4498
|
async describeFailure(response) {
|
|
4499
|
+
const nothing = { description: null, retryAfterSeconds: null };
|
|
3294
4500
|
let body;
|
|
3295
4501
|
try {
|
|
3296
4502
|
body = await response.text();
|
|
3297
4503
|
}
|
|
3298
4504
|
catch {
|
|
3299
|
-
return
|
|
4505
|
+
return nothing;
|
|
3300
4506
|
}
|
|
3301
4507
|
let parsed;
|
|
3302
4508
|
try {
|
|
3303
4509
|
parsed = JSON.parse(body);
|
|
3304
4510
|
}
|
|
3305
4511
|
catch {
|
|
3306
|
-
return
|
|
4512
|
+
return nothing;
|
|
3307
4513
|
}
|
|
3308
4514
|
if (parsed === null || typeof parsed !== "object")
|
|
3309
|
-
return
|
|
4515
|
+
return nothing;
|
|
3310
4516
|
const description = parsed["description"];
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
4517
|
+
return {
|
|
4518
|
+
description: typeof description !== "string" || description.length === 0 ? null : this.redact(description),
|
|
4519
|
+
// PR #614 recheck NF-2: a 429 says how long to wait, and the review
|
|
4520
|
+
// walkthrough honours it rather than spending its retry budget early.
|
|
4521
|
+
retryAfterSeconds: retryAfterFrom(parsed),
|
|
4522
|
+
};
|
|
3314
4523
|
}
|
|
3315
4524
|
/**
|
|
3316
4525
|
* One Bot API call.
|
|
@@ -3340,10 +4549,10 @@ export class TelegramChannel {
|
|
|
3340
4549
|
// was thrown out of were the same "HTTP 400" on the operator's
|
|
3341
4550
|
// terminal. Read best effort — a status is still worth reporting when
|
|
3342
4551
|
// the body is missing, truncated, or not JSON at all.
|
|
3343
|
-
const description = await this.describeFailure(response);
|
|
4552
|
+
const { description, retryAfterSeconds } = await this.describeFailure(response);
|
|
3344
4553
|
throw new TelegramApiError(description === null
|
|
3345
4554
|
? `${method}: HTTP ${response.status}`
|
|
3346
|
-
: `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
|
|
4555
|
+
: `${method}: HTTP ${response.status} (${description})`, method, response.status, description, retryAfterSeconds);
|
|
3347
4556
|
}
|
|
3348
4557
|
raw = await response.text();
|
|
3349
4558
|
}
|
|
@@ -3377,7 +4586,7 @@ export class TelegramChannel {
|
|
|
3377
4586
|
// No HTTP status: this envelope arrived on a 2xx. The description is
|
|
3378
4587
|
// carried anyway so a caller reads the same field whichever shape the
|
|
3379
4588
|
// failure took (APRV-277).
|
|
3380
|
-
null, description);
|
|
4589
|
+
null, description, retryAfterFrom(envelope));
|
|
3381
4590
|
}
|
|
3382
4591
|
return envelope["result"];
|
|
3383
4592
|
}
|