switchroom 0.19.42 → 0.19.43
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/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +32 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +1187 -669
- package/dist/host-control/main.js +205 -28
- package/dist/vault/approvals/kernel-server.js +32 -7
- package/dist/vault/broker/server.js +32 -7
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1225 -614
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
|
@@ -69,6 +69,11 @@ import {
|
|
|
69
69
|
isCapturedProseDeliveryEnabledEnv,
|
|
70
70
|
isGatewayHeartbeatFresh,
|
|
71
71
|
} from './silent-end-scan.mjs'
|
|
72
|
+
import {
|
|
73
|
+
decideCaptureAudience,
|
|
74
|
+
resolveOpenObligation,
|
|
75
|
+
AUDIENCE_INTERNAL,
|
|
76
|
+
} from './audience-classify.mjs'
|
|
72
77
|
|
|
73
78
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
74
79
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
@@ -114,6 +119,16 @@ function buildNextState(base, decision, retryCount) {
|
|
|
114
119
|
} else {
|
|
115
120
|
delete next.pendingText
|
|
116
121
|
}
|
|
122
|
+
// #4141: carry THIS turn's reply-throw signal through to the captured-prose
|
|
123
|
+
// bridge, which is the delivering machine on the ELECTED path and has no
|
|
124
|
+
// transcript of its own. Same stale-carryover discipline as `pendingText` /
|
|
125
|
+
// `turnId`: explicitly DELETED when this turn's scan saw no throw, so a
|
|
126
|
+
// spread of a prior turn's file can never mislabel a clean turn.
|
|
127
|
+
if (decision.replyToolThrewThisTurn === true) {
|
|
128
|
+
next.replyToolThrewThisTurn = true
|
|
129
|
+
} else {
|
|
130
|
+
delete next.replyToolThrewThisTurn
|
|
131
|
+
}
|
|
117
132
|
return next
|
|
118
133
|
}
|
|
119
134
|
|
|
@@ -156,13 +171,64 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
|
|
|
156
171
|
return false
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/**
|
|
175
|
+
* W1-d (#3865): classify WHO the captured prose is for, at capture time — the
|
|
176
|
+
* only point in the pipeline that can still see the turn.
|
|
177
|
+
*
|
|
178
|
+
* The obligation snapshot is read here (impure) and the verdict is computed by
|
|
179
|
+
* the shared pure classifier, so the hook and the gateway run the identical
|
|
180
|
+
* predicate. Any failure resolves to `'user'` via `resolveOpenObligation`'s
|
|
181
|
+
* `'unknown'` → the record delivers exactly as it does today.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} stateDir
|
|
184
|
+
* @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
|
|
185
|
+
* @returns {'user' | 'internal'}
|
|
186
|
+
*/
|
|
187
|
+
function classifyCaptureAudience(stateDir, capture) {
|
|
188
|
+
let snapshotRaw = null
|
|
189
|
+
try {
|
|
190
|
+
const p = join(stateDir, 'obligations.json')
|
|
191
|
+
if (existsSync(p)) snapshotRaw = readFileSync(p, 'utf8')
|
|
192
|
+
} catch {
|
|
193
|
+
/* unreadable ⇒ null ⇒ 'unknown' ⇒ 'user' (fail-safe toward delivering) */
|
|
194
|
+
}
|
|
195
|
+
// #4146: with the ledger disabled, obligations are going UNTRACKED and any
|
|
196
|
+
// file on disk is a leftover, not a fact. Distrust it outright rather than
|
|
197
|
+
// read a stale empty set as positive proof that nobody is waiting — that is
|
|
198
|
+
// the one configuration in which the asymmetric default would invert. (The
|
|
199
|
+
// gateway also unlinks the snapshot at boot in this mode; this is the
|
|
200
|
+
// reader-side half, covering the window before it does.)
|
|
201
|
+
// STATIC is the second persistence-off mode (`gateway.ts:1343` gates the same
|
|
202
|
+
// `onChange` wiring on it at `:2810`), so it must distrust the file for the
|
|
203
|
+
// same reason. Writer-side cleanup already covers it at boot; this leg covers
|
|
204
|
+
// the window the writer cannot — an unlink that failed (EACCES) or a gateway
|
|
205
|
+
// that has not restarted since the mode changed. Honest caveat:
|
|
206
|
+
// `TELEGRAM_ACCESS_MODE` is supplied by the host, not by this repo, so if it
|
|
207
|
+
// is not exported into the hook's environment this leg degrades to a no-op and
|
|
208
|
+
// the writer-side unlink remains the cover. It can only ever move the verdict
|
|
209
|
+
// toward DELIVER, so a degraded read is never worse than today.
|
|
210
|
+
const snapshotTrusted =
|
|
211
|
+
process.env.SWITCHROOM_OBLIGATION_LEDGER !== '0' &&
|
|
212
|
+
process.env.TELEGRAM_ACCESS_MODE !== 'static'
|
|
213
|
+
return decideCaptureAudience({
|
|
214
|
+
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
215
|
+
openInboundObligation: resolveOpenObligation({
|
|
216
|
+
snapshotRaw,
|
|
217
|
+
snapshotTrusted,
|
|
218
|
+
// Same chat the sweep will route to: the envelope chat when present,
|
|
219
|
+
// else this session's origin chat (`resolveOutboxChat`'s F2 fallback).
|
|
220
|
+
chatId: capture.chatId ?? capture.originChatId ?? null,
|
|
221
|
+
}),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
/**
|
|
160
226
|
* Write the durable outbox record for a captured undelivered final answer
|
|
161
227
|
* (atomic tmp+rename), mirroring `writeOutboxRecordAtomic` in `../outbox.ts`.
|
|
162
228
|
* The gateway heartbeat sweep is the single deliverer. Best-effort; never
|
|
163
229
|
* throws. Returns true when a record exists on disk for the nonce afterward.
|
|
164
230
|
*/
|
|
165
|
-
function writeOutboxRecord(stateDir, capture) {
|
|
231
|
+
function writeOutboxRecord(stateDir, capture, audience) {
|
|
166
232
|
const outboxDir = join(stateDir, 'outbox')
|
|
167
233
|
try {
|
|
168
234
|
mkdirSync(outboxDir, { recursive: true })
|
|
@@ -187,6 +253,18 @@ function writeOutboxRecord(stateDir, capture) {
|
|
|
187
253
|
// journal alone. Driven by the SAME boolean that gates capture-vs-election
|
|
188
254
|
// in main() — fix and telemetry cannot drift.
|
|
189
255
|
replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
|
|
256
|
+
// W1-d (#3865): WHO this text is for. Always stamped explicitly (never
|
|
257
|
+
// left implicit) so a post-change record is self-describing and the
|
|
258
|
+
// sweep's gate never has to infer. `'user'` is byte-for-byte the
|
|
259
|
+
// pre-change behaviour.
|
|
260
|
+
audience: audience === AUDIENCE_INTERNAL ? AUDIENCE_INTERNAL : 'user',
|
|
261
|
+
// W1-d follow-up (#4141): the RAW structural signal, persisted alongside
|
|
262
|
+
// the verdict it fed. `audience` collapses it with the obligation state
|
|
263
|
+
// and loses it; the sweep needs it on its own to decide provenance
|
|
264
|
+
// framing for the `'user'` records the audience gate deliberately does
|
|
265
|
+
// not catch (the foreground case). Stamped here because this is the last
|
|
266
|
+
// point in the pipeline that can still see the transcript.
|
|
267
|
+
replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
|
|
190
268
|
}
|
|
191
269
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
192
270
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -267,11 +345,16 @@ function main() {
|
|
|
267
345
|
`deferring to single-writer election (#3510)\n`,
|
|
268
346
|
)
|
|
269
347
|
} else {
|
|
270
|
-
|
|
348
|
+
const audience = classifyCaptureAudience(stateDir, capture)
|
|
349
|
+
writeOutboxRecord(stateDir, capture, audience)
|
|
271
350
|
process.stderr.write(
|
|
272
351
|
`[silent-end-interrupt] captured undelivered final answer to outbox ` +
|
|
273
352
|
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
274
|
-
`replyAlreadyDeliveredThisTurn=false
|
|
353
|
+
`replyAlreadyDeliveredThisTurn=false audience=${audience} ` +
|
|
354
|
+
`replyToolThrewThisTurn=${capture.replyToolThrewThisTurn === true}) — ` +
|
|
355
|
+
`${audience === AUDIENCE_INTERNAL
|
|
356
|
+
? 'internal prose: journaled for inspection, sweep will NOT deliver'
|
|
357
|
+
: 'sweep will deliver'}; allowing stop\n`,
|
|
275
358
|
)
|
|
276
359
|
process.exit(0)
|
|
277
360
|
}
|
|
@@ -248,9 +248,18 @@ function buildTurnId(chatId, threadId, messageId) {
|
|
|
248
248
|
* reply tool (Option A transcript-prose bridge). Only populated when it
|
|
249
249
|
* clears the substance floor, so the gateway never re-delivers a short
|
|
250
250
|
* trailing pleasantry. Omitted entirely otherwise.
|
|
251
|
+
* @param {boolean} [replyToolThrewThisTurn] W1-d follow-up (#4141): one of this
|
|
252
|
+
* turn's reply-tool calls came back `is_error: true`. Carried on the BLOCK
|
|
253
|
+
* result so the single-writer election can persist it onto the silent-end
|
|
254
|
+
* state file — the captured-prose bridge (the machine that actually delivers
|
|
255
|
+
* on the ELECTED path, where no outbox record is ever written) has no other
|
|
256
|
+
* way to learn it. A raw SIGNAL, never a verdict: downstream it only ever
|
|
257
|
+
* adds a provenance banner. Omitted rather than `false` when absent, so a
|
|
258
|
+
* spread of a prior state file cannot be read as positive evidence.
|
|
251
259
|
*/
|
|
252
|
-
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
|
|
260
|
+
function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
|
|
253
261
|
const block = { decided: 'block', reason }
|
|
262
|
+
if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
|
|
254
263
|
// Single-writer election input (#duplicate-message fix): does ANY
|
|
255
264
|
// non-empty, non-silent trailing text block exist after the last
|
|
256
265
|
// delivery event? The zero-reply election allows only when this is
|
|
@@ -381,6 +390,15 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
381
390
|
// whether emitted as plain text or as a reply-tool payload) or
|
|
382
391
|
// plain undelivered text.
|
|
383
392
|
const blocks = []
|
|
393
|
+
// W1-d follow-up (#4141): the same reply-throw signal `scanForOutboxCapture`
|
|
394
|
+
// already derives, computed here too because the ELECTED path never reaches
|
|
395
|
+
// that scanner's capture branch (a thrown-but-qualifying reply sets
|
|
396
|
+
// `replyAlreadyDeliveredThisTurn`, so the hook defers to the election and
|
|
397
|
+
// writes NO outbox record). Collected as two id sets whose intersection is
|
|
398
|
+
// the signal. Read-only: nothing below branches on it, so no verdict of this
|
|
399
|
+
// scanner changes.
|
|
400
|
+
const replyToolUseIds = new Set()
|
|
401
|
+
const erroredToolUseIds = new Set()
|
|
384
402
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
385
403
|
const line = lines[i]
|
|
386
404
|
if (!line || line[0] !== '{') continue
|
|
@@ -390,6 +408,20 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
390
408
|
// be in a separate transcript file, but `isSidechain:true` is the
|
|
391
409
|
// documented marker if they leak).
|
|
392
410
|
if (obj?.isSidechain === true) continue
|
|
411
|
+
if (obj?.type === 'user') {
|
|
412
|
+
// Tool RESULT lines: `user`-type, content array carrying
|
|
413
|
+
// `{type:'tool_result', tool_use_id, is_error}`. Same shape read by
|
|
414
|
+
// `scanForOutboxCapture` (#3865).
|
|
415
|
+
const rc = obj?.message?.content
|
|
416
|
+
if (!Array.isArray(rc)) continue
|
|
417
|
+
for (const r of rc) {
|
|
418
|
+
if (r?.type !== 'tool_result') continue
|
|
419
|
+
if (r?.is_error !== true) continue
|
|
420
|
+
if (typeof r.tool_use_id !== 'string') continue
|
|
421
|
+
erroredToolUseIds.add(r.tool_use_id)
|
|
422
|
+
}
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
393
425
|
if (obj?.type !== 'assistant') continue
|
|
394
426
|
const content = obj?.message?.content
|
|
395
427
|
if (!Array.isArray(content)) continue
|
|
@@ -449,6 +481,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
449
481
|
}
|
|
450
482
|
continue
|
|
451
483
|
}
|
|
484
|
+
// #4141: EVERY reply-tool call's id, qualifying or not — an interim ack
|
|
485
|
+
// that threw is still a reply-tool throw.
|
|
486
|
+
if (typeof c.id === 'string') replyToolUseIds.add(c.id)
|
|
452
487
|
const input = c.input ?? {}
|
|
453
488
|
const text = String(input.text ?? '')
|
|
454
489
|
// Silent-marker carve-out: the operator explicitly signaled
|
|
@@ -555,6 +590,10 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
555
590
|
// delivers the joined prose instead of dropping. A pure narration run still
|
|
556
591
|
// yields `undefined` (the #3228 Finding 2 guard, now structural).
|
|
557
592
|
const zeroReplyPendingText = backstopText
|
|
593
|
+
// #4141 — one of this turn's reply-tool calls errored. Derived here, consumed
|
|
594
|
+
// only by `buildBlockResult` below (and thence the elected state file); it
|
|
595
|
+
// gates nothing in this scanner.
|
|
596
|
+
const replyToolThrewThisTurn = [...replyToolUseIds].some((id) => erroredToolUseIds.has(id))
|
|
558
597
|
|
|
559
598
|
if (lastAllowBlockIdx === -1) {
|
|
560
599
|
// No qualifying delivery/silence event anywhere in the turn.
|
|
@@ -567,7 +606,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
567
606
|
if (envelope.source === 'cron') {
|
|
568
607
|
return { decided: 'allow', reason: 'cron-source' }
|
|
569
608
|
}
|
|
570
|
-
return buildBlockResult(
|
|
609
|
+
return buildBlockResult(
|
|
610
|
+
envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse, replyToolThrewThisTurn,
|
|
611
|
+
)
|
|
571
612
|
}
|
|
572
613
|
|
|
573
614
|
if (sawUndeliveredTextAfterAllow) {
|
|
@@ -576,7 +617,9 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
576
617
|
// sent through a delivery tool. This is the "at least once" bug:
|
|
577
618
|
// an early ack (or any qualifying reply) must not amnesty
|
|
578
619
|
// everything written afterward.
|
|
579
|
-
return buildBlockResult(
|
|
620
|
+
return buildBlockResult(
|
|
621
|
+
envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse, replyToolThrewThisTurn,
|
|
622
|
+
)
|
|
580
623
|
}
|
|
581
624
|
|
|
582
625
|
return { decided: 'allow', reason: lastAllowReason }
|
|
@@ -917,7 +960,7 @@ function resolveSessionOriginChat(lines) {
|
|
|
917
960
|
*
|
|
918
961
|
* @param {string} jsonl
|
|
919
962
|
* @param {number} [now]
|
|
920
|
-
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
|
|
963
|
+
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null, replyToolThrewThisTurn: boolean }}
|
|
921
964
|
*/
|
|
922
965
|
export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
923
966
|
const lines = jsonl.split('\n')
|
|
@@ -925,12 +968,34 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
925
968
|
const { envelope } = anchor
|
|
926
969
|
|
|
927
970
|
const blocks = []
|
|
971
|
+
// W1-d (#3865): ids of reply-tool `tool_use` blocks seen this turn, and the
|
|
972
|
+
// ids whose `tool_result` came back `is_error: true`. Their intersection is
|
|
973
|
+
// the "did the reply tool throw this turn" signal.
|
|
974
|
+
const replyToolUseIds = new Set()
|
|
975
|
+
const erroredToolUseIds = new Set()
|
|
928
976
|
for (let i = anchor.startIdx + 1; i < lines.length; i++) {
|
|
929
977
|
const line = lines[i]
|
|
930
978
|
if (!line || line[0] !== '{') continue
|
|
931
979
|
let obj
|
|
932
980
|
try { obj = JSON.parse(line) } catch { continue }
|
|
933
981
|
if (obj?.isSidechain === true) continue
|
|
982
|
+
// W1-d (#3865): tool RESULT lines are how a reply-tool THROW becomes
|
|
983
|
+
// visible. Claude Code writes the result of a `tool_use` as a `user`-type
|
|
984
|
+
// line whose content array carries `{type:'tool_result', tool_use_id,
|
|
985
|
+
// is_error}`. Nothing in this scanner read those before, which is exactly
|
|
986
|
+
// why an errored reply was indistinguishable from a delivered one — the
|
|
987
|
+
// transcript shape is otherwise identical.
|
|
988
|
+
if (obj?.type === 'user') {
|
|
989
|
+
const rc = obj?.message?.content
|
|
990
|
+
if (!Array.isArray(rc)) continue
|
|
991
|
+
for (const r of rc) {
|
|
992
|
+
if (r?.type !== 'tool_result') continue
|
|
993
|
+
if (r?.is_error !== true) continue
|
|
994
|
+
if (typeof r.tool_use_id !== 'string') continue
|
|
995
|
+
erroredToolUseIds.add(r.tool_use_id)
|
|
996
|
+
}
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
934
999
|
if (obj?.type !== 'assistant') continue
|
|
935
1000
|
const content = obj?.message?.content
|
|
936
1001
|
if (!Array.isArray(content)) continue
|
|
@@ -971,6 +1036,12 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
971
1036
|
}
|
|
972
1037
|
continue
|
|
973
1038
|
}
|
|
1039
|
+
// W1-d (#3865): remember EVERY reply-tool call's id, not just the
|
|
1040
|
+
// qualifying-final-answer ones. An interim ack that threw is still a
|
|
1041
|
+
// reply-tool throw, and the #3861 shape (reply raises, agent then writes
|
|
1042
|
+
// trailing working notes) does not require the errored call to have been
|
|
1043
|
+
// a qualifying final answer.
|
|
1044
|
+
if (typeof c.id === 'string') replyToolUseIds.add(c.id)
|
|
974
1045
|
const input = c.input ?? {}
|
|
975
1046
|
const text = String(input.text ?? '')
|
|
976
1047
|
if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
|
|
@@ -1081,5 +1152,10 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
1081
1152
|
originThreadId: origin.threadId,
|
|
1082
1153
|
replyAlreadyDeliveredThisTurn,
|
|
1083
1154
|
deliveredReplySha256,
|
|
1155
|
+
// W1-d (#3865): one of this turn's reply-tool calls came back an error.
|
|
1156
|
+
// Reported as a raw SIGNAL, not a verdict — `decideCaptureAudience` in
|
|
1157
|
+
// `audience-classify.mjs` combines it with the obligation state (which
|
|
1158
|
+
// this pure transcript scanner cannot see) to decide the audience.
|
|
1159
|
+
replyToolThrewThisTurn: [...replyToolUseIds].some((id) => erroredToolUseIds.has(id)),
|
|
1084
1160
|
}
|
|
1085
1161
|
}
|
|
@@ -65,6 +65,19 @@ import {
|
|
|
65
65
|
} from 'node:fs'
|
|
66
66
|
import { join } from 'node:path'
|
|
67
67
|
import { homedir } from 'node:os'
|
|
68
|
+
// W1-d (#3865). The audience vocabulary and its resolution rule live in ONE
|
|
69
|
+
// shared pure module because the unbundled Stop hooks (which stamp it) and this
|
|
70
|
+
// bundled gateway code (which persists and enforces it) must agree exactly. A
|
|
71
|
+
// hand-synced second copy here would be a leak waiting on a drift.
|
|
72
|
+
import type { Audience } from './hooks/audience-classify.mjs'
|
|
73
|
+
// #4141: the provenance-framing rule lives in the same shared pure module, for
|
|
74
|
+
// the same reason — the decision must be one implementation, not two.
|
|
75
|
+
import {
|
|
76
|
+
applyReplyThrowFraming,
|
|
77
|
+
shouldFrameReplyThrow,
|
|
78
|
+
} from './hooks/audience-classify.mjs'
|
|
79
|
+
|
|
80
|
+
export type { Audience }
|
|
68
81
|
|
|
69
82
|
/** One captured, not-yet-delivered final message for a single main-session turn. */
|
|
70
83
|
export interface OutboxRecord {
|
|
@@ -113,6 +126,42 @@ export interface OutboxRecord {
|
|
|
113
126
|
* or in a sweep journal entry — is direct evidence of a regression.
|
|
114
127
|
*/
|
|
115
128
|
replyAlreadyDeliveredThisTurn?: boolean
|
|
129
|
+
/**
|
|
130
|
+
* W1-d (#3865): WHO this text is addressed to, decided at capture time by
|
|
131
|
+
* `hooks/audience-classify.mjs` (`decideCaptureAudience`) — the only point in
|
|
132
|
+
* the pipeline that can still see the turn that produced it.
|
|
133
|
+
*
|
|
134
|
+
* `'internal'` means the text is the agent's own working notes (the #3861
|
|
135
|
+
* shape: the reply tool threw, nobody was waiting, and the structurally-
|
|
136
|
+
* selected terminal prose run is orchestration narration rather than an
|
|
137
|
+
* answer). Such a record is journaled and inspectable but MUST NEVER reach a
|
|
138
|
+
* chat — the sweep drops it at entry selection.
|
|
139
|
+
*
|
|
140
|
+
* OPTIONAL for legacy reasons: every record written before this field
|
|
141
|
+
* existed has no `audience`, and `resolveRecordAudience` maps missing (and
|
|
142
|
+
* any non-`'internal'` value) to `'user'`. Suppression requires an
|
|
143
|
+
* affirmative tag; absence is never evidence.
|
|
144
|
+
*/
|
|
145
|
+
audience?: Audience
|
|
146
|
+
/**
|
|
147
|
+
* W1-d follow-up (#4141): did one of this turn's reply-tool calls come back
|
|
148
|
+
* `is_error: true`? Stamped at capture by `hooks/silent-end-scan.mjs` (which
|
|
149
|
+
* reads the `tool_result` blocks) and persisted here so the SWEEP — hours
|
|
150
|
+
* later, in another process, with the transcript long gone — can still state
|
|
151
|
+
* this text's provenance.
|
|
152
|
+
*
|
|
153
|
+
* This is the raw structural SIGNAL, not a verdict. `audience` already
|
|
154
|
+
* consumed it once (combined with the obligation state) to decide
|
|
155
|
+
* suppression; the sweep consumes it again, on its own, to decide FRAMING for
|
|
156
|
+
* the records suppression deliberately does NOT catch — the foreground case,
|
|
157
|
+
* where the reply threw but someone is provably still waiting, so the record
|
|
158
|
+
* is `'user'` and MUST deliver.
|
|
159
|
+
*
|
|
160
|
+
* OPTIONAL: absent on every pre-#4141 record and on gateway-authored records
|
|
161
|
+
* (`gateway/flood-reply-queue.ts`), both of which deliver unframed. Only an
|
|
162
|
+
* exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
|
|
163
|
+
*/
|
|
164
|
+
replyToolThrewThisTurn?: boolean
|
|
116
165
|
}
|
|
117
166
|
|
|
118
167
|
/** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
|
|
@@ -131,6 +180,30 @@ export interface DeliveredEntry {
|
|
|
131
180
|
deliverySource?: 'sweep' | 'reply-tool' | 'flush'
|
|
132
181
|
/** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
|
|
133
182
|
replyAlreadyDeliveredThisTurn?: boolean
|
|
183
|
+
/** W1-d (#3865): the record's audience, carried onto the journal line. */
|
|
184
|
+
audience?: Audience
|
|
185
|
+
/**
|
|
186
|
+
* W1-d (#3865): this journal line is a TERMINAL SUPPRESSION, not a delivery.
|
|
187
|
+
* The sweep withheld an `'internal'` record from the chat and wrote this line
|
|
188
|
+
* so the nonce is terminally claimed (a second tick sends nothing) and the
|
|
189
|
+
* decision is durably auditable.
|
|
190
|
+
*
|
|
191
|
+
* This is what keeps suppression honest: the outcome is recorded as an
|
|
192
|
+
* explicit, named state rather than as an absence. `tgMessageId` is absent on
|
|
193
|
+
* these lines because nothing was sent.
|
|
194
|
+
*/
|
|
195
|
+
suppressedAudience?: Audience
|
|
196
|
+
/**
|
|
197
|
+
* W1-d follow-up (#4141): this delivery carried the reply-throw provenance
|
|
198
|
+
* banner (`REPLY_THROW_PROVENANCE_NOTICE`) in front of the captured prose.
|
|
199
|
+
*
|
|
200
|
+
* Durable and terminal, for the same reason `suppressedAudience` is: framing
|
|
201
|
+
* changes what a human sees, so the outcome is recorded as an explicit named
|
|
202
|
+
* state on the journal line rather than being inferable only from a log line
|
|
203
|
+
* that may have rotated away. `tgMessageId` IS present on these lines — a
|
|
204
|
+
* framed delivery is a delivery.
|
|
205
|
+
*/
|
|
206
|
+
framedProvenance?: 'reply-throw'
|
|
134
207
|
}
|
|
135
208
|
|
|
136
209
|
export function sha256Hex(s: string): string {
|
|
@@ -439,6 +512,13 @@ export interface OutboxSweepDecision {
|
|
|
439
512
|
action: OutboxSweepAction
|
|
440
513
|
/** The text to deliver (with any "(delayed)"/"(from background task)" prefix). */
|
|
441
514
|
text?: string
|
|
515
|
+
/**
|
|
516
|
+
* W1-d follow-up (#4141): `text` carries the reply-throw provenance banner.
|
|
517
|
+
* Surfaced on the decision (rather than left implicit in a string compare) so
|
|
518
|
+
* the caller can journal the outcome and emit telemetry without re-deriving
|
|
519
|
+
* the rule.
|
|
520
|
+
*/
|
|
521
|
+
framedProvenance?: 'reply-throw'
|
|
442
522
|
}
|
|
443
523
|
|
|
444
524
|
/**
|
|
@@ -456,7 +536,10 @@ export interface OutboxSweepDecision {
|
|
|
456
536
|
* send-delayed — older than max-age; deliver with a "(delayed)" prefix, never drop.
|
|
457
537
|
*/
|
|
458
538
|
export function decideOutboxSweep(input: {
|
|
459
|
-
record: Pick<
|
|
539
|
+
record: Pick<
|
|
540
|
+
OutboxRecord,
|
|
541
|
+
'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
|
|
542
|
+
>
|
|
460
543
|
now: number
|
|
461
544
|
deliveredNonces: Set<string>
|
|
462
545
|
textAlreadyDelivered: boolean
|
|
@@ -476,6 +559,13 @@ export function decideOutboxSweep(input: {
|
|
|
476
559
|
* `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
|
|
477
560
|
*/
|
|
478
561
|
shownLedgerHit?: boolean
|
|
562
|
+
/**
|
|
563
|
+
* W1-d follow-up (#4141) kill switch for the reply-throw provenance banner.
|
|
564
|
+
* `false` restores the pre-change body byte-for-byte; the default is ON.
|
|
565
|
+
* Passed in rather than read from env here so this core stays pure and the
|
|
566
|
+
* revert-check can flip it in-process.
|
|
567
|
+
*/
|
|
568
|
+
provenanceFraming?: boolean
|
|
479
569
|
}): OutboxSweepDecision {
|
|
480
570
|
const {
|
|
481
571
|
record,
|
|
@@ -487,6 +577,7 @@ export function decideOutboxSweep(input: {
|
|
|
487
577
|
quietMs = OUTBOX_QUIET_MS,
|
|
488
578
|
maxAgeMs = OUTBOX_MAX_AGE_MS,
|
|
489
579
|
shownLedgerHit = false,
|
|
580
|
+
provenanceFraming = true,
|
|
490
581
|
} = input
|
|
491
582
|
if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
|
|
492
583
|
if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
|
|
@@ -496,7 +587,17 @@ export function decideOutboxSweep(input: {
|
|
|
496
587
|
if (!routable) return { action: 'skip-unroutable' }
|
|
497
588
|
const delayed = age > maxAgeMs
|
|
498
589
|
const prefix = (delayed ? '(delayed) ' : '') + routePrefix
|
|
499
|
-
|
|
590
|
+
// #4141: the banner sits BETWEEN the delivery prefixes and the body — the
|
|
591
|
+
// prefixes describe the DELIVERY ("(delayed)", "(from background task)"),
|
|
592
|
+
// the banner describes the TEXT. It is additive only: it can never turn a
|
|
593
|
+
// send into a skip, so this branch cannot manufacture silence.
|
|
594
|
+
const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
|
|
595
|
+
const body = framed ? applyReplyThrowFraming(record.text) : record.text
|
|
596
|
+
return {
|
|
597
|
+
action: delayed ? 'send-delayed' : 'send',
|
|
598
|
+
text: prefix + body,
|
|
599
|
+
...(framed && body !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
|
|
600
|
+
}
|
|
500
601
|
}
|
|
501
602
|
|
|
502
603
|
export interface ResolvedChat {
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
|
|
29
29
|
import { parse } from "./parse.js";
|
|
30
30
|
import { renderSafe, type RenderResult } from "./render.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
PLAIN_TEXT_MAX_CHARS,
|
|
33
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
34
|
+
splitMarkdownChunks,
|
|
35
|
+
} from "../format.js";
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
38
|
* The legacy plain-text `sendMessage` / `editMessageText` wire cap (4096
|
|
@@ -38,8 +42,12 @@ import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
|
|
|
38
42
|
* the plain `sendMessage` endpoint (see stream-controller `doSend`/`doEdit`).
|
|
39
43
|
* A degraded plain body larger than this is rejected by Telegram with
|
|
40
44
|
* `message is too long`, so `renderOutboundChunks` caps plain pieces here.
|
|
45
|
+
*
|
|
46
|
+
* Defined in `format.ts` (#4043) so the parse-reject plain FALLBACKS in the
|
|
47
|
+
* send path can share the one constant without importing the renderer;
|
|
48
|
+
* re-exported here for this module's existing importers.
|
|
41
49
|
*/
|
|
42
|
-
export
|
|
50
|
+
export { PLAIN_TEXT_MAX_CHARS };
|
|
43
51
|
|
|
44
52
|
/** Parse the `SWITCHROOM_RICH_RENDER` kill-switch value. Default ON; disabled
|
|
45
53
|
* only by an explicit falsey/off token (`0`/`false`/`off`/`no`,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surgical quote-reply parameters (`reply_parameters.quote`).
|
|
3
|
+
*
|
|
4
|
+
* The `reply` / `stream_reply` tools document a `quote_text` argument: quote a
|
|
5
|
+
* specific fragment of the message being replied to, so the reply visibly
|
|
6
|
+
* anchors on that fragment instead of the whole message.
|
|
7
|
+
*
|
|
8
|
+
* WIRE SHAPE — the bug this module exists to make unrepeatable
|
|
9
|
+
* ------------------------------------------------------------
|
|
10
|
+
* Bot API `ReplyParameters` (https://core.telegram.org/bots/api#replyparameters):
|
|
11
|
+
*
|
|
12
|
+
* quote String Quoted part of the message to be replied to;
|
|
13
|
+
* 0-1024 characters after entities parsing.
|
|
14
|
+
* quote_parse_mode String Mode for parsing entities in the quote.
|
|
15
|
+
* quote_entities Array Entities in the quote.
|
|
16
|
+
* quote_position Integer Position of the quote in the original message in
|
|
17
|
+
* UTF-16 code units.
|
|
18
|
+
*
|
|
19
|
+
* `quote` is a **String** and `quote_position` is a **separate sibling
|
|
20
|
+
* Integer**. Both send sites used to emit `quote: { text, position: 0 }` — an
|
|
21
|
+
* object — so Telegram rejected every quoted reply with
|
|
22
|
+
* `400 Bad Request: field "quote" must be of type String`, the reply threw, and
|
|
23
|
+
* the agent re-attempted a payload that could never be accepted. A fleet sweep
|
|
24
|
+
* of `tg-post method=sendRichMessage … status=err` lines found 378 of 384 total
|
|
25
|
+
* send failures were this one error (agent `overlord`, 2026-07-29 → 07-30, in
|
|
26
|
+
* retry bursts). The feature had never worked.
|
|
27
|
+
*
|
|
28
|
+
* DELIBERATE OMISSIONS
|
|
29
|
+
* --------------------
|
|
30
|
+
* - **No `quote_position`.** Telegram locates the quote text on its own; a
|
|
31
|
+
* fabricated `0` is what encouraged the object shape in the first place, and
|
|
32
|
+
* neither send site has a real position source (the model supplies text, not
|
|
33
|
+
* an offset). A wrong position is worse than none: it either mislocates the
|
|
34
|
+
* highlight or 400s.
|
|
35
|
+
* - **No `quote_parse_mode` / `quote_entities`, and therefore NO HTML
|
|
36
|
+
* escaping.** With no parse mode the quote is matched as literal text, which
|
|
37
|
+
* is exactly what `quote_text` carries (plain text the model copied out of
|
|
38
|
+
* the user's message). Escaping it (`&` → `&`) would make the string stop
|
|
39
|
+
* being an exact substring of the original and guarantee a rejection.
|
|
40
|
+
*
|
|
41
|
+
* REJECTION HANDLING
|
|
42
|
+
* ------------------
|
|
43
|
+
* The quote must be an exact substring of the target message; Telegram 400s
|
|
44
|
+
* when it isn't found. That is a *quote* failure, not an answer failure — the
|
|
45
|
+
* reply body is still correct and must still be delivered. {@link
|
|
46
|
+
* isQuoteRejectionError} classifies it and {@link dropQuoteFromSendOpts}
|
|
47
|
+
* produces the same options with the quote removed, so callers can retry once
|
|
48
|
+
* and land the reply as an ordinary (unquoted) reply.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { GrammyError } from 'grammy'
|
|
52
|
+
|
|
53
|
+
import { isHtmlParseRejectError, isMessageTooLongError } from './retry-api-call.js'
|
|
54
|
+
|
|
55
|
+
/** Bot API cap: `quote` is 0-1024 characters after entities parsing. */
|
|
56
|
+
export const QUOTE_MAX_CHARS = 1024
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Normalize a caller-supplied `quote_text` into the string Telegram accepts, or
|
|
60
|
+
* `null` when there is nothing worth quoting.
|
|
61
|
+
*
|
|
62
|
+
* - empty / whitespace-only ⇒ `null` (an empty quote highlights nothing and is
|
|
63
|
+
* a guaranteed rejection).
|
|
64
|
+
* - longer than {@link QUOTE_MAX_CHARS} ⇒ truncated. A PREFIX of an exact
|
|
65
|
+
* substring is still an exact substring, so truncation still matches the
|
|
66
|
+
* original message, whereas an over-length quote is a certain 400. The cut
|
|
67
|
+
* never splits a surrogate pair (a lone surrogate is not valid UTF-8 on the
|
|
68
|
+
* wire).
|
|
69
|
+
* - otherwise verbatim — never escaped, never trimmed (leading/trailing spaces
|
|
70
|
+
* are part of the substring the user may have selected).
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeQuoteText(quoteText: string | null | undefined): string | null {
|
|
73
|
+
if (typeof quoteText !== 'string') return null
|
|
74
|
+
if (quoteText.trim().length === 0) return null
|
|
75
|
+
if (quoteText.length <= QUOTE_MAX_CHARS) return quoteText
|
|
76
|
+
let end = QUOTE_MAX_CHARS
|
|
77
|
+
const code = quoteText.charCodeAt(end - 1)
|
|
78
|
+
// High surrogate at the cut boundary ⇒ drop it rather than emit half a pair.
|
|
79
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1
|
|
80
|
+
return quoteText.slice(0, end)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the `reply_parameters` object for a reply to `messageId`, optionally
|
|
85
|
+
* carrying a surgical quote.
|
|
86
|
+
*
|
|
87
|
+
* This is the ONLY place the `quote` field is constructed. Both send sites (the
|
|
88
|
+
* `reply` chunk loop in `gateway/outbound-send-path.ts` and the `stream_reply`
|
|
89
|
+
* controller in `stream-controller.ts`) call it, so the wire shape cannot drift
|
|
90
|
+
* apart again.
|
|
91
|
+
*/
|
|
92
|
+
export function buildReplyParameters(
|
|
93
|
+
messageId: number,
|
|
94
|
+
quoteText?: string | null,
|
|
95
|
+
): { message_id: number; quote?: string } {
|
|
96
|
+
const quote = normalizeQuoteText(quoteText)
|
|
97
|
+
return { message_id: messageId, ...(quote != null ? { quote } : {}) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* True when Telegram rejected the send because of the QUOTE, not the body.
|
|
102
|
+
*
|
|
103
|
+
* Covers the "quote isn't in the original message" family (the documented
|
|
104
|
+
* failure mode) and any other 400 naming the quote — matched on the word rather
|
|
105
|
+
* than one exact phrase, because the Bot API's wording for this class has
|
|
106
|
+
* changed before (`QUOTE_TEXT_INVALID` vs the prose form) and a missed match
|
|
107
|
+
* costs the whole answer.
|
|
108
|
+
*
|
|
109
|
+
* A BODY error must never be claimed here, though, and the word alone is not
|
|
110
|
+
* enough to tell them apart: `<blockquote>` in a rich body produces
|
|
111
|
+
* `can't parse entities: Can't find end tag corresponding to start tag
|
|
112
|
+
* "blockquote"` / `Unsupported start tag "blockquote"` — 400s that contain the
|
|
113
|
+
* substring "quote" but are PARSE failures. Claiming one would drop the quote
|
|
114
|
+
* and resend the same unparseable body, hit the same 400, and throw straight
|
|
115
|
+
* out of the caller's fallback ladder — losing the answer that the plain-text
|
|
116
|
+
* rescue would otherwise have delivered. So defer to the body classifiers
|
|
117
|
+
* first, exactly as `isHtmlParseRejectError` defers to `isMessageTooLongError`
|
|
118
|
+
* (retry-api-call.ts) for the same reason. No false negative results: the real
|
|
119
|
+
* quote rejections (`QUOTE_TEXT_INVALID`, "message quote not found") contain
|
|
120
|
+
* neither a parse phrase nor a length phrase.
|
|
121
|
+
*/
|
|
122
|
+
export function isQuoteRejectionError(err: unknown): boolean {
|
|
123
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
124
|
+
if (isHtmlParseRejectError(err) || isMessageTooLongError(err)) return false
|
|
125
|
+
return (err.description || '').toLowerCase().includes('quote')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** True when `opts` carries a `reply_parameters.quote`. Structural on purpose:
|
|
129
|
+
* the reply path builds untyped `Record<string, unknown>` send options and the
|
|
130
|
+
* stream path a typed `StreamSendOpts`, and both must be checkable. */
|
|
131
|
+
export function sendOptsHaveQuote(opts: object): boolean {
|
|
132
|
+
const rp = (opts as { reply_parameters?: { quote?: unknown } }).reply_parameters
|
|
133
|
+
return rp != null && typeof rp === 'object' && rp.quote != null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Same send options with the surgical quote removed — the retry payload after a
|
|
138
|
+
* {@link isQuoteRejectionError}. Everything else (reply target, thread, markup,
|
|
139
|
+
* notification) is preserved, so the answer still lands as a reply to the right
|
|
140
|
+
* message; only the highlight is sacrificed.
|
|
141
|
+
*/
|
|
142
|
+
export function dropQuoteFromSendOpts<T extends object>(opts: T): T {
|
|
143
|
+
if (!sendOptsHaveQuote(opts)) return { ...opts }
|
|
144
|
+
const rp = { ...((opts as { reply_parameters: Record<string, unknown> }).reply_parameters) }
|
|
145
|
+
delete rp.quote
|
|
146
|
+
delete rp.quote_parse_mode
|
|
147
|
+
delete rp.quote_entities
|
|
148
|
+
delete rp.quote_position
|
|
149
|
+
return { ...opts, reply_parameters: rp }
|
|
150
|
+
}
|