approval-md 0.4.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/SPEC.md +3 -0
- package/cli.js +72 -4
- package/dist/src/channels/contract.d.ts +53 -0
- package/dist/src/channels/contract.js +56 -0
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +57 -5
- 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.d.ts +454 -19
- package/dist/src/channels/telegram.js +931 -80
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +8 -1
- package/dist/src/cli/amend.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-telegram-webhook.d.ts +2 -0
- package/dist/src/cli/channel-telegram-webhook.js +7 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +75 -2
- package/dist/src/cli/channel-telegram.js +170 -15
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/execute.d.ts +12 -0
- package/dist/src/cli/execute.js +46 -1
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +1 -0
- package/dist/src/cli/help.js +39 -18
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +114 -3
- package/dist/src/cli/hook.js +468 -50
- package/dist/src/cli/hook.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.js +23 -6
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/up.js +5 -0
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.js +1 -0
- 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/audit.d.ts +98 -9
- package/dist/src/core/audit.js +363 -15
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/command-class.js +6 -0
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/gate.d.ts +58 -1
- package/dist/src/core/gate.js +30 -3
- package/dist/src/core/gate.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.d.ts +82 -3
- package/dist/src/core/log.js +285 -40
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +17 -0
- package/dist/src/core/policy-proposal.js +29 -14
- 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/sender-identity.d.ts +24 -1
- package/dist/src/core/sender-identity.js +31 -0
- package/dist/src/core/sender-identity.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/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/serve/archive.d.ts +8 -0
- package/dist/src/serve/archive.js +11 -2
- package/dist/src/serve/archive.js.map +1 -1
- package/docs/README-extended.md +2 -0
- package/docs/cli-reference.md +507 -25
- package/package.json +2 -2
- package/schema/event.schema.json +354 -28
- 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/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.json +2 -1
- package/schema/policy.schema.json +52 -0
|
@@ -219,15 +219,16 @@
|
|
|
219
219
|
* process memory and nothing else: no event is appended, no message is edited,
|
|
220
220
|
* and the log is not opened.
|
|
221
221
|
*/
|
|
222
|
-
import { createHash } from "node:crypto";
|
|
222
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
223
223
|
import { GLOSS_UNVERIFIED_SUFFIX, refusedDecisionLine } from "./contract.js";
|
|
224
224
|
// APRV-299. The reaction vocabulary and the verdict type, imported for the
|
|
225
225
|
// reason `core/audit.ts` states where it exports them: they are shown here and
|
|
226
226
|
// decided nowhere. Nothing in this file branches on a reaction, and SPEC.md
|
|
227
227
|
// §11.1 invariant 10's guard scans the modules that decide, which this is not.
|
|
228
228
|
import { REACTIONS } from "../core/audit.js";
|
|
229
|
-
import { TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
229
|
+
import { DEFAULT_PROMPT_STYLE, TELEGRAM_PROMPT_LAYOUT, } from "../core/prompt-layout.js";
|
|
230
230
|
import { commandPayloadView, payloadRegionText } from "./payload-view.js";
|
|
231
|
+
import { MINIMAL_DENY_LABEL, renderTelegramMinimal } from "./telegram-minimal.js";
|
|
231
232
|
// ---------------------------------------------------------------------------
|
|
232
233
|
// Configuration
|
|
233
234
|
// ---------------------------------------------------------------------------
|
|
@@ -765,6 +766,81 @@ export function renderTelegram(request, heading = TELEGRAM_PROMPT_HEADING, layou
|
|
|
765
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)}`,
|
|
766
767
|
};
|
|
767
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;
|
|
768
844
|
/**
|
|
769
845
|
* Split `text` so every chunk survives HTML escaping inside the message limit.
|
|
770
846
|
*
|
|
@@ -1336,6 +1412,15 @@ export const TELEGRAM_REVIEW_DENIED = "✗ REVIEWED — DENIED";
|
|
|
1336
1412
|
* been recorded.
|
|
1337
1413
|
*/
|
|
1338
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
|
+
}
|
|
1339
1424
|
/**
|
|
1340
1425
|
* What a review tap's single answer says (APRV-302).
|
|
1341
1426
|
*
|
|
@@ -1351,8 +1436,29 @@ export const TELEGRAM_REVIEW_ARMED = "DENY ARMED — nothing is recorded yet";
|
|
|
1351
1436
|
export const TELEGRAM_REVIEW_ACK = "Heard — recording your review. The card will say what the log recorded.";
|
|
1352
1437
|
/** The toast a first Deny tap gets: it says plainly that nothing was written. */
|
|
1353
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.";
|
|
1354
1448
|
/** The toast a reaction that needs the human's own words gets. */
|
|
1355
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
|
+
}
|
|
1356
1462
|
/**
|
|
1357
1463
|
* What the ForceReply prompt asks for.
|
|
1358
1464
|
*
|
|
@@ -1445,6 +1551,255 @@ function trimNotice(text) {
|
|
|
1445
1551
|
? text
|
|
1446
1552
|
: `${text.slice(0, REVIEW_NOTICE_MAX)}… (cut to fit one message; the whole refusal is on the listener's stderr)`;
|
|
1447
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
|
+
}
|
|
1448
1803
|
/**
|
|
1449
1804
|
* The card's message: the rows, whatever notice the last tap produced, and the
|
|
1450
1805
|
* keyboard.
|
|
@@ -1457,13 +1812,15 @@ function trimNotice(text) {
|
|
|
1457
1812
|
* every card said the same thing to a reader who had already read them once, and
|
|
1458
1813
|
* pushed the rows a review is actually about off the first screen.
|
|
1459
1814
|
*
|
|
1460
|
-
* Pure.
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
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.
|
|
1467
1824
|
*/
|
|
1468
1825
|
export function renderReviewCard(state) {
|
|
1469
1826
|
const card = state.card;
|
|
@@ -1471,41 +1828,27 @@ export function renderReviewCard(state) {
|
|
|
1471
1828
|
if (state.settled !== null) {
|
|
1472
1829
|
return {
|
|
1473
1830
|
text: [
|
|
1474
|
-
`<b>${escapeHtml(state.settled.headline)}</b>`,
|
|
1475
|
-
`<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>`,
|
|
1476
1833
|
"",
|
|
1477
|
-
...state.settled.detail.map((entry) => escapeHtml(
|
|
1834
|
+
...capReviewRegion(state.settled.detail.map(trimNotice), REVIEW_SETTLED_REGION_MAX).map((entry) => escapeHtml(entry)),
|
|
1478
1835
|
].join("\n"),
|
|
1479
1836
|
keyboard: null,
|
|
1480
1837
|
};
|
|
1481
1838
|
}
|
|
1482
|
-
const
|
|
1483
|
-
const claimedLines = [];
|
|
1484
|
-
for (const row of REVIEW_CARD_ROWS) {
|
|
1485
|
-
const candidate = reviewRow(card.fields, row);
|
|
1486
|
-
if (candidate === null)
|
|
1487
|
-
continue;
|
|
1488
|
-
if (candidate.line.kind === "computed")
|
|
1489
|
-
computedLines.push(candidate.line);
|
|
1490
|
-
else
|
|
1491
|
-
claimedLines.push(candidate.line);
|
|
1492
|
-
}
|
|
1493
|
-
computedLines.push(line("ran_at", card.ranAt, "ran at", card.ranAt.value));
|
|
1494
|
-
computedLines.push(line("verdict", card.verdict, "verdict", card.verdict.value));
|
|
1495
|
-
const render = (entry) => `• <b>${escapeHtml(entry.label)}:</b> ${escapeHtml(entry.text)} <i>(${escapeHtml(entry.origin)})</i>`;
|
|
1496
|
-
const author = originOf(card.fields.summary);
|
|
1839
|
+
const frame = reviewCardFrame(card);
|
|
1497
1840
|
const lines = [
|
|
1498
|
-
`<b>${escapeHtml(
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
...
|
|
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,
|
|
1506
1849
|
];
|
|
1507
1850
|
if (state.notice !== null) {
|
|
1508
|
-
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)));
|
|
1509
1852
|
}
|
|
1510
1853
|
const button = (choice) => ({
|
|
1511
1854
|
text: REVIEW_BUTTON_LABELS[choice],
|
|
@@ -1529,6 +1872,7 @@ export class TelegramApiError extends Error {
|
|
|
1529
1872
|
method;
|
|
1530
1873
|
status;
|
|
1531
1874
|
description;
|
|
1875
|
+
retryAfterSeconds;
|
|
1532
1876
|
constructor(message, method,
|
|
1533
1877
|
/**
|
|
1534
1878
|
* The HTTP status, when the failure was an HTTP one. `null` for a transport
|
|
@@ -1541,14 +1885,84 @@ export class TelegramApiError extends Error {
|
|
|
1541
1885
|
* error body carried one. `null` when the body was absent, unreadable, not
|
|
1542
1886
|
* JSON, or carried no description.
|
|
1543
1887
|
*/
|
|
1544
|
-
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) {
|
|
1545
1896
|
super(message);
|
|
1546
1897
|
this.method = method;
|
|
1547
1898
|
this.status = status;
|
|
1548
1899
|
this.description = description;
|
|
1900
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
1549
1901
|
this.name = "TelegramApiError";
|
|
1550
1902
|
}
|
|
1551
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
|
+
}
|
|
1552
1966
|
/**
|
|
1553
1967
|
* Telegram's wording for "that edit would have changed nothing" (APRV-277).
|
|
1554
1968
|
*
|
|
@@ -1599,7 +2013,7 @@ export function isPollConflict(cause) {
|
|
|
1599
2013
|
}
|
|
1600
2014
|
/** A fresh, empty batch report. One shape for both transports (APRV-424). */
|
|
1601
2015
|
function emptyPollResult() {
|
|
1602
|
-
return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [] };
|
|
2016
|
+
return { updates: 0, outcomes: [], ignored: [], commands: [], reviews: [], refused: [] };
|
|
1603
2017
|
}
|
|
1604
2018
|
/**
|
|
1605
2019
|
* Why a transport claim was refused. Frozen, per SPEC §11.1 invariant 6.
|
|
@@ -1647,6 +2061,12 @@ export class TelegramChannel {
|
|
|
1647
2061
|
describeAction;
|
|
1648
2062
|
/** The policy's row layout for this channel (APRV-218). Read-only, and pure input to the renderer. */
|
|
1649
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;
|
|
1650
2070
|
/** When {@link sweep} last ran, so the poll loop can call it every cycle. */
|
|
1651
2071
|
lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
1652
2072
|
/**
|
|
@@ -1656,6 +2076,25 @@ export class TelegramChannel {
|
|
|
1656
2076
|
*/
|
|
1657
2077
|
ack = null;
|
|
1658
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();
|
|
1659
2098
|
/**
|
|
1660
2099
|
* What to do with a bot command (APRV-216). Absent unless the runtime asked
|
|
1661
2100
|
* for commands, and its absence is what keeps `message` out of
|
|
@@ -1771,6 +2210,7 @@ export class TelegramChannel {
|
|
|
1771
2210
|
"unknown-command": 0,
|
|
1772
2211
|
},
|
|
1773
2212
|
staleCopyDecisions: 0,
|
|
2213
|
+
staleCopyRefusals: 0,
|
|
1774
2214
|
commands: 0,
|
|
1775
2215
|
reviews: 0,
|
|
1776
2216
|
};
|
|
@@ -1794,11 +2234,22 @@ export class TelegramChannel {
|
|
|
1794
2234
|
this.now = config.now ?? (() => Date.now());
|
|
1795
2235
|
this.describeAction = config.describeAction ?? null;
|
|
1796
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;
|
|
1797
2243
|
this.makeNonce =
|
|
1798
2244
|
config.nonce ??
|
|
1799
2245
|
(() => {
|
|
1800
2246
|
this.counter += 1;
|
|
1801
|
-
|
|
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")}`;
|
|
1802
2253
|
});
|
|
1803
2254
|
}
|
|
1804
2255
|
// -------------------------------------------------------------------------
|
|
@@ -1807,6 +2258,25 @@ export class TelegramChannel {
|
|
|
1807
2258
|
onDecision(handler) {
|
|
1808
2259
|
this.handler = handler;
|
|
1809
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
|
+
}
|
|
1810
2280
|
/**
|
|
1811
2281
|
* Register what to do with a checkpoint tap (APRV-257).
|
|
1812
2282
|
*
|
|
@@ -1877,8 +2347,9 @@ export class TelegramChannel {
|
|
|
1877
2347
|
* A unit like a checkpoint prompt: one thing to read, never grouped into a
|
|
1878
2348
|
* digest, and never delivered through {@link notify} — a digest is a set of
|
|
1879
2349
|
* similar pending REQUESTS decided together, and a sample is neither pending
|
|
1880
|
-
* nor a request. It sends ONE message:
|
|
1881
|
-
*
|
|
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.
|
|
1882
2353
|
*
|
|
1883
2354
|
* Refuses when no handler is registered, rather than sending a dead button.
|
|
1884
2355
|
*/
|
|
@@ -1893,6 +2364,9 @@ export class TelegramChannel {
|
|
|
1893
2364
|
card,
|
|
1894
2365
|
nonce,
|
|
1895
2366
|
denyArmed: false,
|
|
2367
|
+
heldReaction: null,
|
|
2368
|
+
armedBy: null,
|
|
2369
|
+
heldBy: null,
|
|
1896
2370
|
settled: null,
|
|
1897
2371
|
notice: null,
|
|
1898
2372
|
awaitingNote: null,
|
|
@@ -2108,6 +2582,7 @@ export class TelegramChannel {
|
|
|
2108
2582
|
state.deliveryId = deliveryId;
|
|
2109
2583
|
// Armed only now, and all at once: until the message with the buttons on it
|
|
2110
2584
|
// exists there is nothing a callback could legitimately answer.
|
|
2585
|
+
const rendering = this.digestRendering(stale !== null);
|
|
2111
2586
|
for (const [index, member] of state.members.entries()) {
|
|
2112
2587
|
this.deliveries.set(member.nonce, {
|
|
2113
2588
|
actionKey: member.actionKey,
|
|
@@ -2116,6 +2591,7 @@ export class TelegramChannel {
|
|
|
2116
2591
|
batchDeliveryId,
|
|
2117
2592
|
deliveredAtMs,
|
|
2118
2593
|
windowMs: windows[index] ?? null,
|
|
2594
|
+
...(rendering === undefined ? {} : { rendering }),
|
|
2119
2595
|
});
|
|
2120
2596
|
}
|
|
2121
2597
|
this.digests.set(deliveryId, state);
|
|
@@ -2200,7 +2676,39 @@ export class TelegramChannel {
|
|
|
2200
2676
|
],
|
|
2201
2677
|
],
|
|
2202
2678
|
};
|
|
2203
|
-
|
|
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
|
+
}
|
|
2204
2712
|
this.counters.notified += 1;
|
|
2205
2713
|
this.deliveries.set(nonce, {
|
|
2206
2714
|
actionKey,
|
|
@@ -2209,6 +2717,8 @@ export class TelegramChannel {
|
|
|
2209
2717
|
deliveredAtMs: this.now(),
|
|
2210
2718
|
windowMs: this.retentionWindowMs(request),
|
|
2211
2719
|
...(batchDeliveryId === undefined ? {} : { batchDeliveryId }),
|
|
2720
|
+
...(rendering === undefined ? {} : { rendering }),
|
|
2721
|
+
...(card === undefined ? {} : { card }),
|
|
2212
2722
|
});
|
|
2213
2723
|
return {
|
|
2214
2724
|
deliveryId: sent.deliveryId,
|
|
@@ -2218,6 +2728,95 @@ export class TelegramChannel {
|
|
|
2218
2728
|
},
|
|
2219
2729
|
};
|
|
2220
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
|
+
}
|
|
2221
2820
|
/**
|
|
2222
2821
|
* Forget every nonce issued for `deliveryId`, and report the action key it
|
|
2223
2822
|
* was issued for.
|
|
@@ -2232,6 +2831,7 @@ export class TelegramChannel {
|
|
|
2232
2831
|
*/
|
|
2233
2832
|
disarm(deliveryId) {
|
|
2234
2833
|
let actionKey = "";
|
|
2834
|
+
let card;
|
|
2235
2835
|
// Deleting the current entry mid-iteration is defined behaviour for a Map,
|
|
2236
2836
|
// and every nonce for this message id goes — a re-notify of the same
|
|
2237
2837
|
// message would otherwise leave an older nonce still resolving.
|
|
@@ -2239,6 +2839,7 @@ export class TelegramChannel {
|
|
|
2239
2839
|
if (delivery.deliveryId !== deliveryId)
|
|
2240
2840
|
continue;
|
|
2241
2841
|
actionKey = delivery.actionKey;
|
|
2842
|
+
card = delivery.card ?? card;
|
|
2242
2843
|
this.deliveries.delete(nonce);
|
|
2243
2844
|
}
|
|
2244
2845
|
const digest = this.digests.get(deliveryId);
|
|
@@ -2246,7 +2847,7 @@ export class TelegramChannel {
|
|
|
2246
2847
|
this.allNonces.delete(digest.allNonce);
|
|
2247
2848
|
this.digests.delete(deliveryId);
|
|
2248
2849
|
}
|
|
2249
|
-
return actionKey;
|
|
2850
|
+
return { actionKey, card };
|
|
2250
2851
|
}
|
|
2251
2852
|
/**
|
|
2252
2853
|
* How long after delivery the request behind a prompt can still be decided,
|
|
@@ -2438,20 +3039,41 @@ export class TelegramChannel {
|
|
|
2438
3039
|
await this.settleMember(digest, actionKey, outcome, detail);
|
|
2439
3040
|
return;
|
|
2440
3041
|
}
|
|
2441
|
-
const
|
|
2442
|
-
const
|
|
3042
|
+
const settled = this.disarm(deliveryId);
|
|
3043
|
+
const technicalText = [
|
|
2443
3044
|
`<b>${escapeHtml(outcome)}</b>`,
|
|
2444
|
-
`<code>${escapeHtml(actionKey ??
|
|
3045
|
+
`<code>${escapeHtml(actionKey ?? settled.actionKey)}</code>`,
|
|
2445
3046
|
"",
|
|
2446
3047
|
...detail.map((entry) => escapeHtml(entry)),
|
|
2447
3048
|
].join("\n");
|
|
2448
|
-
|
|
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", {
|
|
2449
3055
|
chat_id: this.chatId,
|
|
2450
3056
|
message_id: Number(deliveryId),
|
|
2451
|
-
text,
|
|
3057
|
+
text: body,
|
|
2452
3058
|
parse_mode: "HTML",
|
|
2453
3059
|
disable_web_page_preview: true,
|
|
2454
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
|
+
}
|
|
2455
3077
|
}
|
|
2456
3078
|
/**
|
|
2457
3079
|
* The withdrawal case of {@link annotate} (APRV-106), and the one the
|
|
@@ -3005,6 +3627,15 @@ export class TelegramChannel {
|
|
|
3005
3627
|
// a redelivered or replayed refused tap reaches neither the gate nor the
|
|
3006
3628
|
// new card, exactly as it did before that card existed.
|
|
3007
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
|
+
}
|
|
3008
3639
|
if (delivery === undefined && parsed.actionRef !== null && !refusedCard) {
|
|
3009
3640
|
// The pre-restart copy. Its nonce died with the process that issued it,
|
|
3010
3641
|
// but the request it names is one THIS process has since re-delivered, so
|
|
@@ -3046,6 +3677,9 @@ export class TelegramChannel {
|
|
|
3046
3677
|
...(delivery.batchDeliveryId === undefined
|
|
3047
3678
|
? {}
|
|
3048
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 }),
|
|
3049
3683
|
...(parsed.decision === "reject"
|
|
3050
3684
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId})` }
|
|
3051
3685
|
: {}),
|
|
@@ -3172,12 +3806,14 @@ export class TelegramChannel {
|
|
|
3172
3806
|
let landed = 0;
|
|
3173
3807
|
const refusals = [];
|
|
3174
3808
|
for (const member of open) {
|
|
3809
|
+
const allRendering = this.digestRendering(digest.stale !== null);
|
|
3175
3810
|
const one = {
|
|
3176
3811
|
action_key: member.actionKey,
|
|
3177
3812
|
decision,
|
|
3178
3813
|
deliveryId: digest.deliveryId,
|
|
3179
3814
|
batchDeliveryId: digest.batchDeliveryId,
|
|
3180
3815
|
...(sender === undefined ? {} : { sender }),
|
|
3816
|
+
...(allRendering === undefined ? {} : { rendering: allRendering }),
|
|
3181
3817
|
...(decision === "reject"
|
|
3182
3818
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
|
|
3183
3819
|
: {}),
|
|
@@ -3347,31 +3983,88 @@ export class TelegramChannel {
|
|
|
3347
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.");
|
|
3348
3984
|
return;
|
|
3349
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
|
+
}
|
|
3350
4002
|
// The first Deny tap arms and writes nothing. Stated on the card, so the
|
|
3351
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.
|
|
3352
4012
|
if (tap.choice === "deny" && !state.denyArmed) {
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
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);
|
|
3357
4018
|
return;
|
|
3358
4019
|
}
|
|
3359
|
-
const verdict = state.denyArmed ? "denied" : "ok";
|
|
3360
4020
|
if (tap.choice === "ok" || tap.choice === "deny") {
|
|
3361
4021
|
// `ok` with deny armed is a correction, and it disarms: a human who
|
|
3362
4022
|
// reached for Deny and then chose OK meant OK, and nothing was written in
|
|
3363
4023
|
// between for the change of mind to contradict.
|
|
3364
4024
|
const chosen = tap.choice === "deny" ? "denied" : "ok";
|
|
3365
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
|
+
}
|
|
3366
4042
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
3367
4043
|
await this.recordReview(state, {
|
|
3368
4044
|
sampleSeq: state.card.sampleSeq,
|
|
3369
4045
|
verdict: chosen,
|
|
4046
|
+
...(held === null ? {} : { reaction: held }),
|
|
3370
4047
|
...(sender === undefined ? {} : { sender }),
|
|
3371
4048
|
}, result);
|
|
3372
4049
|
return;
|
|
3373
4050
|
}
|
|
3374
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";
|
|
3375
4068
|
// The two grades that require the human's own words ask for them FIRST, and
|
|
3376
4069
|
// nothing is appended until the reply arrives. The exception is the pair
|
|
3377
4070
|
// `core/audit.ts` refuses outright: a denied review that says liked or
|
|
@@ -3403,26 +4096,75 @@ export class TelegramChannel {
|
|
|
3403
4096
|
* stays open, and a fresh card is offered — and can never cost a record
|
|
3404
4097
|
* nobody asked for.
|
|
3405
4098
|
*
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
3408
|
-
* 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.
|
|
3409
4125
|
*/
|
|
3410
4126
|
async askForNote(state, verdict, reaction, sender) {
|
|
3411
|
-
|
|
3412
|
-
|
|
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
|
+
}
|
|
3413
4139
|
const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
.
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
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
|
+
}
|
|
3423
4157
|
const promptId = String(sent.message_id);
|
|
4158
|
+
if (state.awaitingNote !== null)
|
|
4159
|
+
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
3424
4160
|
state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
|
|
3425
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
|
+
}
|
|
3426
4168
|
}
|
|
3427
4169
|
/**
|
|
3428
4170
|
* A message replying to an outstanding note prompt (APRV-299).
|
|
@@ -3494,13 +4236,25 @@ export class TelegramChannel {
|
|
|
3494
4236
|
* to say which half they meant, and `note-required` asks for words — so the
|
|
3495
4237
|
* buttons stay, with the refusal rendered above them and the arming intact.
|
|
3496
4238
|
*/
|
|
3497
|
-
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) {
|
|
3498
4247
|
const handler = this.reviewHandler;
|
|
3499
4248
|
if (handler === null)
|
|
3500
|
-
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;
|
|
3501
4255
|
let response;
|
|
3502
4256
|
try {
|
|
3503
|
-
response = await handler(
|
|
4257
|
+
response = await handler(sent);
|
|
3504
4258
|
}
|
|
3505
4259
|
catch (cause) {
|
|
3506
4260
|
state.notice = { headline: TELEGRAM_NOT_RECORDED, lines: [TELEGRAM_HANDLER_FAILED] };
|
|
@@ -3508,11 +4262,14 @@ export class TelegramChannel {
|
|
|
3508
4262
|
throw cause;
|
|
3509
4263
|
}
|
|
3510
4264
|
this.counters.reviews += 1;
|
|
3511
|
-
result.reviews.push({ tap, ok: response.ok });
|
|
4265
|
+
result.reviews.push({ tap: sent, ok: response.ok });
|
|
3512
4266
|
if (response.ok) {
|
|
3513
4267
|
state.settled = { headline: response.headline, detail: response.detail };
|
|
3514
4268
|
state.notice = null;
|
|
3515
4269
|
state.denyArmed = false;
|
|
4270
|
+
state.heldReaction = null;
|
|
4271
|
+
state.armedBy = null;
|
|
4272
|
+
state.heldBy = null;
|
|
3516
4273
|
this.reviewNonces.delete(state.nonce);
|
|
3517
4274
|
if (state.awaitingNote !== null) {
|
|
3518
4275
|
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
@@ -3521,8 +4278,23 @@ export class TelegramChannel {
|
|
|
3521
4278
|
}
|
|
3522
4279
|
else {
|
|
3523
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
|
+
}
|
|
3524
4295
|
}
|
|
3525
4296
|
await this.redrawReview(state);
|
|
4297
|
+
return response;
|
|
3526
4298
|
}
|
|
3527
4299
|
/** One `editMessageText` that replaces a review card's text and its keyboard. */
|
|
3528
4300
|
async redrawReview(state) {
|
|
@@ -3547,6 +4319,81 @@ export class TelegramChannel {
|
|
|
3547
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`);
|
|
3548
4320
|
}
|
|
3549
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
|
+
}
|
|
3550
4397
|
async ignore(result, callbackId, kind, detail, reply) {
|
|
3551
4398
|
this.counters.anomalies[kind] += 1;
|
|
3552
4399
|
result.ignored.push({ kind, detail });
|
|
@@ -3649,26 +4496,30 @@ export class TelegramChannel {
|
|
|
3649
4496
|
* replace the real reason with a worse one.
|
|
3650
4497
|
*/
|
|
3651
4498
|
async describeFailure(response) {
|
|
4499
|
+
const nothing = { description: null, retryAfterSeconds: null };
|
|
3652
4500
|
let body;
|
|
3653
4501
|
try {
|
|
3654
4502
|
body = await response.text();
|
|
3655
4503
|
}
|
|
3656
4504
|
catch {
|
|
3657
|
-
return
|
|
4505
|
+
return nothing;
|
|
3658
4506
|
}
|
|
3659
4507
|
let parsed;
|
|
3660
4508
|
try {
|
|
3661
4509
|
parsed = JSON.parse(body);
|
|
3662
4510
|
}
|
|
3663
4511
|
catch {
|
|
3664
|
-
return
|
|
4512
|
+
return nothing;
|
|
3665
4513
|
}
|
|
3666
4514
|
if (parsed === null || typeof parsed !== "object")
|
|
3667
|
-
return
|
|
4515
|
+
return nothing;
|
|
3668
4516
|
const description = parsed["description"];
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
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
|
+
};
|
|
3672
4523
|
}
|
|
3673
4524
|
/**
|
|
3674
4525
|
* One Bot API call.
|
|
@@ -3698,10 +4549,10 @@ export class TelegramChannel {
|
|
|
3698
4549
|
// was thrown out of were the same "HTTP 400" on the operator's
|
|
3699
4550
|
// terminal. Read best effort — a status is still worth reporting when
|
|
3700
4551
|
// the body is missing, truncated, or not JSON at all.
|
|
3701
|
-
const description = await this.describeFailure(response);
|
|
4552
|
+
const { description, retryAfterSeconds } = await this.describeFailure(response);
|
|
3702
4553
|
throw new TelegramApiError(description === null
|
|
3703
4554
|
? `${method}: HTTP ${response.status}`
|
|
3704
|
-
: `${method}: HTTP ${response.status} (${description})`, method, response.status, description);
|
|
4555
|
+
: `${method}: HTTP ${response.status} (${description})`, method, response.status, description, retryAfterSeconds);
|
|
3705
4556
|
}
|
|
3706
4557
|
raw = await response.text();
|
|
3707
4558
|
}
|
|
@@ -3735,7 +4586,7 @@ export class TelegramChannel {
|
|
|
3735
4586
|
// No HTTP status: this envelope arrived on a 2xx. The description is
|
|
3736
4587
|
// carried anyway so a caller reads the same field whichever shape the
|
|
3737
4588
|
// failure took (APRV-277).
|
|
3738
|
-
null, description);
|
|
4589
|
+
null, description, retryAfterFrom(envelope));
|
|
3739
4590
|
}
|
|
3740
4591
|
return envelope["result"];
|
|
3741
4592
|
}
|