switchroom 0.18.30 → 0.18.32
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 +4 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +708 -255
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +142 -7
- package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +5479 -7069
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -35,6 +35,60 @@ import {
|
|
|
35
35
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
36
36
|
import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
|
|
37
37
|
|
|
38
|
+
// ── send-orchestration façade imports (#2996 P2) ──
|
|
39
|
+
// Pure/deterministic helpers are imported; stateful or side-effecting gateway
|
|
40
|
+
// surfaces are injected via SendReplyGatewayDeps (see the DI contract below).
|
|
41
|
+
import { statSync } from 'fs'
|
|
42
|
+
import { extname } from 'path'
|
|
43
|
+
import { GrammyError, InputFile, type Bot, type Context } from 'grammy'
|
|
44
|
+
import {
|
|
45
|
+
combineReadBackResults,
|
|
46
|
+
type ReadBackResult,
|
|
47
|
+
} from './backstop-delivery.js'
|
|
48
|
+
import {
|
|
49
|
+
escapeMarkdown,
|
|
50
|
+
} from '../format.js'
|
|
51
|
+
import { richMessage } from '../rich-send.js'
|
|
52
|
+
import { resolveChatIdFallback } from './chat-id-fallback.js'
|
|
53
|
+
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
54
|
+
import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
|
|
55
|
+
import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
56
|
+
import { decideSupersedeCorrection, type FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
57
|
+
import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
|
|
58
|
+
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
59
|
+
import {
|
|
60
|
+
mintVoiceOnDemandToken,
|
|
61
|
+
buildListenKeyboard,
|
|
62
|
+
mayInjectListenButton,
|
|
63
|
+
type VoiceOnDemandCache,
|
|
64
|
+
} from '../voice-ondemand.js'
|
|
65
|
+
import { eagerVoiceEnabled, type PreSynthQueue } from '../voice-presynth.js'
|
|
66
|
+
import { validateInlineKeyboard, type AnyButton } from '../telegram-button-constraints.js'
|
|
67
|
+
import {
|
|
68
|
+
wrapAgentCallbacks,
|
|
69
|
+
redactAgentKeyboard,
|
|
70
|
+
extractAgentButtonMeta,
|
|
71
|
+
type AgentButtonMeta,
|
|
72
|
+
} from '../inline-keyboard-callbacks.js'
|
|
73
|
+
import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
|
|
74
|
+
import { retryWithThreadFallback, isPhotoDimensionRejectError, type RetryCallOpts } from '../retry-api-call.js'
|
|
75
|
+
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
76
|
+
import type { RuntimeMetricEvent } from '../runtime-metrics.js'
|
|
77
|
+
import {
|
|
78
|
+
settleCapturedProseDelivery,
|
|
79
|
+
silentEndFallbackText,
|
|
80
|
+
type SilentEndDeps,
|
|
81
|
+
type CapturedProseSendOutcome,
|
|
82
|
+
} from '../silent-end.js'
|
|
83
|
+
import type { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
84
|
+
import type { DraftStreamHandle } from '../draft-stream.js'
|
|
85
|
+
import type { EmissionAuthority } from './emission-authority.js'
|
|
86
|
+
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
87
|
+
// Type-only import from the gateway (erased at compile — no runtime cycle):
|
|
88
|
+
// CurrentTurn/Access are the gateway's own turn + access.json shapes, so the
|
|
89
|
+
// injected closures type EXACTLY as their gateway definitions.
|
|
90
|
+
import type { CurrentTurn, Access } from './gateway.js'
|
|
91
|
+
|
|
38
92
|
/** The redactor the caller injects. In gateway this is `redactOutboundText`,
|
|
39
93
|
* which wraps `redact()` and logs (never the secret value) when a mask fires.
|
|
40
94
|
* Injected rather than imported so the redaction structural-wiring test
|
|
@@ -375,3 +429,1961 @@ export async function sendReplyChunks(
|
|
|
375
429
|
|
|
376
430
|
return { threadId, previewMessageId }
|
|
377
431
|
}
|
|
432
|
+
|
|
433
|
+
// ─── Read-back confirmation probe (#3278) ─────────────────────────────────
|
|
434
|
+
//
|
|
435
|
+
// A returned message_id proves Telegram ACCEPTED a send, not that the message
|
|
436
|
+
// is VISIBLE: a server-side accept-then-silently-discard (flood/anti-spam)
|
|
437
|
+
// returns a fresh id yet the user sees nothing. The only echo primitive the Bot
|
|
438
|
+
// API offers (there is no getMessage) is a no-op `editMessageText` against the
|
|
439
|
+
// returned id — a `400 message to edit not found` proves absence, an edit-ok /
|
|
440
|
+
// `message is not modified` proves presence. This primitive classifies that
|
|
441
|
+
// probe per id and combines a chunk's ids into one verdict; the backstop
|
|
442
|
+
// delivery orchestrator (`runBackstopDelivery`) consumes the verdict to decide
|
|
443
|
+
// confirm / demote-and-re-send / leave-unconfirmed. Scoped to the RARE backstop
|
|
444
|
+
// path only — the hot reply-tool send path issues ZERO probes (#3278 §1.3/§1.5).
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Classify a single-id read-back `editMessageText` FAILURE into a
|
|
448
|
+
* {@link ReadBackResult}. Pure over the thrown error:
|
|
449
|
+
* - `400 message to edit not found` ⇒ `absent` (positive drop → safe re-send)
|
|
450
|
+
* - `400 message is not modified` ⇒ `exists` (a no-op edit → message present)
|
|
451
|
+
* - anything else (429 / 5xx / network / thread-not-found / parse) ⇒ `ambiguous`
|
|
452
|
+
* (never re-send — a re-send would risk a duplicate).
|
|
453
|
+
*
|
|
454
|
+
* A read-back that RESOLVES (the edit applied) means the message existed and is
|
|
455
|
+
* classified `exists` by the caller — this helper only maps the error branch.
|
|
456
|
+
*/
|
|
457
|
+
export function classifyReadBackError(err: unknown): ReadBackResult {
|
|
458
|
+
if (err instanceof GrammyError && err.error_code === 400) {
|
|
459
|
+
const d = (err.description || '').toLowerCase()
|
|
460
|
+
if (d.includes('message to edit not found')) return 'absent'
|
|
461
|
+
if (d.includes('not modified')) return 'exists'
|
|
462
|
+
}
|
|
463
|
+
return 'ambiguous'
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Injected per-id probe for {@link createBackstopReadBackProbe}. Resolves to the
|
|
467
|
+
* chunk-id's {@link ReadBackResult} — the gateway wires this to a no-op
|
|
468
|
+
* `editMessageText` paced through the send gate (cosmetic priority, so it sheds
|
|
469
|
+
* under a flood window and never storms); a test supplies a scripted fake. */
|
|
470
|
+
export interface BackstopReadBackDeps {
|
|
471
|
+
/** Probe ONE landed message id (edit it to `body`, its identical rich render). */
|
|
472
|
+
probeId: (messageId: number, body: unknown) => Promise<ReadBackResult>
|
|
473
|
+
/** rich-markdown wrapper (`richMessage`) — the probe edits to the SAME body the
|
|
474
|
+
* chunk was sent with, so an existing message returns "not modified" (no
|
|
475
|
+
* visible mutation) rather than actually changing the delivered answer. */
|
|
476
|
+
richMessage: (s: string) => unknown
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Build the `readBack` dep injected into `runBackstopDelivery` (#3278). For each
|
|
481
|
+
* landed chunk it probes EVERY message id (a length-resplit chunk lands >1) and
|
|
482
|
+
* combines them (guard A7 — the chunk is confirmed only if ALL ids exist; ANY
|
|
483
|
+
* positive absence ⇒ re-send the whole chunk). An empty id set is `ambiguous`
|
|
484
|
+
* (nothing to probe → fabricate neither a confirmation nor a demotion).
|
|
485
|
+
*/
|
|
486
|
+
export function createBackstopReadBackProbe(
|
|
487
|
+
deps: BackstopReadBackDeps,
|
|
488
|
+
): (chunkIndex: number, messageIds: readonly number[], text: string) => Promise<ReadBackResult> {
|
|
489
|
+
return async (_chunkIndex, messageIds, text) => {
|
|
490
|
+
if (messageIds.length === 0) return 'ambiguous'
|
|
491
|
+
const body = deps.richMessage(text)
|
|
492
|
+
const perId: ReadBackResult[] = []
|
|
493
|
+
for (const id of messageIds) {
|
|
494
|
+
perId.push(await deps.probeId(id, body))
|
|
495
|
+
}
|
|
496
|
+
return combineReadBackResults(perId)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Injected wiring for {@link createBackstopReadBack}. The gateway supplies its
|
|
501
|
+
* raw send primitives; a test can drive the whole probe with fakes. */
|
|
502
|
+
export interface BackstopReadBackWiring {
|
|
503
|
+
/** Raw `editMessageText` with the chat_id baked in by the caller — edits the
|
|
504
|
+
* message to `body` with `apiOpts`; resolves grammy's result or throws the
|
|
505
|
+
* raw grammy error (NOT swallowed, so not-found/not-modified stay
|
|
506
|
+
* distinguishable via {@link classifyReadBackError}). */
|
|
507
|
+
editMessageText: (messageId: number, body: unknown, apiOpts: unknown) => Promise<unknown>
|
|
508
|
+
/** Send-gate wrapper — the probe routes through it at COSMETIC priority so it
|
|
509
|
+
* sheds under a flood window (never storms) and paces like every other send. */
|
|
510
|
+
gate: <T>(fn: () => Promise<T>, opts: RetryCallOpts) => Promise<T>
|
|
511
|
+
/** `SEND_GATE_SHED` sentinel detector: a shed probe is `ambiguous` (never a
|
|
512
|
+
* re-send), NOT a false `exists`. */
|
|
513
|
+
isShed: (r: unknown) => boolean
|
|
514
|
+
/** rich-markdown wrapper (`richMessage`). */
|
|
515
|
+
richMessage: (s: string) => unknown
|
|
516
|
+
chatId: string
|
|
517
|
+
threadId: number | undefined
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Build the complete #3278 `readBack` dep for `runBackstopDelivery`, owning the
|
|
522
|
+
* per-id probe: a no-op `editMessageText` to the message's IDENTICAL body,
|
|
523
|
+
* paced cosmetic through the send gate. A resolved edit (or a `not modified`
|
|
524
|
+
* 400) ⇒ `exists`; a `message to edit not found` 400 ⇒ `absent`; a gate shed,
|
|
525
|
+
* 429/5xx/network, or any other throw ⇒ `ambiguous` (never re-send). Per-chunk
|
|
526
|
+
* ids are combined by {@link createBackstopReadBackProbe} (guard A7).
|
|
527
|
+
*/
|
|
528
|
+
export function createBackstopReadBack(
|
|
529
|
+
w: BackstopReadBackWiring,
|
|
530
|
+
): (chunkIndex: number, messageIds: readonly number[], text: string) => Promise<ReadBackResult> {
|
|
531
|
+
const probeId = async (messageId: number, body: unknown): Promise<ReadBackResult> => {
|
|
532
|
+
// Match the SEND's link_preview_options so editing to the identical body is
|
|
533
|
+
// a genuine no-op ("message is not modified") and never visibly mutates the
|
|
534
|
+
// delivered answer. editMessageText targets a globally-unique message_id, so
|
|
535
|
+
// no message_thread_id is needed on the edit itself.
|
|
536
|
+
const editApiOpts = { link_preview_options: { is_disabled: true } }
|
|
537
|
+
// Retry/gate metadata (RetryCallOpts) — keys the gate's per-message edit
|
|
538
|
+
// floor + cosmetic shedding; NOT sent to Telegram as API params.
|
|
539
|
+
const gateOpts: RetryCallOpts = {
|
|
540
|
+
chat_id: w.chatId,
|
|
541
|
+
verb: 'backstop.readback',
|
|
542
|
+
priorityClass: 'cosmetic',
|
|
543
|
+
messageId,
|
|
544
|
+
editPayload: body,
|
|
545
|
+
...(w.threadId != null ? { threadId: w.threadId } : {}),
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const r = await w.gate(() => w.editMessageText(messageId, body, editApiOpts), gateOpts)
|
|
549
|
+
return w.isShed(r) ? 'ambiguous' : 'exists'
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return classifyReadBackError(err)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return createBackstopReadBackProbe({ probeId, richMessage: w.richMessage })
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ─── Send-orchestration façade (#2996 P2, Amendments 9/10) ────────────────
|
|
558
|
+
//
|
|
559
|
+
// `executeReply`'s full orchestration body and the `deliverCapturedProse`
|
|
560
|
+
// silent-end-recovery send path, relocated VERBATIM from gateway.ts so the
|
|
561
|
+
// send path is one invocable, testable primitive (see
|
|
562
|
+
// send-reply-golden.test.ts). gateway.ts keeps thin wrappers that pin the
|
|
563
|
+
// turn at entry and inject the live singletons + closures.
|
|
564
|
+
//
|
|
565
|
+
// DI contract (plan Amendment 9 — supersedes the original P2 contract):
|
|
566
|
+
// - `outboundDedup` is THE one live instance (#546 dedup cache). A re-`new`
|
|
567
|
+
// anywhere in this module is a hard review-fail: the stream-render (P4)
|
|
568
|
+
// surface records into the SAME cache, and a second instance reinstates
|
|
569
|
+
// the duplicate-reply class (Amendment 1).
|
|
570
|
+
// - BOTH the entry-pinned turn (`req.turn`, #1664 attribution) AND a live
|
|
571
|
+
// `getCurrentTurn()` accessor are provided. Each call site keeps its
|
|
572
|
+
// original pin-vs-live choice VERBATIM — the live re-reads (over-ping
|
|
573
|
+
// block, card-finalize, silent-anchor, dedup registry keys) are
|
|
574
|
+
// load-bearing: a turn that ends mid-send must skip card-finalize.
|
|
575
|
+
// Collapsing to pin-everywhere is a forbidden behavior change.
|
|
576
|
+
// - `backstopDeliveryLedger` and `sendGate` are deliberately NOT injected:
|
|
577
|
+
// 0 references in this body (the ledger is the P4 stream surface's dep;
|
|
578
|
+
// the send gate lives inside the injected `robustApiCall`).
|
|
579
|
+
// - Pure helpers are IMPORTED; anything stateful, side-effecting, or
|
|
580
|
+
// gateway-configured is INJECTED (so the golden harness can fake it).
|
|
581
|
+
//
|
|
582
|
+
// The bodies below are byte-identical to the pre-move gateway.ts inline
|
|
583
|
+
// bodies except for the 8 enumerated pin-vs-live spellings
|
|
584
|
+
// (`currentTurn` -> `getCurrentTurn()` / `req.turn` /
|
|
585
|
+
// `getLastActiveTurnChatId()`) and the deps destructure preamble. Do not
|
|
586
|
+
// "clean up" while moving — bugfixes ship as separate PRs.
|
|
587
|
+
|
|
588
|
+
/** The subset of the gateway's voice-out plan the send path consumes. */
|
|
589
|
+
export interface VoiceOutPlan {
|
|
590
|
+
engine: 'kokoro' | 'openai'
|
|
591
|
+
voice?: string
|
|
592
|
+
speed: number
|
|
593
|
+
apiKeyRef?: string
|
|
594
|
+
replyMode: 'voice+text' | 'voice-only' | 'on-demand'
|
|
595
|
+
ttsChunks: string[]
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export interface SendReplyRequest {
|
|
599
|
+
/** Raw `reply` tool args, exactly as the MCP dispatch received them. */
|
|
600
|
+
args: Record<string, unknown>
|
|
601
|
+
/** #1664 — the turn pinned at executeReply entry by the gateway wrapper.
|
|
602
|
+
* Late writes (finalAnswerDelivered) attribute to THIS turn even if the
|
|
603
|
+
* module-scope turn rolls over mid-call. */
|
|
604
|
+
turn: CurrentTurn | null
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Gateway dependencies for {@link sendReply}. Function members use method
|
|
608
|
+
* syntax deliberately (bivariant) so the gateway's more-precisely-typed
|
|
609
|
+
* closures are assignable without adapter noise. */
|
|
610
|
+
export interface SendReplyGatewayDeps {
|
|
611
|
+
// ── shared singletons — the ONE live instance each (Amendment 1/9) ──
|
|
612
|
+
outboundDedup: OutboundDedupCache
|
|
613
|
+
flushedTurnSupersede: FlushedTurnSupersedeRegistry
|
|
614
|
+
firstTextReplyLogged: Set<string>
|
|
615
|
+
suppressPtyPreview: Set<string>
|
|
616
|
+
activeDraftStreams: Map<string, DraftStreamHandle>
|
|
617
|
+
lastPtyPreviewByChat: Map<string, string>
|
|
618
|
+
voiceOnDemandCache: VoiceOnDemandCache
|
|
619
|
+
voicePreSynthQueue: PreSynthQueue
|
|
620
|
+
/** cross-turn pending-async ambient ticker (module-singleton namespace). */
|
|
621
|
+
pendingProgress: {
|
|
622
|
+
clearPending(key: string, reason: string): void
|
|
623
|
+
noteOutbound(key: string, anchor: { messageId: number; text: string; literalText: boolean }): void
|
|
624
|
+
}
|
|
625
|
+
/** outbound-gap / TTFO KPI tracker (module-singleton namespace). */
|
|
626
|
+
signalTracker: {
|
|
627
|
+
noteOutbound(key: string, at: number): void
|
|
628
|
+
noteSignal(key: string, at: number): void
|
|
629
|
+
}
|
|
630
|
+
/** silence-poke clock (module-singleton namespace). */
|
|
631
|
+
silencePoke: { noteOutbound(key: string, at: number): void }
|
|
632
|
+
|
|
633
|
+
// ── turn identity (Amendment 9: pin-vs-live preserved per call site) ──
|
|
634
|
+
getCurrentTurn(): CurrentTurn | null
|
|
635
|
+
getLastActiveTurnChatId(): string | undefined
|
|
636
|
+
|
|
637
|
+
// ── gateway config values ──
|
|
638
|
+
HISTORY_ENABLED: boolean
|
|
639
|
+
TURN_ORIGIN_ROUTING_ENABLED: boolean
|
|
640
|
+
AUTOCLASSIFY_MIDTURN_SHADOW: boolean
|
|
641
|
+
MAX_ATTACHMENT_BYTES: number
|
|
642
|
+
MAX_CHUNK_LIMIT: number
|
|
643
|
+
PHOTO_EXTS: Set<string>
|
|
644
|
+
|
|
645
|
+
// ── bot + retry policy (the send gate lives inside robustApiCall) ──
|
|
646
|
+
lockedBot: Bot<Context>
|
|
647
|
+
robustApiCall<T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T>
|
|
648
|
+
swallowingApiCall(fn: () => Promise<unknown>, meta: RetryCallOpts): Promise<unknown>
|
|
649
|
+
|
|
650
|
+
// ── gateway closures ──
|
|
651
|
+
loadAccess(): Access
|
|
652
|
+
redactOutboundText(text: string, site: string): string
|
|
653
|
+
assertAllowedChat(chatId: string | number): void
|
|
654
|
+
assertSendable(f: string): void
|
|
655
|
+
statusKey(chatId: string, threadId?: number | null): string
|
|
656
|
+
streamKey(chatId: string, threadId?: number | null): string
|
|
657
|
+
resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): CurrentTurn | null
|
|
658
|
+
findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
|
|
659
|
+
findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
|
|
660
|
+
resolveAnswerThreadWithLog(
|
|
661
|
+
chatId: string,
|
|
662
|
+
explicitThreadId: number | undefined,
|
|
663
|
+
originTurn: CurrentTurn | null,
|
|
664
|
+
originVia: 'echo' | 'quoted' | null,
|
|
665
|
+
liveTurn: CurrentTurn | null,
|
|
666
|
+
surface: 'reply' | 'stream_reply',
|
|
667
|
+
): number | undefined
|
|
668
|
+
resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
|
|
669
|
+
getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
|
|
670
|
+
recordOutbound(rec: {
|
|
671
|
+
chat_id: string
|
|
672
|
+
thread_id: number | null
|
|
673
|
+
message_ids: number[]
|
|
674
|
+
texts: string[]
|
|
675
|
+
attachment_kinds?: (string | null)[]
|
|
676
|
+
}): void
|
|
677
|
+
emissionAuthorityFor(turn: CurrentTurn): EmissionAuthority
|
|
678
|
+
clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | null): void
|
|
679
|
+
startTypingLoop(chatId: string, threadId?: number | null): void
|
|
680
|
+
stopTypingLoop(chatId: string, threadId?: number | null): void
|
|
681
|
+
logOutbound(
|
|
682
|
+
path: 'reply' | 'edit',
|
|
683
|
+
chatId: string,
|
|
684
|
+
messageId: number | null,
|
|
685
|
+
chars: number,
|
|
686
|
+
extra?: string,
|
|
687
|
+
): void
|
|
688
|
+
closeObligationOnSubstantiveReply(
|
|
689
|
+
args: Record<string, unknown>,
|
|
690
|
+
liveTurn: CurrentTurn | null | undefined,
|
|
691
|
+
routedOriginTurn?: CurrentTurn | null,
|
|
692
|
+
): void
|
|
693
|
+
finalizeStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done'): void
|
|
694
|
+
releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void
|
|
695
|
+
reapQueuedStatus(chatId: string, thread: number | undefined): void
|
|
696
|
+
noteAgentOutputAt(key: string, ts: number): void
|
|
697
|
+
rememberAgentButtonMeta(chatId: string | number, messageId: number, meta: Map<string, AgentButtonMeta>): void
|
|
698
|
+
resolveVoiceOutPlan(voiceOut: Access['voice_out'], replyText: string): VoiceOutPlan | null
|
|
699
|
+
synthesizeVoiceOut(plan: {
|
|
700
|
+
engine: 'kokoro' | 'openai'
|
|
701
|
+
voice?: string
|
|
702
|
+
speed?: number
|
|
703
|
+
apiKeyRef?: string
|
|
704
|
+
ttsText: string
|
|
705
|
+
}): Promise<Uint8Array | null>
|
|
706
|
+
publishToTelegraph(text: string, shortName: string, authorName?: string): Promise<string | null>
|
|
707
|
+
clearSilentEndState(key: string): void
|
|
708
|
+
emitRuntimeMetric(m: RuntimeMetricEvent): void
|
|
709
|
+
shadowEmit(ev: { kind: 'modelOutbound'; key: _ChatKey; at: number }): void
|
|
710
|
+
progressDriver: { recordOutboundDelivered(chatId: string, threadId?: string): void } | null
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* The reply send orchestration — `executeReply`'s body, verbatim.
|
|
715
|
+
* See the section comment above for the DI contract and the enumerated
|
|
716
|
+
* verbatim deviations.
|
|
717
|
+
*/
|
|
718
|
+
export async function sendReply(
|
|
719
|
+
deps: SendReplyGatewayDeps,
|
|
720
|
+
req: SendReplyRequest,
|
|
721
|
+
): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
722
|
+
const {
|
|
723
|
+
outboundDedup, flushedTurnSupersede, firstTextReplyLogged, suppressPtyPreview,
|
|
724
|
+
activeDraftStreams, lastPtyPreviewByChat, voiceOnDemandCache, voicePreSynthQueue,
|
|
725
|
+
pendingProgress, signalTracker, silencePoke,
|
|
726
|
+
getCurrentTurn, getLastActiveTurnChatId, progressDriver,
|
|
727
|
+
HISTORY_ENABLED, TURN_ORIGIN_ROUTING_ENABLED, AUTOCLASSIFY_MIDTURN_SHADOW,
|
|
728
|
+
MAX_ATTACHMENT_BYTES, MAX_CHUNK_LIMIT, PHOTO_EXTS,
|
|
729
|
+
lockedBot, robustApiCall, swallowingApiCall,
|
|
730
|
+
loadAccess, redactOutboundText, assertAllowedChat, assertSendable,
|
|
731
|
+
statusKey, streamKey,
|
|
732
|
+
resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
|
|
733
|
+
resolveAnswerThreadWithLog, resolveThreadId,
|
|
734
|
+
getLatestInboundMessageId, recordOutbound,
|
|
735
|
+
emissionAuthorityFor, clearActivitySummary,
|
|
736
|
+
startTypingLoop, stopTypingLoop, logOutbound,
|
|
737
|
+
closeObligationOnSubstantiveReply, finalizeStatusReaction,
|
|
738
|
+
releaseTurnBufferGate, reapQueuedStatus, noteAgentOutputAt,
|
|
739
|
+
rememberAgentButtonMeta, resolveVoiceOutPlan, synthesizeVoiceOut,
|
|
740
|
+
publishToTelegraph, clearSilentEndState, emitRuntimeMetric, shadowEmit,
|
|
741
|
+
} = deps
|
|
742
|
+
const args = req.args
|
|
743
|
+
// #1664 — pin the turn this reply belongs to at entry. The
|
|
744
|
+
// finalAnswerDelivered write near the end of this function runs after
|
|
745
|
+
// several awaits; turn-pinning (the #1067 pattern used across the
|
|
746
|
+
// gateway) keeps the write attributed to THIS turn rather than reading
|
|
747
|
+
// module-scope currentTurn, which a future refactor could let roll over
|
|
748
|
+
// mid-call. (#2996 P2: the pin is taken by the gateway wrapper at call
|
|
749
|
+
// entry and passed in `req.turn` — same read, same position.)
|
|
750
|
+
const turn = req.turn
|
|
751
|
+
const _rawChatId = String(args.chat_id ?? '')
|
|
752
|
+
if (!_rawChatId) throw new Error('reply: chat_id is required')
|
|
753
|
+
// Non-Claude models (e.g. Gemini via LiteLLM sr-* routing) sometimes pass a
|
|
754
|
+
// chat_id that is not in the allowlist — either an int/string mismatch, or
|
|
755
|
+
// the model echoing the wrong identifier from context. When the raw value
|
|
756
|
+
// fails the allowlist check, fall back to the active (or last-known) turn's
|
|
757
|
+
// validated sessionChatId so the reply still lands correctly.
|
|
758
|
+
// Tier 1: live turn (currentTurn was non-null at reply entry).
|
|
759
|
+
// Tier 2: last-known turn (survives silence poke — Bug D fix; currentTurn is
|
|
760
|
+
// null because clearTurnStarted fired ≥5 min of model silence, but the model
|
|
761
|
+
// finally called reply after the poke).
|
|
762
|
+
const chat_id = (() => {
|
|
763
|
+
const resolved = resolveChatIdFallback(
|
|
764
|
+
_rawChatId,
|
|
765
|
+
loadAccess(),
|
|
766
|
+
turn?.sessionChatId,
|
|
767
|
+
getLastActiveTurnChatId(),
|
|
768
|
+
turn != null,
|
|
769
|
+
)
|
|
770
|
+
if (resolved.tier !== 'raw') {
|
|
771
|
+
process.stderr.write(
|
|
772
|
+
`telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
773
|
+
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
|
|
777
|
+
})()
|
|
778
|
+
const rawText = args.text as string | undefined
|
|
779
|
+
if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
|
|
780
|
+
// Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
|
|
781
|
+
// into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
|
|
782
|
+
// code are left untouched — see normalizeParagraphBreaks).
|
|
783
|
+
// Outbound text pipeline (#2996 §3B): normalize → redact → punctuation/bold
|
|
784
|
+
// → voice-scrub, extracted verbatim into outbound-send-path.ts. The order is
|
|
785
|
+
// load-bearing (secret scrub BEFORE the punctuation/bold normalizers so a
|
|
786
|
+
// secret with an em-dash or `**` is matched literally; voice scrub last so
|
|
787
|
+
// retries see the scrubbed dedup key). The metric side effect (fired on a
|
|
788
|
+
// non-zero voice-scrub replacement) stays here — the pure module returns the
|
|
789
|
+
// replacement count and the gateway emits.
|
|
790
|
+
const _normalized = normalizeOutboundBody(rawText, 'reply', redactOutboundText)
|
|
791
|
+
let text = _normalized.text
|
|
792
|
+
if (_normalized.voiceReplaced > 0) {
|
|
793
|
+
emitRuntimeMetric({
|
|
794
|
+
kind: 'voice_scrub_applied',
|
|
795
|
+
chatKey: statusKey(chat_id, args.message_thread_id != null
|
|
796
|
+
? Number(args.message_thread_id) : undefined),
|
|
797
|
+
replaced: _normalized.voiceReplaced,
|
|
798
|
+
site: 'reply',
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
|
|
802
|
+
// #2527: emit time_to_first_text_reply_ms on the FIRST text reply of each
|
|
803
|
+
// turn so operators can see how long users waited for any visible output.
|
|
804
|
+
// Only fires once per turn (firstTextReplyLogged guards the repeat).
|
|
805
|
+
if (turn != null) {
|
|
806
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
807
|
+
const replyKey = statusKey(chat_id, threadId)
|
|
808
|
+
if (!firstTextReplyLogged.has(replyKey)) {
|
|
809
|
+
firstTextReplyLogged.add(replyKey)
|
|
810
|
+
logStreamingEvent({
|
|
811
|
+
kind: 'turn_reply_timing',
|
|
812
|
+
chatId: chat_id,
|
|
813
|
+
threadId,
|
|
814
|
+
turnId: turn.turnId,
|
|
815
|
+
timeToFirstTextReplyMs: Date.now() - turn.gatewayReceiveAt,
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// #546 dedup check: was this content just sent via turn-flush or
|
|
821
|
+
// a sibling reply path? Skip the actual send and return a
|
|
822
|
+
// plausible tool result so claude-code's retry loop closes
|
|
823
|
+
// cleanly. NOTE: only fires when content matches; legitimate
|
|
824
|
+
// late-replies with different content sail through.
|
|
825
|
+
{
|
|
826
|
+
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
827
|
+
const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), getCurrentTurn()?.registryKey ?? null)
|
|
828
|
+
if (dup != null) {
|
|
829
|
+
process.stderr.write(
|
|
830
|
+
`telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
|
|
831
|
+
`ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}\n`,
|
|
832
|
+
)
|
|
833
|
+
return { content: [{ type: 'text', text: 'sent (deduped — same content sent via earlier path)' }] }
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// 2026-07 duplicate-reply fix — turnId-keyed supersede (consumption side).
|
|
838
|
+
// If an earlier turn-flush (answer-ready quiescence OR the turn-end backstop)
|
|
839
|
+
// already posted THIS turn's terminal text and the model's REAL `reply` for
|
|
840
|
+
// the same turn is landing now (it was still composing the tool call when the
|
|
841
|
+
// flush fired, or claude-code replayed the tool_call after a bridge
|
|
842
|
+
// reconnect), delete the flushed message(s) so the canonical reply below
|
|
843
|
+
// delivers exactly one clean message instead of a second one. Keyed on the
|
|
844
|
+
// per-turn `turnId` nonce, so it fires even when the flushed narration+answer
|
|
845
|
+
// blob differs from the clean answer-only reply — the containment case the
|
|
846
|
+
// exact-text `outboundDedup` above structurally cannot catch. A reply for a
|
|
847
|
+
// DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
|
|
848
|
+
// so a fresh turn's answer is never clobbered.
|
|
849
|
+
//
|
|
850
|
+
// Reply-flicker fix: rather than delete the flushed message(s) HERE (which
|
|
851
|
+
// makes the user see delete+replace when the canonical reply sends fresh
|
|
852
|
+
// below), we DEFER the correction to the send site. Once chunk count / files /
|
|
853
|
+
// preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
|
|
854
|
+
// single flushed message into the canonical reply — no flicker, no re-ping)
|
|
855
|
+
// when the reply fits one plain-text message, and falls back to the legacy
|
|
856
|
+
// delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
|
|
857
|
+
let supersedeFlushIds: number[] = []
|
|
858
|
+
{
|
|
859
|
+
const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
860
|
+
// 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
|
|
861
|
+
// belongs to by IDENTITY, via the SAME full chain the thread-router uses
|
|
862
|
+
// (`resolveReplyOwnerTurn`): live `currentTurn`, then the model-echoed
|
|
863
|
+
// `origin_turn_id`, then the framework-owned quoted message id, then the
|
|
864
|
+
// chat's most-recently-ended turn. The prior chain stopped at
|
|
865
|
+
// `currentTurn ?? findTurnByOriginId`, so a DM late reply — `currentTurn`
|
|
866
|
+
// nulled by the flush's synthetic turn_end AND no `origin_turn_id` (a
|
|
867
|
+
// supergroup-only field) — resolved to a null owner. `decideSupersede`
|
|
868
|
+
// deliberately never lets a null live turn supersede a turnId-bearing flush
|
|
869
|
+
// record, so message A survived AND the reply shipped message B (the exact
|
|
870
|
+
// double-send). The quoted / latest-ended recoveries are precisely what the
|
|
871
|
+
// router already did for the same reply, so unifying here makes the two
|
|
872
|
+
// resolvers agree and the late-reply supersede fires by identity.
|
|
873
|
+
const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
|
|
874
|
+
const resolvedTurnId = ownerTurn?.turnId ?? null
|
|
875
|
+
const decision = flushedTurnSupersede.take(
|
|
876
|
+
chat_id,
|
|
877
|
+
replyThreadId,
|
|
878
|
+
{ liveTurnId: resolvedTurnId, now: Date.now() },
|
|
879
|
+
)
|
|
880
|
+
if (decision.supersede) {
|
|
881
|
+
process.stderr.write(
|
|
882
|
+
`telegram gateway: reply: superseding flushed turn message(s) ` +
|
|
883
|
+
`chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
|
|
884
|
+
)
|
|
885
|
+
// Deferred: the correction (edit-in-place vs delete+resend) is decided at
|
|
886
|
+
// the send site once chunk count / files / preview are known.
|
|
887
|
+
supersedeFlushIds = decision.deleteMessageIds
|
|
888
|
+
// Set the answer-delivered latch NOW, at record consumption — BEFORE the
|
|
889
|
+
// arg-validation throws between here and the correction site (file
|
|
890
|
+
// too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
|
|
891
|
+
// late reply that supersedes a flush AND carries an oversized file /
|
|
892
|
+
// invalid keyboard would throw before the correction runs (message A
|
|
893
|
+
// neither deleted nor edited), the model would retry `reply`, and — the
|
|
894
|
+
// supersede record already consumed by `take()` above — the retry would
|
|
895
|
+
// fall into the else/no-record branch below with no latch set, so
|
|
896
|
+
// suppression wouldn't fire and a fresh B would ship alongside the stale
|
|
897
|
+
// narration A (both visible). Latching here mirrors the else-branch's own
|
|
898
|
+
// `answerDelivered = true` and closes that resurrection window: the retry
|
|
899
|
+
// resolves the same ended owner turn, sees the latch, and is suppressed —
|
|
900
|
+
// exactly one message ever ships. The latch is idempotent and the normal
|
|
901
|
+
// (no-throw) path is unaffected: the correction below still ships B once.
|
|
902
|
+
if (ownerTurn != null) ownerTurn.answerDelivered = true
|
|
903
|
+
} else {
|
|
904
|
+
// 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
|
|
905
|
+
// Supersede found no record. Either there was no flush (normal reply), or
|
|
906
|
+
// the flush FIRED but has not yet recorded its message ids (the residual
|
|
907
|
+
// pre-record race Part 1's supersede cannot reach). The flush sets
|
|
908
|
+
// `answerDelivered = true` synchronously at fire time (before its async
|
|
909
|
+
// send AND before `record`), and it persists on the ended turn — so when
|
|
910
|
+
// this LATE, substantive reply resolves its owner turn and sees the latch
|
|
911
|
+
// already set, the flush's message A is already on its way out and this
|
|
912
|
+
// reply would ship a duplicate. Suppress it. Scoped to the substantive
|
|
913
|
+
// ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
|
|
914
|
+
// interim sub-floor ack, a chunked multi-part answer, or a legitimate
|
|
915
|
+
// second in-turn substantive reply (live `currentTurn`) is never
|
|
916
|
+
// suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
|
|
917
|
+
// the `reply` path (no `done`); pass the model's original notification
|
|
918
|
+
// intent to mirror the #2533 decoupling call shape.
|
|
919
|
+
const replySubstantive = isSubstantiveFinalReply({
|
|
920
|
+
text: rawText,
|
|
921
|
+
disableNotification: args.disable_notification === true,
|
|
922
|
+
})
|
|
923
|
+
const suppressByLatch = decideAnswerLatchSuppression({
|
|
924
|
+
superseded: false,
|
|
925
|
+
replySubstantive,
|
|
926
|
+
isLateReply: turn == null,
|
|
927
|
+
ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
|
|
928
|
+
})
|
|
929
|
+
if (suppressByLatch) {
|
|
930
|
+
process.stderr.write(
|
|
931
|
+
`telegram gateway: reply: suppressed by answer-delivered latch ` +
|
|
932
|
+
`(flush already delivered this turn's answer) chatId=${chat_id} ` +
|
|
933
|
+
`ownerTurnId=${JSON.stringify(resolvedTurnId)}\n`,
|
|
934
|
+
)
|
|
935
|
+
return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
|
|
936
|
+
}
|
|
937
|
+
// A substantive answer is going out via this reply — set the latch on its
|
|
938
|
+
// owner turn so a later bridge-replayed / reworded duplicate of the same
|
|
939
|
+
// answer is caught by the branch above.
|
|
940
|
+
if (replySubstantive && ownerTurn != null) {
|
|
941
|
+
ownerTurn.answerDelivered = true
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const files = (args.files as string[] | undefined) ?? []
|
|
947
|
+
const quoteOptIn = args.quote !== false
|
|
948
|
+
let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
|
|
949
|
+
const protectContent = args.protect_content === true
|
|
950
|
+
const quoteText = args.quote_text as string | undefined
|
|
951
|
+
const access = loadAccess()
|
|
952
|
+
// Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
|
|
953
|
+
// plain-text TTS input); synthesis happens just before the send so a
|
|
954
|
+
// voice-only reply can suppress the text chunk loop on success. Voice is
|
|
955
|
+
// fully best-effort — every failure below falls back to the text reply.
|
|
956
|
+
const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
|
|
957
|
+
const configParseMode = access.parseMode ?? 'html'
|
|
958
|
+
const format = (args.format as string | undefined) ?? configParseMode
|
|
959
|
+
const disableLinkPreview = args.disable_web_page_preview != null
|
|
960
|
+
? Boolean(args.disable_web_page_preview)
|
|
961
|
+
: (access.disableLinkPreview ?? true)
|
|
962
|
+
// #1122 conversational pacing: mid-turn updates pass disable_notification:true
|
|
963
|
+
// so only the final answer pings the device. Default false (pings) so
|
|
964
|
+
// existing call-sites and the typical "final answer" reply keep their
|
|
965
|
+
// current behaviour without an explicit flag.
|
|
966
|
+
let disableNotification = args.disable_notification === true
|
|
967
|
+
// #2527/#1664 — the over-ping safety net below may downgrade
|
|
968
|
+
// `disableNotification` ping→silent for ANTI-SPAM (one ping per turn). That
|
|
969
|
+
// delivery-channel decision must NOT pollute final-answer CLASSIFICATION: a
|
|
970
|
+
// final answer the model intended to ping is STILL the final answer even when
|
|
971
|
+
// the framework silences the actual ping. Classify on the model's original
|
|
972
|
+
// intent (what executeReply already does), so an over-ping-silenced
|
|
973
|
+
// final answer sets finalAnswerDelivered=true — fixing both a spurious
|
|
974
|
+
// silent-end re-prompt and a false 'undelivered' (😐) terminal reaction.
|
|
975
|
+
const modelDisableNotification = args.disable_notification === true
|
|
976
|
+
|
|
977
|
+
// #1675 over-ping safety net. The conversational-pacing contract
|
|
978
|
+
// (`reference/rfcs/conversational-pacing.md` beat 5) says EXACTLY ONE
|
|
979
|
+
// device ping per turn — the final answer. The model sometimes
|
|
980
|
+
// violates this by sending a substantive answer pinged + a wrap-up
|
|
981
|
+
// ("Delivered all three steps…", "Sent.", or meta-narration) ALSO
|
|
982
|
+
// pinged. Both messages then fire notifications. The fleet UAT on
|
|
983
|
+
// 2026-05-23 reproduced this (Step 3 + Delivered both pinged, two
|
|
984
|
+
// beeps for a turn that should have produced one). Framework owns
|
|
985
|
+
// the safety net: once the turn has emitted ONE pinged reply, every
|
|
986
|
+
// subsequent reply call in the same turn auto-downgrades to silent
|
|
987
|
+
// (disable_notification: true). Model intent ("I want this loud")
|
|
988
|
+
// is honoured for the first ping; subsequent pings are demoted with
|
|
989
|
+
// a stderr log so operators can see the safety net engage.
|
|
990
|
+
//
|
|
991
|
+
// The slot is claimed BEFORE the actual send to keep the logic
|
|
992
|
+
// sequential — a send that fails part-way leaves firstPingAt set
|
|
993
|
+
// and subsequent pings would be silenced. Acceptable trade-off (a
|
|
994
|
+
// failed first ping is an edge case; the alternative — claim after
|
|
995
|
+
// send — races concurrent reply calls).
|
|
996
|
+
// Tracks whether the over-ping safety net coerced this reply
|
|
997
|
+
// from ping→silent. Threaded into the silent-anchor predicate
|
|
998
|
+
// below: a demoted final-answer reply must NOT merge into the
|
|
999
|
+
// silent preamble bubble; it lands as a fresh silent bubble so
|
|
1000
|
+
// the user can still find it (see #1674 / silent-anchor follow-up).
|
|
1001
|
+
let wasOverPingSuppressed = false
|
|
1002
|
+
{
|
|
1003
|
+
const turn = getCurrentTurn()
|
|
1004
|
+
if (turn != null) {
|
|
1005
|
+
const now = Date.now()
|
|
1006
|
+
// Notification ownership (R8 / PR-2): on the `reply` path,
|
|
1007
|
+
// substantiveness is purely the ≥200-char (or `done`) backstop —
|
|
1008
|
+
// `isSubstantiveFinalReply` is `done === true || text.length >= 200`
|
|
1009
|
+
// and ignores the notification flag entirely. `reply` carries no
|
|
1010
|
+
// `done`, so it reduces to the ≥200-char length test. We still pass
|
|
1011
|
+
// `modelDisableNotification` (the MODEL's original intent, not the
|
|
1012
|
+
// possibly-downgraded `disableNotification`) to mirror the #2533
|
|
1013
|
+
// final-answer decoupling call shape, but that arg does NOT
|
|
1014
|
+
// participate in classification here — it is inert on this path.
|
|
1015
|
+
const replySubstantive = isSubstantiveFinalReply({
|
|
1016
|
+
text: rawText,
|
|
1017
|
+
disableNotification: modelDisableNotification,
|
|
1018
|
+
})
|
|
1019
|
+
// PR-4c: the over-ping DECISION relocates into the emission-authority
|
|
1020
|
+
// façade, behind the kill-switch (default OFF), the same structural way
|
|
1021
|
+
// PR-4b moved the OPEN gate. `decideOverPing` is already pure, so PR-4c
|
|
1022
|
+
// extracts NOTHING new — it relocates the *call* into the façade's enabled
|
|
1023
|
+
// branch and keeps the *effects* (stderr, metric, the atomic
|
|
1024
|
+
// `firstPingAt`/`firstPingWasSubstantive` pair-set, the
|
|
1025
|
+
// `disableNotification`/`wasOverPingSuppressed` outer-scope writes) HERE,
|
|
1026
|
+
// parameterized by the decision the façade hands back via `applyDecision`.
|
|
1027
|
+
//
|
|
1028
|
+
// - Disabled branch runs `disabledOverPing()` — its own LITERAL
|
|
1029
|
+
// `decideOverPing(...)` call + the full effects block, VERBATIM from
|
|
1030
|
+
// PR-4b-base (the disabled-path-is-byte-identical proof).
|
|
1031
|
+
// - Enabled branch: the façade computes the decision and hands it to
|
|
1032
|
+
// `applyOverPingDecision(decision)`, which performs the IDENTICAL
|
|
1033
|
+
// effects. Same pure inputs ⇒ same decision ⇒ flag-ON ≡ flag-OFF ≡ base.
|
|
1034
|
+
//
|
|
1035
|
+
// The effects block is shared between both thunks by closing over `decision`
|
|
1036
|
+
// — but the disabled thunk computes it via its OWN literal `decideOverPing(`
|
|
1037
|
+
// first, so the disabled path never depends on the façade for the decision.
|
|
1038
|
+
const applyOverPingDecision = (decision: OverPingDecision): void => {
|
|
1039
|
+
if (decision.suppress) {
|
|
1040
|
+
process.stderr.write(
|
|
1041
|
+
`telegram gateway: reply over-ping safety net — ` +
|
|
1042
|
+
`downgrading disable_notification:false → true ` +
|
|
1043
|
+
`(chat=${chat_id} thread=${args.message_thread_id ?? '-'} ` +
|
|
1044
|
+
`firstPingAt=${turn.firstPingAt} sinceFirstPing_ms=${decision.sinceFirstPingMs})\n`,
|
|
1045
|
+
)
|
|
1046
|
+
// Observability: surface to the unified runtime-metrics
|
|
1047
|
+
// fan-out so the cadence dashboard can track fleet-wide
|
|
1048
|
+
// over-ping rate (leading indicator of model pacing drift).
|
|
1049
|
+
emitRuntimeMetric({
|
|
1050
|
+
kind: 'over_ping_suppressed',
|
|
1051
|
+
key: statusKey(chat_id, args.message_thread_id != null
|
|
1052
|
+
? Number(args.message_thread_id) : undefined),
|
|
1053
|
+
sinceFirstPingMs: decision.sinceFirstPingMs ?? 0,
|
|
1054
|
+
})
|
|
1055
|
+
disableNotification = true
|
|
1056
|
+
wasOverPingSuppressed = true
|
|
1057
|
+
} else if (decision.claimSlot) {
|
|
1058
|
+
// Claim (first ping) OR upgrade (substantive answer pinging over an
|
|
1059
|
+
// ack's slot). Set firstPingAt AND firstPingWasSubstantive ATOMICALLY
|
|
1060
|
+
// (no await between) so a racing second reply reads a consistent pair.
|
|
1061
|
+
turn.firstPingAt = now
|
|
1062
|
+
turn.firstPingWasSubstantive = replySubstantive
|
|
1063
|
+
if (decision.upgrade) {
|
|
1064
|
+
process.stderr.write(
|
|
1065
|
+
`telegram gateway: reply over-ping safety net — ` +
|
|
1066
|
+
`UPGRADE: substantive answer pings over an ack's slot ` +
|
|
1067
|
+
`(chat=${chat_id} thread=${args.message_thread_id ?? '-'})\n`,
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
emissionAuthorityFor(turn).claimOrDowngradePing(
|
|
1073
|
+
{ modelRequestedPing: !disableNotification, substantive: replySubstantive },
|
|
1074
|
+
{
|
|
1075
|
+
firstPingAt: turn.firstPingAt,
|
|
1076
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
1077
|
+
nowMs: now,
|
|
1078
|
+
},
|
|
1079
|
+
applyOverPingDecision,
|
|
1080
|
+
() => {
|
|
1081
|
+
// Disabled-path: literal `decideOverPing(` + effects, VERBATIM base.
|
|
1082
|
+
const decision = decideOverPing({
|
|
1083
|
+
modelRequestedPing: !disableNotification,
|
|
1084
|
+
firstPingAt: turn.firstPingAt,
|
|
1085
|
+
substantive: replySubstantive,
|
|
1086
|
+
firstPingWasSubstantive: turn.firstPingWasSubstantive,
|
|
1087
|
+
nowMs: now,
|
|
1088
|
+
})
|
|
1089
|
+
applyOverPingDecision(decision)
|
|
1090
|
+
},
|
|
1091
|
+
)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Telegraph publish (#579). When the reply text is long enough AND
|
|
1096
|
+
// the agent has telegraph enabled in access.json, publish to
|
|
1097
|
+
// Telegraph + replace the local text with a single-message link.
|
|
1098
|
+
// Telegram renders the link as a native Instant View card.
|
|
1099
|
+
// Failure paths fall through to normal HTML chunking, so a flaky
|
|
1100
|
+
// Telegraph backend never breaks the reply path.
|
|
1101
|
+
const tg = access.telegraph
|
|
1102
|
+
const tgThreshold = tg?.threshold ?? 3000
|
|
1103
|
+
if (tg?.enabled && files.length === 0 && text.length > tgThreshold) {
|
|
1104
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME ?? 'switchroom-agent'
|
|
1105
|
+
const shortName = tg.short_name ?? agentSlug
|
|
1106
|
+
const url = await publishToTelegraph(text, shortName, tg.author_name)
|
|
1107
|
+
if (url != null) {
|
|
1108
|
+
const title = deriveTelegraphTitle(text)
|
|
1109
|
+
// Replace the local text with a one-line link. The first line
|
|
1110
|
+
// is the chosen title (so the user sees the topic at a glance);
|
|
1111
|
+
// the second line is the URL Telegram will Instant-View.
|
|
1112
|
+
text = `**${escapeMarkdown(title)}**\n${url}`
|
|
1113
|
+
}
|
|
1114
|
+
// url null → fall through and chunk; the user still gets the
|
|
1115
|
+
// long reply, just split across messages.
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Single rich-markdown path (#2669). The only fork is `format:'text'`,
|
|
1119
|
+
// a literal/no-markdown send: the body bypasses the rich parser entirely
|
|
1120
|
+
// (plain `sendMessage`, no rich wrapper). Everything else — the default —
|
|
1121
|
+
// ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
|
|
1122
|
+
// raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
|
|
1123
|
+
const literalText = format === 'text'
|
|
1124
|
+
// Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
|
|
1125
|
+
// renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
|
|
1126
|
+
// jammed together — unlike the old HTML path. Inject a visible blank-line
|
|
1127
|
+
// spacer into prose `\n\n` gaps on the rich path only. The literal
|
|
1128
|
+
// (`format:'text'`) path must stay byte-exact, so it is left untouched.
|
|
1129
|
+
const effectiveText: string = computeEffectiveText(text, literalText)
|
|
1130
|
+
|
|
1131
|
+
assertAllowedChat(chat_id)
|
|
1132
|
+
|
|
1133
|
+
// Thread resolution precedence (ANSWER path, component 3 — turn-origin
|
|
1134
|
+
// routing): (1) explicit message_thread_id the model passed; else
|
|
1135
|
+
// (2) the ORIGIN turn's thread — the turn that OWNS this reply, matched
|
|
1136
|
+
// by origin_turn_id (the meta field the model echoes back). This is
|
|
1137
|
+
// authoritative even after `currentTurn` has flipped to a successor (the
|
|
1138
|
+
// Brevo→Meta late-reply bug). Else (3) the live turn's thread (legacy
|
|
1139
|
+
// #1664 fallback when no origin turn is resolvable). Answer paths
|
|
1140
|
+
// DELIBERATELY do NOT fall through to chatThreadMap last-seen — that
|
|
1141
|
+
// heuristic is what mis-routed a late reply to whichever topic most
|
|
1142
|
+
// recently received a message. DM: every tier is undefined → unchanged.
|
|
1143
|
+
// Kill switch off → exact legacy resolveThreadId precedence.
|
|
1144
|
+
// Hoist the resolved origin turn so the obligation-close path (below) can
|
|
1145
|
+
// pass it into resolveCloseTarget as routedOriginId, closing re-presented
|
|
1146
|
+
// obligations even when the model omitted origin_turn_id (Fix 1/2).
|
|
1147
|
+
let replyRoutedOriginTurn: CurrentTurn | null = null
|
|
1148
|
+
let threadId: number | undefined
|
|
1149
|
+
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
1150
|
+
const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
1151
|
+
// Origin precedence: model echo first (authoritative), then the
|
|
1152
|
+
// framework-owned quoted message_id (deterministic, no model thread
|
|
1153
|
+
// assertion) as a fallback when the model omitted the echo.
|
|
1154
|
+
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
1155
|
+
const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
|
|
1156
|
+
const originTurn = echoedTurn ?? quotedTurn
|
|
1157
|
+
replyRoutedOriginTurn = originTurn ?? null
|
|
1158
|
+
threadId = resolveAnswerThreadWithLog(
|
|
1159
|
+
chat_id,
|
|
1160
|
+
Number.isFinite(explicit as number) ? (explicit as number) : undefined,
|
|
1161
|
+
originTurn,
|
|
1162
|
+
originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
|
|
1163
|
+
turn,
|
|
1164
|
+
'reply',
|
|
1165
|
+
)
|
|
1166
|
+
} else {
|
|
1167
|
+
threadId = resolveThreadId(
|
|
1168
|
+
chat_id,
|
|
1169
|
+
(args.message_thread_id as string | undefined) ??
|
|
1170
|
+
(turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
|
|
1171
|
+
)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
|
|
1175
|
+
try {
|
|
1176
|
+
const latest = getLatestInboundMessageId(chat_id, threadId ?? null)
|
|
1177
|
+
if (latest != null) reply_to = latest
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
process.stderr.write(`telegram gateway: quote-reply lookup failed: ${(err as Error).message}\n`)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
for (const f of files) {
|
|
1184
|
+
assertSendable(f)
|
|
1185
|
+
const st = statSync(f)
|
|
1186
|
+
if (st.size > MAX_ATTACHMENT_BYTES) {
|
|
1187
|
+
throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
|
|
1192
|
+
const replyMode = access.replyToMode ?? 'first'
|
|
1193
|
+
const chunks = computeReplyChunks({
|
|
1194
|
+
effectiveText,
|
|
1195
|
+
literalText,
|
|
1196
|
+
limit,
|
|
1197
|
+
chunkMode: access.chunkMode ?? 'length',
|
|
1198
|
+
})
|
|
1199
|
+
const sentIds: number[] = []
|
|
1200
|
+
|
|
1201
|
+
// Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
|
|
1202
|
+
// voice-only reply can suppress the text chunks on success. ONE voice note
|
|
1203
|
+
// per response for the kokoro path (ttsChunks is a single element — the
|
|
1204
|
+
// whole normalized reply — synthesized in one /tts call). The OpenAI path
|
|
1205
|
+
// may still produce several ordered notes (one per chunk) because of its
|
|
1206
|
+
// input cap. Ken is often on a bike/driving and can't read the screen, so
|
|
1207
|
+
// the full answer must be SPOKEN. Best-effort: a chunk that fails to
|
|
1208
|
+
// synthesize is skipped; if NOTHING synthesizes the text path proceeds
|
|
1209
|
+
// unchanged so the answer is never dropped.
|
|
1210
|
+
// on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
|
|
1211
|
+
// synthesizes via the local sidecar, so a Listen button is only meaningful
|
|
1212
|
+
// when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
|
|
1213
|
+
// local host verdict for kokoro, so engine==='kokoro' here implies the
|
|
1214
|
+
// sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
|
|
1215
|
+
// NOT inject a button (its taps would dead-end on the local sidecar) — we
|
|
1216
|
+
// fall through to the normal immediate-synth path so the openai reply behaves
|
|
1217
|
+
// exactly like a normal openai voice reply.
|
|
1218
|
+
const useOnDemandButton =
|
|
1219
|
+
voiceOutPlan != null &&
|
|
1220
|
+
voiceOutPlan.replyMode === 'on-demand' &&
|
|
1221
|
+
voiceOutPlan.engine === 'kokoro'
|
|
1222
|
+
|
|
1223
|
+
const voiceOggs: Uint8Array[] = []
|
|
1224
|
+
// Skip reply-time synthesis ONLY when we're actually deferring to a Listen
|
|
1225
|
+
// button (kokoro on-demand). An openai on-demand config still synthesizes
|
|
1226
|
+
// immediately below.
|
|
1227
|
+
if (voiceOutPlan != null && !useOnDemandButton) {
|
|
1228
|
+
for (const chunkText of voiceOutPlan.ttsChunks) {
|
|
1229
|
+
const ogg = await synthesizeVoiceOut({
|
|
1230
|
+
engine: voiceOutPlan.engine,
|
|
1231
|
+
voice: voiceOutPlan.voice,
|
|
1232
|
+
speed: voiceOutPlan.speed,
|
|
1233
|
+
apiKeyRef: voiceOutPlan.apiKeyRef,
|
|
1234
|
+
ttsText: chunkText,
|
|
1235
|
+
})
|
|
1236
|
+
// Skip a failed chunk but keep going — a partial spoken answer still
|
|
1237
|
+
// beats silence; full-fail (no oggs at all) falls back to text below.
|
|
1238
|
+
if (ogg != null) voiceOggs.push(ogg)
|
|
1239
|
+
}
|
|
1240
|
+
if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
|
|
1241
|
+
process.stderr.write(
|
|
1242
|
+
`telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
|
|
1243
|
+
)
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
// Suppress the text body ONLY when voice-only AND we synthesized the FULL
|
|
1247
|
+
// set of chunks (every part of the answer is spoken). A partial or total
|
|
1248
|
+
// synthesis failure leaves the text path running so the answer still
|
|
1249
|
+
// lands in full — never drop the user's answer silently.
|
|
1250
|
+
const suppressText =
|
|
1251
|
+
voiceOutPlan?.replyMode === 'voice-only' &&
|
|
1252
|
+
voiceOutPlan.ttsChunks.length > 0 &&
|
|
1253
|
+
voiceOggs.length === voiceOutPlan.ttsChunks.length
|
|
1254
|
+
|
|
1255
|
+
// #271: validate inline_keyboard and namespace any callback_data with
|
|
1256
|
+
// the `agent:` prefix so the gateway's callback_query dispatcher can
|
|
1257
|
+
// round-trip taps back to this agent without colliding with
|
|
1258
|
+
// infrastructure prefixes (auth:/op:/vd:/vg:/aq:/perm:). URL buttons
|
|
1259
|
+
// pass through unchanged. Attached to the LAST chunk only so buttons
|
|
1260
|
+
// appear on the final visible message.
|
|
1261
|
+
let replyMarkup: { inline_keyboard: AnyButton[][] } | undefined
|
|
1262
|
+
let replyButtonMeta: Map<string, AgentButtonMeta> | undefined
|
|
1263
|
+
const rawKeyboard = args.inline_keyboard as AnyButton[][] | undefined
|
|
1264
|
+
if (rawKeyboard != null) {
|
|
1265
|
+
const validationErrors = validateInlineKeyboard(rawKeyboard)
|
|
1266
|
+
if (validationErrors.length > 0) {
|
|
1267
|
+
const summary = validationErrors
|
|
1268
|
+
.map((e) => `${e.path}.${e.field}: ${e.reason}`)
|
|
1269
|
+
.join('; ')
|
|
1270
|
+
throw new Error(`inline_keyboard validation failed: ${summary}`)
|
|
1271
|
+
}
|
|
1272
|
+
// #3148 fast-follow: mask any secret an agent put in a visible button
|
|
1273
|
+
// `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
|
|
1274
|
+
// payload BEFORE the keyboard is sent — the same outbound scrub the reply
|
|
1275
|
+
// `text` body uses. `callback_data` (the routing key) is left exact.
|
|
1276
|
+
// Feeding BOTH the meta extraction and the callback wrap from the redacted
|
|
1277
|
+
// copy means the stashed toast, the tap echo (`button_text`), and the
|
|
1278
|
+
// "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
|
|
1279
|
+
const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
|
|
1280
|
+
redactOutboundText(s, 'reply_inline_keyboard'),
|
|
1281
|
+
)
|
|
1282
|
+
replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
|
|
1283
|
+
replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
1287
|
+
// spoken reply only when tapped. The button carries a RAW `voice:<token>`
|
|
1288
|
+
// callback_data (NOT wrapped with the agent: prefix) so the dispatcher
|
|
1289
|
+
// handles it internally and never routes it to the agent as an inbound.
|
|
1290
|
+
//
|
|
1291
|
+
// Collision gate: inject ONLY when the reply carries no agent-authored
|
|
1292
|
+
// buttons. If the agent supplied its own keyboard, the callback dispatcher's
|
|
1293
|
+
// single_use strip (keyboardIsSingleUse) governs that whole message; adding
|
|
1294
|
+
// a foreign single_use:false button would flip that message to a mixed
|
|
1295
|
+
// keyboard and defeat the agent's double-fire protection. Keep it simple —
|
|
1296
|
+
// agent buttons present → skip the Listen button for this message.
|
|
1297
|
+
//
|
|
1298
|
+
// useOnDemandButton already gates on engine==='kokoro': an openai on-demand
|
|
1299
|
+
// config never reaches here (it synthesized immediately above), so a Listen
|
|
1300
|
+
// button is never minted for an engine whose taps would dead-end on the
|
|
1301
|
+
// local sidecar.
|
|
1302
|
+
if (
|
|
1303
|
+
useOnDemandButton &&
|
|
1304
|
+
voiceOutPlan!.ttsChunks.length > 0 &&
|
|
1305
|
+
voiceOutPlan!.ttsChunks[0]!.length > 0
|
|
1306
|
+
) {
|
|
1307
|
+
if (!mayInjectListenButton(rawKeyboard)) {
|
|
1308
|
+
process.stderr.write(
|
|
1309
|
+
'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
|
|
1310
|
+
)
|
|
1311
|
+
} else {
|
|
1312
|
+
// Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
|
|
1313
|
+
// invariant the operator is the only authorized sender across all chats,
|
|
1314
|
+
// and the tap handler re-checks access.allowFrom before synthesizing, so
|
|
1315
|
+
// a token needs no per-chat scoping to be safe.
|
|
1316
|
+
const token = mintVoiceOnDemandToken()
|
|
1317
|
+
voiceOnDemandCache.put(token, {
|
|
1318
|
+
// ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
|
|
1319
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
1320
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
1321
|
+
speed: voiceOutPlan.speed,
|
|
1322
|
+
})
|
|
1323
|
+
// The keyboard stays after the tap (never stripped) so it can be
|
|
1324
|
+
// replayed. The gate above guarantees this is the ONLY button on the
|
|
1325
|
+
// message, so keeping it is safe (no agent buttons to protect).
|
|
1326
|
+
replyMarkup = buildListenKeyboard(token)
|
|
1327
|
+
// #2763 eager pre-synthesis: kick a background synth of the same
|
|
1328
|
+
// payload so the Listen tap attaches the pre-made file instantly.
|
|
1329
|
+
// Local-engine only (useOnDemandButton already gates engine==='kokoro'
|
|
1330
|
+
// — the cloud engine never eager-synthesizes). enqueue() is a
|
|
1331
|
+
// synchronous array push; the drain is deferred + async and never
|
|
1332
|
+
// awaited here, so the text sends below are never delayed and a synth
|
|
1333
|
+
// failure can never affect message delivery (the tap just falls back
|
|
1334
|
+
// to the lazy path).
|
|
1335
|
+
if (eagerVoiceEnabled()) {
|
|
1336
|
+
voicePreSynthQueue.enqueue({
|
|
1337
|
+
token,
|
|
1338
|
+
text: voiceOutPlan.ttsChunks[0]!,
|
|
1339
|
+
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
1340
|
+
speed: voiceOutPlan.speed,
|
|
1341
|
+
})
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const replySKey = streamKey(chat_id, threadId)
|
|
1347
|
+
suppressPtyPreview.add(replySKey)
|
|
1348
|
+
let previewMessageId: number | null = null
|
|
1349
|
+
const openStream = activeDraftStreams.get(replySKey)
|
|
1350
|
+
if (openStream && !openStream.isFinal()) {
|
|
1351
|
+
await openStream.finalize().catch(() => {})
|
|
1352
|
+
previewMessageId = openStream.getMessageId()
|
|
1353
|
+
activeDraftStreams.delete(replySKey)
|
|
1354
|
+
lastPtyPreviewByChat.delete(replySKey)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Pre-alloc placeholder consumption removed in #553 PR 5 — the
|
|
1358
|
+
// gateway no longer pre-allocates a draft on inbound, so reply tools
|
|
1359
|
+
// simply render fresh. The 👀 reaction (#568) and typing indicator
|
|
1360
|
+
// (#585) provide the visual gap that the placeholder used to fill.
|
|
1361
|
+
|
|
1362
|
+
const deleteStalePreview = async (id: number): Promise<void> => {
|
|
1363
|
+
// #1075: operates on a message in a (possibly-deleted) thread. The
|
|
1364
|
+
// "delete not found" case is already swallowed by robustApiCall; on
|
|
1365
|
+
// THREAD_NOT_FOUND, swallowingApiCall logs + returns undefined so the
|
|
1366
|
+
// reply flow continues with a fresh send.
|
|
1367
|
+
await swallowingApiCall(
|
|
1368
|
+
() => lockedBot.api.deleteMessage(chat_id, id),
|
|
1369
|
+
{ chat_id, verb: 'reply.deleteStalePreview' },
|
|
1370
|
+
)
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
logStreamingEvent({
|
|
1374
|
+
kind: 'reply_called',
|
|
1375
|
+
chatId: chat_id,
|
|
1376
|
+
charCount: effectiveText.length,
|
|
1377
|
+
replacedPreview: previewMessageId != null,
|
|
1378
|
+
previewMessageId,
|
|
1379
|
+
})
|
|
1380
|
+
// #1122 KPI: a `reply` always produces a fresh user-visible outbound
|
|
1381
|
+
// message — count it for the outbound-gap / TTFO KPI AND reset the
|
|
1382
|
+
// silence-poke clock so the next poke is measured from this send.
|
|
1383
|
+
signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
1384
|
+
silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
1385
|
+
// Mid-turn auto-classify recency clock: the agent just produced visible output
|
|
1386
|
+
// in this chat/thread (cross-turn, unlike silencePoke's per-turn lastOutboundAt).
|
|
1387
|
+
// Only maintained when the shadow flag is on → truly zero overhead by default.
|
|
1388
|
+
if (AUTOCLASSIFY_MIDTURN_SHADOW) noteAgentOutputAt(statusKey(chat_id, threadId), Date.now())
|
|
1389
|
+
// PR3b-cutover: feed lastOutboundAt to the delivery machine so its
|
|
1390
|
+
// TTL `tick` suppresses the fallback for a long-but-active turn
|
|
1391
|
+
// (model streaming past 5 min) — parity with silencePoke's own
|
|
1392
|
+
// suppression, so the cutover gate doesn't clear a live turn.
|
|
1393
|
+
shadowEmit({ kind: 'modelOutbound', key: statusKey(chat_id, threadId) as _ChatKey, at: Date.now() })
|
|
1394
|
+
// #1741 — only clear silent-end state on a plausibly-final reply.
|
|
1395
|
+
// An interim ack (disable_notification:true, short text, no done)
|
|
1396
|
+
// must NOT clear the state file; otherwise a turn that ends with
|
|
1397
|
+
// ack-only + answer-as-transcript leaves no state for the Stop
|
|
1398
|
+
// hook to act on if `turn_end` never lands (the `turn_duration`
|
|
1399
|
+
// system event is unreliable for trivial-prompt turns — see the
|
|
1400
|
+
// executeReply finalize comments). Final-answer replies still
|
|
1401
|
+
// clear; the main turn-end path also re-writes the state when
|
|
1402
|
+
// finalAnswerDelivered=false, so this is a belt-and-braces gate
|
|
1403
|
+
// for the turn_end-missing case (#1741).
|
|
1404
|
+
if (isFinalAnswerReply({ text: rawText, disableNotification: modelDisableNotification })) {
|
|
1405
|
+
clearSilentEndState(statusKey(chat_id, threadId))
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Lever 2 (design §9 lever 2): finalize the activity card BEFORE the reply
|
|
1409
|
+
// chunks send, so the card keeps its (lower) message_id and the reply is
|
|
1410
|
+
// structurally last on screen. ONLY for a *substantive* final — for an ack
|
|
1411
|
+
// (non-substantive) do NOTHING: finalizing an ack early would
|
|
1412
|
+
// close → reopen → emit MORE messages (the #2141 ack-then-work feed, R3).
|
|
1413
|
+
// `clearActivitySummary` edits the existing card in place (no new send) and
|
|
1414
|
+
// nulls `activityMessageId`; combined with the sticky latch set here it
|
|
1415
|
+
// prevents any post-reply re-OPEN below the answer. Idempotent with the
|
|
1416
|
+
// tool_use-event clear at the first-reply handoff (the existing backstop).
|
|
1417
|
+
{
|
|
1418
|
+
const finalizeTurn = getCurrentTurn()
|
|
1419
|
+
if (
|
|
1420
|
+
finalizeTurn != null
|
|
1421
|
+
&& isSubstantiveFinalReply({ text: rawText, disableNotification: modelDisableNotification })
|
|
1422
|
+
) {
|
|
1423
|
+
// PR-4a: routed through the emission-authority façade (no-op delegates —
|
|
1424
|
+
// the latch-set and the finalize run exactly as before).
|
|
1425
|
+
const ea = emissionAuthorityFor(finalizeTurn)
|
|
1426
|
+
ea.markSubstantiveFinalDelivered(() => {
|
|
1427
|
+
finalizeTurn.finalAnswerEverDelivered = true
|
|
1428
|
+
finalizeTurn.finalAnswerDeliveredAt = Date.now()
|
|
1429
|
+
})
|
|
1430
|
+
ea.finalizeCard(() => {
|
|
1431
|
+
clearActivitySummary(finalizeTurn)
|
|
1432
|
+
})
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Reply-flicker fix — apply the deferred flushed-turn correction now that
|
|
1437
|
+
// chunk count / files / preview are all resolved. `edit-in-place` reuses the
|
|
1438
|
+
// single-message edit lane below (`previewMessageId`): it edits the flushed
|
|
1439
|
+
// message A into the canonical reply B with the SAME rich rendering a fresh
|
|
1440
|
+
// reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
|
|
1441
|
+
// 400 (message too old / uneditable / gone) — so exactly one message with the
|
|
1442
|
+
// canonical content always survives. We also forgo the quote (an edit can't
|
|
1443
|
+
// carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
|
|
1444
|
+
// guard just below does NOT delete our edit target. `delete-resend` keeps the
|
|
1445
|
+
// legacy behaviour (delete the flushed message(s), then send fresh below).
|
|
1446
|
+
if (supersedeFlushIds.length > 0) {
|
|
1447
|
+
const correction = decideSupersedeCorrection({
|
|
1448
|
+
flushMessageIds: supersedeFlushIds,
|
|
1449
|
+
chunkCount: chunks.length,
|
|
1450
|
+
hasFiles: files.length > 0,
|
|
1451
|
+
suppressText,
|
|
1452
|
+
hasOpenPreview: previewMessageId != null,
|
|
1453
|
+
})
|
|
1454
|
+
if (correction.mode === 'edit-in-place') {
|
|
1455
|
+
previewMessageId = correction.editMessageId
|
|
1456
|
+
reply_to = undefined
|
|
1457
|
+
process.stderr.write(
|
|
1458
|
+
`telegram gateway: reply: superseding flushed message via edit-in-place ` +
|
|
1459
|
+
`chatId=${chat_id} id=${correction.editMessageId}\n`,
|
|
1460
|
+
)
|
|
1461
|
+
} else {
|
|
1462
|
+
for (const id of correction.deleteMessageIds) {
|
|
1463
|
+
await swallowingApiCall(
|
|
1464
|
+
() => lockedBot.api.deleteMessage(chat_id, id),
|
|
1465
|
+
{ chat_id, verb: 'reply.supersedeFlushed' },
|
|
1466
|
+
)
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
|
|
1472
|
+
await deleteStalePreview(previewMessageId)
|
|
1473
|
+
previewMessageId = null
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
startTypingLoop(chat_id, threadId ?? null)
|
|
1477
|
+
|
|
1478
|
+
// #1677 silent-reply auto-edit. Consecutive silent replies within
|
|
1479
|
+
// a turn edit a single anchor message instead of stacking new
|
|
1480
|
+
// bubbles. We branch BEFORE the chunk loop so the single-chunk
|
|
1481
|
+
// common case takes an editMessageText path; everything else
|
|
1482
|
+
// (multi-chunk, ping, files, buttons) falls through to fresh send
|
|
1483
|
+
// and either captures a new anchor or doesn't, per the predicate.
|
|
1484
|
+
let silentAnchorEditDone = false
|
|
1485
|
+
{
|
|
1486
|
+
const turn = getCurrentTurn()
|
|
1487
|
+
// Skip the silent-anchor merge when a flushed-turn supersede is active: an
|
|
1488
|
+
// edit-in-place correction has re-pointed `previewMessageId` at the flushed
|
|
1489
|
+
// message (the chunk loop below edits it), and merging into a prior silent
|
|
1490
|
+
// anchor instead would orphan the flushed message A (leaving BOTH A and the
|
|
1491
|
+
// anchor visible — the exact duplicate this supersede exists to prevent).
|
|
1492
|
+
if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
|
|
1493
|
+
const decision = decideSilentReplyAnchor({
|
|
1494
|
+
effectivelySilent: disableNotification,
|
|
1495
|
+
anchorMessageId: turn.silentAnchorMessageId,
|
|
1496
|
+
anchorText: turn.silentAnchorText,
|
|
1497
|
+
newReplyText: effectiveText,
|
|
1498
|
+
hasFiles: files.length > 0,
|
|
1499
|
+
hasButtons: replyMarkup != null,
|
|
1500
|
+
wasOverPingSuppressed,
|
|
1501
|
+
})
|
|
1502
|
+
if (decision.kind === 'edit-anchor') {
|
|
1503
|
+
const editParams: {
|
|
1504
|
+
message_thread_id?: number
|
|
1505
|
+
link_preview_options?: { is_disabled: boolean }
|
|
1506
|
+
} = {
|
|
1507
|
+
link_preview_options: { is_disabled: disableLinkPreview },
|
|
1508
|
+
}
|
|
1509
|
+
if (threadId != null) editParams.message_thread_id = threadId
|
|
1510
|
+
try {
|
|
1511
|
+
await robustApiCall(
|
|
1512
|
+
() =>
|
|
1513
|
+
lockedBot.api.editMessageText(
|
|
1514
|
+
chat_id,
|
|
1515
|
+
decision.messageId,
|
|
1516
|
+
literalText ? decision.mergedText : richMessage(decision.mergedText),
|
|
1517
|
+
editParams,
|
|
1518
|
+
),
|
|
1519
|
+
{
|
|
1520
|
+
chat_id,
|
|
1521
|
+
verb: 'reply.silent-anchor-edit',
|
|
1522
|
+
...(threadId != null ? { threadId } : {}),
|
|
1523
|
+
},
|
|
1524
|
+
)
|
|
1525
|
+
turn.silentAnchorText = decision.mergedText
|
|
1526
|
+
sentIds.push(decision.messageId)
|
|
1527
|
+
logOutbound(
|
|
1528
|
+
'edit',
|
|
1529
|
+
chat_id,
|
|
1530
|
+
decision.messageId,
|
|
1531
|
+
decision.mergedText.length,
|
|
1532
|
+
'silent-anchor-merge',
|
|
1533
|
+
)
|
|
1534
|
+
process.stderr.write(
|
|
1535
|
+
`telegram gateway: silent-reply auto-edit — ` +
|
|
1536
|
+
`chat=${chat_id} anchor=${decision.messageId} ` +
|
|
1537
|
+
`merged_len=${decision.mergedText.length}\n`,
|
|
1538
|
+
)
|
|
1539
|
+
|
|
1540
|
+
// #1679 — side effects the chunk-loop completion path runs.
|
|
1541
|
+
// The edit-anchor branch returns early below, so these must
|
|
1542
|
+
// be wired here too. Skipping them silently causes:
|
|
1543
|
+
// - cross-turn ambient (`pending-work-progress.ts`) holds
|
|
1544
|
+
// a stale anchor text and OVERWRITES the model's
|
|
1545
|
+
// accumulated silent content with `still working (Nm)`
|
|
1546
|
+
// when async work is in flight (this is the load-bearing
|
|
1547
|
+
// fix);
|
|
1548
|
+
// - SQLite history (`get_recent_messages`) misses the
|
|
1549
|
+
// silent-anchor content;
|
|
1550
|
+
// - #1664 silent-end re-prompt fires even when the
|
|
1551
|
+
// accumulated silent content qualifies as substantive;
|
|
1552
|
+
// - retries within the dedup window may double-send.
|
|
1553
|
+
// #1760 primary fix — clear any stale prior-turn ticker
|
|
1554
|
+
// before re-anchoring on this silent-reply edit. See the
|
|
1555
|
+
// matching comment at the executeReply finalize site below.
|
|
1556
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
1557
|
+
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
1558
|
+
messageId: decision.messageId,
|
|
1559
|
+
text: decision.mergedText,
|
|
1560
|
+
literalText,
|
|
1561
|
+
})
|
|
1562
|
+
if (HISTORY_ENABLED) {
|
|
1563
|
+
try {
|
|
1564
|
+
recordOutbound({
|
|
1565
|
+
chat_id,
|
|
1566
|
+
thread_id: threadId ?? null,
|
|
1567
|
+
message_ids: [decision.messageId],
|
|
1568
|
+
texts: [decision.mergedText],
|
|
1569
|
+
attachment_kinds: [null],
|
|
1570
|
+
})
|
|
1571
|
+
} catch (histErr) {
|
|
1572
|
+
process.stderr.write(
|
|
1573
|
+
`telegram gateway: history recordOutbound (silent-anchor-edit) failed: ${histErr instanceof Error ? histErr.message : String(histErr)}\n`,
|
|
1574
|
+
)
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (
|
|
1578
|
+
turn != null
|
|
1579
|
+
&& isFinalAnswerReply({
|
|
1580
|
+
text: decision.mergedText,
|
|
1581
|
+
disableNotification: modelDisableNotification,
|
|
1582
|
+
})
|
|
1583
|
+
) {
|
|
1584
|
+
turn.finalAnswerDelivered = true
|
|
1585
|
+
// Feed-reopen refinement: a substantive merged silent-anchor
|
|
1586
|
+
// answer must NOT re-open the feed on post-answer housekeeping.
|
|
1587
|
+
turn.finalAnswerSubstantive = isSubstantiveFinalReply({
|
|
1588
|
+
text: decision.mergedText,
|
|
1589
|
+
disableNotification: modelDisableNotification,
|
|
1590
|
+
})
|
|
1591
|
+
// Sticky ordering latch (lever 1): a substantive final closes the
|
|
1592
|
+
// card OPEN gate for the rest of the turn. NEVER cleared by reopen.
|
|
1593
|
+
if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
|
|
1594
|
+
if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
|
|
1595
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
1596
|
+
}
|
|
1597
|
+
outboundDedup.record(
|
|
1598
|
+
chat_id,
|
|
1599
|
+
threadId,
|
|
1600
|
+
decision.mergedText,
|
|
1601
|
+
Date.now(),
|
|
1602
|
+
turn?.registryKey ?? null,
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
silentAnchorEditDone = true
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
// Edit failed (e.g. message deleted, rate limit exhausted,
|
|
1608
|
+
// parse error). Fall through to fresh-send below — the
|
|
1609
|
+
// anchor will be overwritten by whatever lands.
|
|
1610
|
+
process.stderr.write(
|
|
1611
|
+
`telegram gateway: silent-reply auto-edit failed, ` +
|
|
1612
|
+
`falling back to fresh send: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
1613
|
+
)
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (silentAnchorEditDone) {
|
|
1620
|
+
// Skip the chunk loop entirely — the anchor edit IS the send.
|
|
1621
|
+
// Match the normal exit path: stop typing, then return.
|
|
1622
|
+
stopTypingLoop(chat_id, threadId ?? null)
|
|
1623
|
+
return {
|
|
1624
|
+
content: [
|
|
1625
|
+
{
|
|
1626
|
+
type: 'text',
|
|
1627
|
+
text: `edited (id: ${sentIds[0]})`,
|
|
1628
|
+
},
|
|
1629
|
+
],
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// #2996 step 1 — the chunk-send loop (with its THREAD_NOT_FOUND / oversize
|
|
1634
|
+
// re-split / parse-reject fallback ladder and partial-failure contract) is
|
|
1635
|
+
// relocated verbatim to outbound-send-path.ts's `sendReplyChunks` so it is
|
|
1636
|
+
// unit-testable against a fake bot API (gateway.ts is not importable —
|
|
1637
|
+
// Bun.listen + boot logic run at import). The raw `bot.api.*` calls stay HERE
|
|
1638
|
+
// as thin injected adapters (retry wrapping + allow-raw-bot-api markers
|
|
1639
|
+
// preserved), so the module is bot-agnostic and the check-bot-api-wrapping
|
|
1640
|
+
// allowlist is unchanged. The caller still builds the per-chunk option shape
|
|
1641
|
+
// (byte-identical). `sentIds` is threaded by reference; `threadId` /
|
|
1642
|
+
// `previewMessageId` come back for the file-send + history code below.
|
|
1643
|
+
const chunkSendDeps: ReplyChunkSendDeps = {
|
|
1644
|
+
sendRich: (opts, body, tid) =>
|
|
1645
|
+
robustApiCall(
|
|
1646
|
+
// allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
|
|
1647
|
+
() => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
|
|
1648
|
+
// #3084 PR 2: the final reply is CRITICAL — never shed; degraded mode
|
|
1649
|
+
// fails fast (structured flood_wait) instead of blocking the MCP reply.
|
|
1650
|
+
{ threadId: tid, chat_id, priorityClass: 'critical' },
|
|
1651
|
+
),
|
|
1652
|
+
sendLiteral: (opts, txt, tid) =>
|
|
1653
|
+
robustApiCall(
|
|
1654
|
+
// allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
|
|
1655
|
+
() => lockedBot.api.sendMessage(chat_id, txt, opts as never),
|
|
1656
|
+
{ threadId: tid, chat_id, priorityClass: 'critical' },
|
|
1657
|
+
),
|
|
1658
|
+
sendLiteralRaw: (opts, txt) =>
|
|
1659
|
+
// allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
|
|
1660
|
+
lockedBot.api.sendMessage(chat_id, txt, opts as never),
|
|
1661
|
+
sendRichRaw: (opts, body) =>
|
|
1662
|
+
// allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop's own classification on an already-classified length failure
|
|
1663
|
+
lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
|
|
1664
|
+
editPreview: (mid, body, opts, tid) =>
|
|
1665
|
+
robustApiCall(
|
|
1666
|
+
// allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
|
|
1667
|
+
() => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
|
|
1668
|
+
// Finalizing the reply into the preview message — still CRITICAL (this
|
|
1669
|
+
// IS the answer). Pass messageId/editPayload so the gate's per-message
|
|
1670
|
+
// floor + no-op skip engage on the edit (part3-design §4/§5, PR1 L1).
|
|
1671
|
+
{ threadId: tid, chat_id, priorityClass: 'critical', messageId: mid, editPayload: body },
|
|
1672
|
+
),
|
|
1673
|
+
richMessage,
|
|
1674
|
+
logOutbound,
|
|
1675
|
+
deleteStalePreview,
|
|
1676
|
+
stderr: (s: string) => { process.stderr.write(s) },
|
|
1677
|
+
}
|
|
1678
|
+
try {
|
|
1679
|
+
const _sendResult = await sendReplyChunks(chunkSendDeps, {
|
|
1680
|
+
chatId: chat_id,
|
|
1681
|
+
chunks,
|
|
1682
|
+
literalText,
|
|
1683
|
+
suppressText,
|
|
1684
|
+
threadId,
|
|
1685
|
+
previewMessageId,
|
|
1686
|
+
sentIds,
|
|
1687
|
+
buildSendOpts: (i, isLastChunk, tid) => {
|
|
1688
|
+
const shouldReplyTo =
|
|
1689
|
+
reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
|
|
1690
|
+
return {
|
|
1691
|
+
...(shouldReplyTo
|
|
1692
|
+
? {
|
|
1693
|
+
reply_parameters: {
|
|
1694
|
+
message_id: reply_to,
|
|
1695
|
+
...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
|
|
1696
|
+
},
|
|
1697
|
+
}
|
|
1698
|
+
: {}),
|
|
1699
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
1700
|
+
...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
|
|
1701
|
+
...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
|
|
1702
|
+
...(protectContent ? { protect_content: true } : {}),
|
|
1703
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
buildPreviewEditOpts: (isLastChunk) => {
|
|
1707
|
+
const editOpts: Record<string, unknown> = {}
|
|
1708
|
+
if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
|
|
1709
|
+
if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
|
|
1710
|
+
return editOpts
|
|
1711
|
+
},
|
|
1712
|
+
})
|
|
1713
|
+
threadId = _sendResult.threadId
|
|
1714
|
+
previewMessageId = _sendResult.previewMessageId
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1717
|
+
throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
|
|
1718
|
+
} finally {
|
|
1719
|
+
stopTypingLoop(chat_id, threadId ?? null)
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
|
|
1723
|
+
// (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
|
|
1724
|
+
// the chunk loop). A long reply produces SEVERAL notes — each spoken in
|
|
1725
|
+
// sequence so the whole answer is heard, never truncated. Best-effort and
|
|
1726
|
+
// fully non-fatal: a sendVoice failure must NEVER break the text reply
|
|
1727
|
+
// path. In voice-only mode a failure would leave the user with an
|
|
1728
|
+
// incomplete spoken answer, so on the FIRST send failure we recover by
|
|
1729
|
+
// sending the full text once and stop sending further notes.
|
|
1730
|
+
for (let v = 0; v < voiceOggs.length; v++) {
|
|
1731
|
+
const oggBytes = voiceOggs[v]!
|
|
1732
|
+
const voiceOpts: Record<string, unknown> = {
|
|
1733
|
+
// Quote the user's message only on the FIRST voice note (mirrors the
|
|
1734
|
+
// text chunk loop's first-chunk reply behaviour).
|
|
1735
|
+
...(v === 0 && reply_to != null && replyMode !== 'off'
|
|
1736
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
1737
|
+
: {}),
|
|
1738
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
1739
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
1740
|
+
}
|
|
1741
|
+
try {
|
|
1742
|
+
const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
1743
|
+
robustApiCall,
|
|
1744
|
+
(tid) => {
|
|
1745
|
+
const opts = { ...voiceOpts }
|
|
1746
|
+
if (tid != null) opts.message_thread_id = tid
|
|
1747
|
+
else delete opts.message_thread_id
|
|
1748
|
+
// allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
|
|
1749
|
+
return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
|
|
1750
|
+
},
|
|
1751
|
+
{ threadId, chat_id, verb: 'sendVoice' },
|
|
1752
|
+
)
|
|
1753
|
+
sentIds.push(sentVoice.message_id)
|
|
1754
|
+
logOutbound(
|
|
1755
|
+
'reply',
|
|
1756
|
+
chat_id,
|
|
1757
|
+
sentVoice.message_id,
|
|
1758
|
+
voiceOutPlan?.ttsChunks[v]?.length ?? 0,
|
|
1759
|
+
`voice-note=${v + 1}/${voiceOggs.length}`,
|
|
1760
|
+
)
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1763
|
+
process.stderr.write(
|
|
1764
|
+
`telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
|
|
1765
|
+
)
|
|
1766
|
+
// voice-only fell over mid-stream with text suppressed → recover by
|
|
1767
|
+
// sending the FULL text body once so the answer still lands, then
|
|
1768
|
+
// stop sending further notes (the text now carries everything).
|
|
1769
|
+
if (suppressText) {
|
|
1770
|
+
try {
|
|
1771
|
+
const opts: Record<string, unknown> = {
|
|
1772
|
+
...(reply_to != null && replyMode !== 'off'
|
|
1773
|
+
? { reply_parameters: { message_id: reply_to } }
|
|
1774
|
+
: {}),
|
|
1775
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
1776
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
1777
|
+
}
|
|
1778
|
+
// allow-raw-bot-api: voice-only recovery fallback — the rich path already ran/was skipped; send the source text plainly so the answer is never lost.
|
|
1779
|
+
const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
|
|
1780
|
+
sentIds.push(sent.message_id)
|
|
1781
|
+
logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
|
|
1782
|
+
} catch (textErr) {
|
|
1783
|
+
process.stderr.write(
|
|
1784
|
+
`telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
|
|
1785
|
+
)
|
|
1786
|
+
}
|
|
1787
|
+
break
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// #710: remember per-button agent meta (ack_text / single_use) keyed
|
|
1793
|
+
// by the message that actually carries the keyboard — that's the last
|
|
1794
|
+
// text chunk, since the keyboard is attached only on isLastChunk.
|
|
1795
|
+
if (replyButtonMeta != null && replyButtonMeta.size > 0 && sentIds.length >= chunks.length) {
|
|
1796
|
+
const keyboardMsgId = sentIds[chunks.length - 1]
|
|
1797
|
+
if (typeof keyboardMsgId === 'number') {
|
|
1798
|
+
rememberAgentButtonMeta(chat_id, keyboardMsgId, replyButtonMeta)
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// #1445 cross-turn pending-async ambient. Capture the last text
|
|
1803
|
+
// chunk as the anchor — if this turn ends with a pending async
|
|
1804
|
+
// dispatch, the framework edits THIS message in place every 60s
|
|
1805
|
+
// with a `— still working (Nm)` suffix until the user re-engages.
|
|
1806
|
+
// Multi-chunk replies: anchor is the LAST chunk (edits append to
|
|
1807
|
+
// the visually-trailing message; earlier chunks are left intact).
|
|
1808
|
+
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
1809
|
+
const anchorMsgId = sentIds[chunks.length - 1]
|
|
1810
|
+
if (typeof anchorMsgId === 'number') {
|
|
1811
|
+
// #1760 primary fix — clear any stale prior-turn ticker BEFORE
|
|
1812
|
+
// re-anchoring. The canonical teardown wires (turn_end,
|
|
1813
|
+
// subagent_handback, inbound) can be missed (e.g. SDK turn_end
|
|
1814
|
+
// event dropped, as in the #1760 live evidence). Tearing down on
|
|
1815
|
+
// every reply-finalize is idempotent and resilient: it's a no-op
|
|
1816
|
+
// when nothing is active, and drops a stale ambient before the
|
|
1817
|
+
// new turn captures its anchor.
|
|
1818
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
1819
|
+
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
1820
|
+
messageId: anchorMsgId,
|
|
1821
|
+
text: chunks[chunks.length - 1],
|
|
1822
|
+
literalText,
|
|
1823
|
+
})
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// #1677 silent-reply auto-edit — anchor capture for the FIRST
|
|
1828
|
+
// silent reply of a turn (or the silent reply that replaced the
|
|
1829
|
+
// anchor on overflow). Only captures for the single-chunk,
|
|
1830
|
+
// silent, no-files, no-buttons happy path; the edit-anchor path
|
|
1831
|
+
// earlier in this function handles SUBSEQUENT silent replies by
|
|
1832
|
+
// editing. The next silent reply this turn will see the captured
|
|
1833
|
+
// anchor and edit it in place.
|
|
1834
|
+
if (
|
|
1835
|
+
chunks.length === 1
|
|
1836
|
+
&& disableNotification
|
|
1837
|
+
&& files.length === 0
|
|
1838
|
+
&& replyMarkup == null
|
|
1839
|
+
&& sentIds.length === 1
|
|
1840
|
+
) {
|
|
1841
|
+
const turn = getCurrentTurn()
|
|
1842
|
+
if (turn != null) {
|
|
1843
|
+
turn.silentAnchorMessageId = sentIds[0]!
|
|
1844
|
+
turn.silentAnchorText = effectiveText
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// #3033 layer-2 (pre-send validation): probe each photo-extension file
|
|
1849
|
+
// BEFORE it hits the wire and pre-route any that Telegram's photo path
|
|
1850
|
+
// would reject (extreme aspect ratio, width+height over cap, >10MB) as
|
|
1851
|
+
// documents. One bad photo fails a WHOLE sendMediaGroup album with an
|
|
1852
|
+
// opaque 400 — catching it here keeps the good files deliverable and
|
|
1853
|
+
// sends the offender as a document on the first attempt. Probe
|
|
1854
|
+
// failures keep the photo route; the reactive #3022 fallback backstops.
|
|
1855
|
+
const photoPrecheck = new Map<string, ReturnType<typeof classifyPhotoFile>>()
|
|
1856
|
+
// #3038 polish: files that ultimately went out as documents despite a
|
|
1857
|
+
// photo extension (precheck reroute or reactive fallback). Feeds two
|
|
1858
|
+
// honesty surfaces: the reply tool result suffix (so the agent doesn't
|
|
1859
|
+
// claim an inline image rendered) and attachment_kinds history (record
|
|
1860
|
+
// what was actually sent).
|
|
1861
|
+
const documentReroutes: Array<{ path: string; reason: string }> = []
|
|
1862
|
+
const sentAsDocument = new Set<string>()
|
|
1863
|
+
for (const f of files) {
|
|
1864
|
+
if (!PHOTO_EXTS.has(extname(f).toLowerCase())) continue
|
|
1865
|
+
const cls = classifyPhotoFile(f)
|
|
1866
|
+
photoPrecheck.set(f, cls)
|
|
1867
|
+
if (cls.route === 'document') {
|
|
1868
|
+
documentReroutes.push({ path: f, reason: cls.reason })
|
|
1869
|
+
sentAsDocument.add(f)
|
|
1870
|
+
process.stderr.write(
|
|
1871
|
+
`telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})\n`,
|
|
1872
|
+
)
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
const sendableAsPhoto = (f: string) =>
|
|
1876
|
+
PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== 'document'
|
|
1877
|
+
|
|
1878
|
+
// #273: when files is 2-10 photos, batch them into a single
|
|
1879
|
+
// sendMediaGroup album rather than N separate sendPhoto calls. The
|
|
1880
|
+
// user's device fires one notification for the album instead of N
|
|
1881
|
+
// (notification-budget protection per the issue's JTBD note). Falls
|
|
1882
|
+
// back to the per-file path for any non-all-photo set.
|
|
1883
|
+
//
|
|
1884
|
+
// #3038 known tradeoff: ONE precheck-rerouted photo in the set drops
|
|
1885
|
+
// the WHOLE album to per-file sends — the user gets N notifications
|
|
1886
|
+
// instead of 1. Deliberate: sendMediaGroup can't mix photo and
|
|
1887
|
+
// document media, and a partial album plus a stray document is more
|
|
1888
|
+
// confusing than N files. Revisit only if mixed albums become common.
|
|
1889
|
+
const allPhotos = files.length >= 2 && files.length <= 10
|
|
1890
|
+
&& files.every(sendableAsPhoto)
|
|
1891
|
+
// #1075: thread-id-bearing file sends. Mirror the chunk-loop's
|
|
1892
|
+
// THREAD_NOT_FOUND fallback (deleted topic → drop the thread and
|
|
1893
|
+
// resend on the main chat) so an attachment-bearing reply doesn't
|
|
1894
|
+
// crash when the user deletes the topic mid-flight.
|
|
1895
|
+
const replyParams =
|
|
1896
|
+
reply_to != null && replyMode !== 'off' ? { reply_parameters: { message_id: reply_to } } : {}
|
|
1897
|
+
// Send one file as a document, routed through the same thread-fallback
|
|
1898
|
+
// policy as the photo path. `InputFile` streams are single-use, so a
|
|
1899
|
+
// document retry after a failed sendPhoto must build a FRESH InputFile
|
|
1900
|
+
// from the path. Returns the sent message (with its echoed thread id).
|
|
1901
|
+
const sendAsDocument = (f: string) =>
|
|
1902
|
+
retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
1903
|
+
robustApiCall,
|
|
1904
|
+
(tid) => {
|
|
1905
|
+
const baseOpts = {
|
|
1906
|
+
...replyParams,
|
|
1907
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
1908
|
+
}
|
|
1909
|
+
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware document fallback
|
|
1910
|
+
return lockedBot.api.sendDocument(chat_id, new InputFile(f), baseOpts)
|
|
1911
|
+
},
|
|
1912
|
+
{ threadId, chat_id, verb: 'sendDocument' },
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
if (allPhotos) {
|
|
1916
|
+
const media = files.map((f) => ({
|
|
1917
|
+
type: 'photo' as const,
|
|
1918
|
+
media: new InputFile(f),
|
|
1919
|
+
}))
|
|
1920
|
+
let sent: Array<{ message_id: number; message_thread_id?: number }>
|
|
1921
|
+
try {
|
|
1922
|
+
sent = await retryWithThreadFallback(
|
|
1923
|
+
robustApiCall,
|
|
1924
|
+
(tid) => {
|
|
1925
|
+
const baseOpts = {
|
|
1926
|
+
...replyParams,
|
|
1927
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
1928
|
+
}
|
|
1929
|
+
return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts)
|
|
1930
|
+
},
|
|
1931
|
+
{ threadId, chat_id, verb: 'sendMediaGroup' },
|
|
1932
|
+
)
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
// Graceful fallback: one bad photo in the album (e.g. a tall phone
|
|
1935
|
+
// screenshot → PHOTO_INVALID_DIMENSIONS) fails the WHOLE media group.
|
|
1936
|
+
// Rather than surface an error, re-send each file individually as a
|
|
1937
|
+
// document so the user still receives all of them. See #klanker
|
|
1938
|
+
// 2026-07-10 incident + isPhotoDimensionRejectError.
|
|
1939
|
+
if (!isPhotoDimensionRejectError(err)) throw err
|
|
1940
|
+
process.stderr.write(
|
|
1941
|
+
`telegram gateway: sendMediaGroup rejected the photo album ` +
|
|
1942
|
+
`(${err instanceof Error ? err.message : String(err)}); ` +
|
|
1943
|
+
`falling back to per-file sendDocument\n`,
|
|
1944
|
+
)
|
|
1945
|
+
sent = []
|
|
1946
|
+
const albumReason = 'Telegram rejected the photo album; whole album re-sent as documents'
|
|
1947
|
+
for (const f of files) {
|
|
1948
|
+
sent.push(await sendAsDocument(f))
|
|
1949
|
+
sentAsDocument.add(f)
|
|
1950
|
+
documentReroutes.push({ path: f, reason: albumReason })
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
if (threadId != null) {
|
|
1954
|
+
// If the fallback dropped the thread id, propagate that decision
|
|
1955
|
+
// to subsequent calls in this reply (no further retries needed).
|
|
1956
|
+
// We can't observe which branch resolved cleanly, so peek at the
|
|
1957
|
+
// first sent message: Telegram echoes message_thread_id only when
|
|
1958
|
+
// present in the request. Absent → fallback fired.
|
|
1959
|
+
const first = sent[0] as { message_thread_id?: number } | undefined
|
|
1960
|
+
if (first && first.message_thread_id == null) threadId = undefined
|
|
1961
|
+
}
|
|
1962
|
+
for (const m of sent) sentIds.push(m.message_id)
|
|
1963
|
+
} else {
|
|
1964
|
+
for (const f of files) {
|
|
1965
|
+
const input = new InputFile(f)
|
|
1966
|
+
// Photo-ext files that failed the pre-send probe route straight to
|
|
1967
|
+
// sendDocument (see photoPrecheck above) instead of bouncing a 400.
|
|
1968
|
+
const isPhoto = sendableAsPhoto(f)
|
|
1969
|
+
let sent: { message_id: number; message_thread_id?: number }
|
|
1970
|
+
try {
|
|
1971
|
+
sent = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
1972
|
+
robustApiCall,
|
|
1973
|
+
(tid) => {
|
|
1974
|
+
const baseOpts = {
|
|
1975
|
+
...replyParams,
|
|
1976
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
1977
|
+
}
|
|
1978
|
+
return isPhoto
|
|
1979
|
+
? lockedBot.api.sendPhoto(chat_id, input, baseOpts)
|
|
1980
|
+
: lockedBot.api.sendDocument(chat_id, input, baseOpts)
|
|
1981
|
+
},
|
|
1982
|
+
{ threadId, chat_id, verb: isPhoto ? 'sendPhoto' : 'sendDocument' },
|
|
1983
|
+
)
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
// Graceful fallback: an image Telegram won't accept as a photo
|
|
1986
|
+
// (dimensions out of range / too large — a tall phone screenshot
|
|
1987
|
+
// is the canonical trigger, PHOTO_INVALID_DIMENSIONS) is re-sent
|
|
1988
|
+
// as a document so the user still receives the file instead of
|
|
1989
|
+
// getting nothing. Non-photo-dimension errors propagate as before.
|
|
1990
|
+
if (!(isPhoto && isPhotoDimensionRejectError(err))) throw err
|
|
1991
|
+
process.stderr.write(
|
|
1992
|
+
`telegram gateway: sendPhoto rejected ${f} ` +
|
|
1993
|
+
`(${err instanceof Error ? err.message : String(err)}); ` +
|
|
1994
|
+
`falling back to sendDocument\n`,
|
|
1995
|
+
)
|
|
1996
|
+
sent = await sendAsDocument(f)
|
|
1997
|
+
sentAsDocument.add(f)
|
|
1998
|
+
documentReroutes.push({ path: f, reason: 'Telegram rejected it as a photo; re-sent as document' })
|
|
1999
|
+
}
|
|
2000
|
+
// Mirror the threadId-clear above so the *next* file in the
|
|
2001
|
+
// loop skips the doomed thread without paying for another
|
|
2002
|
+
// round trip + retry.
|
|
2003
|
+
if (threadId != null && sent.message_thread_id == null) {
|
|
2004
|
+
threadId = undefined
|
|
2005
|
+
}
|
|
2006
|
+
sentIds.push(sent.message_id)
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// #3038: surface photo→document reroutes in the tool result — the
|
|
2011
|
+
// agent otherwise only "sees" success and may tell the user an inline
|
|
2012
|
+
// image rendered when it went out as a file attachment.
|
|
2013
|
+
const result = (sentIds.length === 1
|
|
2014
|
+
? `sent (id: ${sentIds[0]})`
|
|
2015
|
+
: `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`)
|
|
2016
|
+
+ rerouteResultSuffix(documentReroutes)
|
|
2017
|
+
|
|
2018
|
+
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
2019
|
+
try {
|
|
2020
|
+
const fileCount = files.length
|
|
2021
|
+
const textCount = sentIds.length - fileCount
|
|
2022
|
+
const texts: string[] = []
|
|
2023
|
+
const attachKinds: (string | null)[] = []
|
|
2024
|
+
for (let i = 0; i < textCount; i++) { texts.push(chunks[i] ?? ''); attachKinds.push(null) }
|
|
2025
|
+
for (let i = 0; i < fileCount; i++) {
|
|
2026
|
+
const f = files[i] ?? ''
|
|
2027
|
+
const ext = extname(f).toLowerCase()
|
|
2028
|
+
// #3038: record what was ACTUALLY sent — a photo-extension file
|
|
2029
|
+
// rerouted to sendDocument (precheck or reactive fallback) is a
|
|
2030
|
+
// 'document' in history, not a 'photo' from its raw extension.
|
|
2031
|
+
const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? 'photo' : 'document'
|
|
2032
|
+
texts.push(`(${kind}: ${f})`)
|
|
2033
|
+
attachKinds.push(kind)
|
|
2034
|
+
}
|
|
2035
|
+
recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds })
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
process.stderr.write(`telegram gateway: history recordOutbound (reply) failed: ${err}\n`)
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// Issue #137: signal to the progress driver that an actual outbound
|
|
2042
|
+
// landed, so a turn-end with replyToolCalled=true but zero deliveries
|
|
2043
|
+
// can render the "⚠️ Reply attempted but not delivered" variant.
|
|
2044
|
+
if (sentIds.length > 0) {
|
|
2045
|
+
try {
|
|
2046
|
+
progressDriver?.recordOutboundDelivered(
|
|
2047
|
+
chat_id,
|
|
2048
|
+
threadId != null ? String(threadId) : undefined,
|
|
2049
|
+
)
|
|
2050
|
+
} catch { /* best-effort signal */ }
|
|
2051
|
+
// #203: fresh sendMessage from reply tool is a user-visible signal.
|
|
2052
|
+
signalTracker.noteSignal(statusKey(chat_id, threadId), Date.now())
|
|
2053
|
+
// #1713: the reply tool is a NON-EVENT for the status reaction
|
|
2054
|
+
// WHEN IT'S AN INTERIM ACK. The reaction reflects current turn
|
|
2055
|
+
// activity, not delivery state — interim acks must not collapse
|
|
2056
|
+
// the working-state ladder to 👍.
|
|
2057
|
+
//
|
|
2058
|
+
// #1728 carve-out (2026-05-24): when this reply IS the final
|
|
2059
|
+
// answer (`isFinalAnswerReply` returns true — same classifier
|
|
2060
|
+
// #1664 uses for silent-end re-prompt gating), it IS effectively
|
|
2061
|
+
// turn-end and we MUST finalize here. Rationale: Claude Code's
|
|
2062
|
+
// `turn_duration` system event is unreliable for the trivial-
|
|
2063
|
+
// prompt happy path (driver sends "what's 2+2", model replies
|
|
2064
|
+
// "4", no `turn_duration` ever lands in the JSONL session tail).
|
|
2065
|
+
// Pre-#1718 this wedge was masked by the legacy
|
|
2066
|
+
// `endStatusReaction` shim running unconditionally on every
|
|
2067
|
+
// reply (outcome='done'); #1718 removed that call site
|
|
2068
|
+
// intending `turn_end` to be the sole terminal trigger. The
|
|
2069
|
+
// contract was right in spirit but `turn_end` doesn't fire 100%
|
|
2070
|
+
// of the time, so the buffer gate (activeTurnStartedAt) stays
|
|
2071
|
+
// set forever and every subsequent inbound gets `held mid-turn`
|
|
2072
|
+
// and never delivered. v0.13.27 shipped + reverted on this
|
|
2073
|
+
// failure mode (#1728).
|
|
2074
|
+
//
|
|
2075
|
+
// Net contract:
|
|
2076
|
+
// - interim ack reply (isFinalAnswerReply === false)
|
|
2077
|
+
// → non-event, no reaction finalize, buffer gate stays
|
|
2078
|
+
// - final-answer reply (isFinalAnswerReply === true)
|
|
2079
|
+
// → finalize reaction (debounced 👍) + release buffer
|
|
2080
|
+
// gate via purgeReactionTracking (called inside
|
|
2081
|
+
// finalizeStatusReaction). currentTurn stays alive so
|
|
2082
|
+
// a subsequent `turn_end` still cleans up its share
|
|
2083
|
+
// idempotently.
|
|
2084
|
+
//
|
|
2085
|
+
// #1664 — `turn.finalAnswerDelivered = true` keeps the silent-
|
|
2086
|
+
// end re-prompt from spuriously firing on a delivered final.
|
|
2087
|
+
if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification: modelDisableNotification })) {
|
|
2088
|
+
turn.finalAnswerDelivered = true
|
|
2089
|
+
// Feed-reopen refinement: track whether this final was substantive
|
|
2090
|
+
// (≥200 chars or stream-done — not a short pinging ack) so post-answer
|
|
2091
|
+
// housekeeping tool work does NOT re-open the feed / trip silent-end.
|
|
2092
|
+
turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification: modelDisableNotification })
|
|
2093
|
+
// Sticky ordering latch (lever 1): set once a SUBSTANTIVE final lands;
|
|
2094
|
+
// never cleared by reopen. The card OPEN gate keys on this, not the
|
|
2095
|
+
// mutable finalAnswerDelivered above (which reopen toggles).
|
|
2096
|
+
if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
|
|
2097
|
+
if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
|
|
2098
|
+
// #1728: release the buffer gate + emit terminal 👍. Mid-turn
|
|
2099
|
+
// acks bypass this branch and remain non-events for the
|
|
2100
|
+
// reaction (preserves #1713). The full turn-state teardown
|
|
2101
|
+
// (nulling `currentTurn`, the per-turn cleanup) still runs in
|
|
2102
|
+
// the `turn_end` handler when it lands; this only fires the
|
|
2103
|
+
// observable side effects that #1718 deferred unconditionally.
|
|
2104
|
+
finalizeStatusReaction(chat_id, threadId, 'done')
|
|
2105
|
+
// PR2: close this origin's obligation on a SUBSTANTIVE final answer
|
|
2106
|
+
// (after finalize so the reaction guard test's anchor window is stable).
|
|
2107
|
+
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
|
|
2108
|
+
}
|
|
2109
|
+
// v0.13.30 follow-up — release the buffer gate on EVERY reply
|
|
2110
|
+
// finalize, not just on `isFinalAnswerReply`. The narrow
|
|
2111
|
+
// `finalizeStatusReaction` path above misses short replies that
|
|
2112
|
+
// set `disable_notification: true` (the model mis-classifies a
|
|
2113
|
+
// genuine answer as an interim ack — e.g. "4" for "what's
|
|
2114
|
+
// 2+2"). Pre-fix the gate stayed set forever and every later
|
|
2115
|
+
// inbound logged `held mid-turn ... will flush on turn-
|
|
2116
|
+
// complete` — but turn-complete never came because Claude
|
|
2117
|
+
// Code's `turn_duration` system event doesn't reliably land
|
|
2118
|
+
// for trivial-prompt turns. v0.13.30 UAT showed the regression
|
|
2119
|
+
// (msg 1873 reply at 13:02:46, msg 1874 held at 13:03:04, gate
|
|
2120
|
+
// never released).
|
|
2121
|
+
//
|
|
2122
|
+
// The reaction controller stays alive (preserves #1713
|
|
2123
|
+
// bidirectional ladder + the steer-vs-queue logic at
|
|
2124
|
+
// gateway.ts:8322 which reads `activeStatusReactions`). Only
|
|
2125
|
+
// the buffer gate flips.
|
|
2126
|
+
//
|
|
2127
|
+
// Component 1: pass the turn so the serialize gate sees this turn's
|
|
2128
|
+
// `finalAnswerDelivered` (set just above for final-answer replies).
|
|
2129
|
+
// An interim ack leaves it false → the cross-topic buffer does NOT
|
|
2130
|
+
// drain yet; the real answer's reply releases it.
|
|
2131
|
+
releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined)
|
|
2132
|
+
// Component 5: the final answer landed — reap the queued-status
|
|
2133
|
+
// placeholder for THIS turn's topic. Key on the turn's own session
|
|
2134
|
+
// thread (where the placeholder was posted / promoted), not the
|
|
2135
|
+
// answer's possibly-overridden threadId.
|
|
2136
|
+
if (turn?.finalAnswerDelivered === true) {
|
|
2137
|
+
reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId)
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
|
|
2142
|
+
// #546 dedup record: future reply / stream_reply / turn-flush
|
|
2143
|
+
// calls with this same content within DEFAULT_DEDUP_TTL_MS will
|
|
2144
|
+
// be suppressed.
|
|
2145
|
+
if (sentIds.length > 0) {
|
|
2146
|
+
outboundDedup.record(chat_id, threadId, text, Date.now(), getCurrentTurn()?.registryKey ?? null)
|
|
2147
|
+
}
|
|
2148
|
+
return { content: [{ type: 'text', text: result }] }
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Gateway dependencies for {@link deliverCapturedProse}. */
|
|
2152
|
+
export interface DeliverCapturedProseDeps {
|
|
2153
|
+
/** THE one live dedup instance — shared with {@link sendReply} and the
|
|
2154
|
+
* stream-render surface (Amendment 1/9). */
|
|
2155
|
+
outboundDedup: OutboundDedupCache
|
|
2156
|
+
bot: Bot<Context>
|
|
2157
|
+
robustApiCall<T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T>
|
|
2158
|
+
redactOutboundText(text: string, site: string): string
|
|
2159
|
+
recordOutbound(rec: {
|
|
2160
|
+
chat_id: string
|
|
2161
|
+
thread_id: number | null
|
|
2162
|
+
message_ids: number[]
|
|
2163
|
+
texts: string[]
|
|
2164
|
+
}): void
|
|
2165
|
+
HISTORY_ENABLED: boolean
|
|
2166
|
+
OBLIGATION_LEDGER_ENABLED: boolean
|
|
2167
|
+
obligationLedger: { close(originTurnId: string): void }
|
|
2168
|
+
clearSilentEndState(key: string): void
|
|
2169
|
+
recordUndeliveredTurnEnd(
|
|
2170
|
+
args: { chatId: string; threadId: number | null; turnKey: string },
|
|
2171
|
+
deps?: SilentEndDeps,
|
|
2172
|
+
): { exhausted: boolean }
|
|
2173
|
+
hasOutboundDeliveredSince(
|
|
2174
|
+
chatId: string,
|
|
2175
|
+
sinceMs: number,
|
|
2176
|
+
threadId: number | null | undefined,
|
|
2177
|
+
minCount?: number,
|
|
2178
|
+
): boolean
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* Silent-end recovery delivery (#3228) — `deliverCapturedProse`'s body,
|
|
2183
|
+
* verbatim. A separate inline send path from the reply orchestration (it
|
|
2184
|
+
* delivers the model's terminal prose recovered from the transcript scan when
|
|
2185
|
+
* the turn ended silently); routed into this module so it shares the ONE
|
|
2186
|
+
* injected `outboundDedup` instance and the golden harness (Amendment 2).
|
|
2187
|
+
*/
|
|
2188
|
+
export async function deliverCapturedProse(
|
|
2189
|
+
deps: DeliverCapturedProseDeps,
|
|
2190
|
+
args: {
|
|
2191
|
+
chatId: string
|
|
2192
|
+
threadId: number | undefined
|
|
2193
|
+
statusKeyStr: string
|
|
2194
|
+
registryKey: string | null
|
|
2195
|
+
originTurnId: string
|
|
2196
|
+
text: string
|
|
2197
|
+
/** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
|
|
2198
|
+
turnDurationMs?: number
|
|
2199
|
+
},
|
|
2200
|
+
): Promise<void> {
|
|
2201
|
+
const {
|
|
2202
|
+
outboundDedup, bot, robustApiCall, redactOutboundText, recordOutbound,
|
|
2203
|
+
HISTORY_ENABLED, OBLIGATION_LEDGER_ENABLED, obligationLedger,
|
|
2204
|
+
clearSilentEndState, recordUndeliveredTurnEnd, hasOutboundDeliveredSince,
|
|
2205
|
+
} = deps
|
|
2206
|
+
const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
|
|
2207
|
+
const now = Date.now()
|
|
2208
|
+
// #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
|
|
2209
|
+
// failed) all funnel through the pure `settleCapturedProseDelivery` core so
|
|
2210
|
+
// the failure posture is deterministic and unit-tested. `outcome` is set on
|
|
2211
|
+
// each branch and applied ONCE at the bottom.
|
|
2212
|
+
let outcome: CapturedProseSendOutcome
|
|
2213
|
+
const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
|
|
2214
|
+
if (already == null) {
|
|
2215
|
+
let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
2216
|
+
out = redactOutboundText(out, 'captured_prose')
|
|
2217
|
+
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
2218
|
+
const sentIds: number[] = []
|
|
2219
|
+
try {
|
|
2220
|
+
let liveThreadId: number | undefined = threadId
|
|
2221
|
+
for (const c of chunks) {
|
|
2222
|
+
const sent = await retryWithThreadFallback(
|
|
2223
|
+
robustApiCall,
|
|
2224
|
+
(tid) => {
|
|
2225
|
+
// Built as a variable (not an inline literal) so excess-property
|
|
2226
|
+
// checks don't reject `link_preview_options` on sendRichMessage's
|
|
2227
|
+
// narrow Other<> type — mirrors the turn-flush send site.
|
|
2228
|
+
const opts = {
|
|
2229
|
+
link_preview_options: { is_disabled: true },
|
|
2230
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
2231
|
+
}
|
|
2232
|
+
return bot.api.sendRichMessage(chatId, richMessage(c), opts)
|
|
2233
|
+
},
|
|
2234
|
+
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose.sendMessage' },
|
|
2235
|
+
)
|
|
2236
|
+
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
2237
|
+
liveThreadId = undefined
|
|
2238
|
+
}
|
|
2239
|
+
sentIds.push(sent.message_id)
|
|
2240
|
+
}
|
|
2241
|
+
if (HISTORY_ENABLED && sentIds.length > 0) {
|
|
2242
|
+
try {
|
|
2243
|
+
recordOutbound({
|
|
2244
|
+
chat_id: chatId,
|
|
2245
|
+
thread_id: threadId ?? null,
|
|
2246
|
+
message_ids: sentIds,
|
|
2247
|
+
texts: chunks,
|
|
2248
|
+
})
|
|
2249
|
+
} catch {}
|
|
2250
|
+
}
|
|
2251
|
+
// Record what we just sent so a late reply / stream_reply retry with the
|
|
2252
|
+
// same content is deduped at its send site (the #546 dedup cache).
|
|
2253
|
+
outboundDedup.record(chatId, threadId, text, now, registryKey)
|
|
2254
|
+
process.stderr.write(
|
|
2255
|
+
`telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
|
|
2256
|
+
`transcript scan (chat=${chatId} origin=${originTurnId})\n`,
|
|
2257
|
+
)
|
|
2258
|
+
outcome = 'sent'
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
// #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
|
|
2261
|
+
// The `failed` outcome routes to `settleCapturedProseDelivery`'s recovery
|
|
2262
|
+
// path (recordUndelivered), NOT the close/clear path. This is load-bearing:
|
|
2263
|
+
// the shared turn-end teardown (endCurrentTurnAtomic →
|
|
2264
|
+
// decideObligationTurnEnd) already closes the obligation whenever
|
|
2265
|
+
// `replyCalled === true` — exactly the interim-ack case that routes here —
|
|
2266
|
+
// so "leaving the obligation open" is not a real net. Arming the Stop-hook
|
|
2267
|
+
// re-prompt makes the failed send recoverable instead of silently lost.
|
|
2268
|
+
process.stderr.write(
|
|
2269
|
+
`telegram gateway: captured-prose delivery failed: ${(err as Error).message} — ` +
|
|
2270
|
+
`arming the silent-end re-prompt net (recordUndeliveredTurnEnd) so the ` +
|
|
2271
|
+
`answer is recoverable (chat=${chatId} origin=${originTurnId})\n`,
|
|
2272
|
+
)
|
|
2273
|
+
outcome = 'failed'
|
|
2274
|
+
}
|
|
2275
|
+
} else {
|
|
2276
|
+
process.stderr.write(
|
|
2277
|
+
`telegram gateway: captured-prose delivery skipped — this answer already went out ` +
|
|
2278
|
+
`(dedup age=${already.ageMs}ms chat=${chatId} origin=${originTurnId}); settling bookkeeping\n`,
|
|
2279
|
+
)
|
|
2280
|
+
outcome = 'skipped-dedup'
|
|
2281
|
+
}
|
|
2282
|
+
// Apply the settlement bookkeeping through the pure core (#3228 Finding 1):
|
|
2283
|
+
// sent / skipped-dedup → close obligation + clear state (answer is with the
|
|
2284
|
+
// user, so represent + exhausted fallback must not fire)
|
|
2285
|
+
// failed → arm the Stop-hook re-prompt net (recordUndelivered),
|
|
2286
|
+
// do NOT close/clear.
|
|
2287
|
+
const settlement = settleCapturedProseDelivery(outcome, {
|
|
2288
|
+
closeObligation: () => {
|
|
2289
|
+
if (OBLIGATION_LEDGER_ENABLED) {
|
|
2290
|
+
try { obligationLedger.close(originTurnId) } catch {}
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
clearState: () => clearSilentEndState(statusKeyStr),
|
|
2294
|
+
recordUndelivered: () => {
|
|
2295
|
+
try {
|
|
2296
|
+
const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
|
|
2297
|
+
? {
|
|
2298
|
+
hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
|
|
2299
|
+
hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
|
|
2300
|
+
}
|
|
2301
|
+
: undefined
|
|
2302
|
+
return recordUndeliveredTurnEnd(
|
|
2303
|
+
{ chatId, threadId: threadId ?? null, turnKey: statusKeyStr },
|
|
2304
|
+
silentEndDeps,
|
|
2305
|
+
)
|
|
2306
|
+
} catch (netErr) {
|
|
2307
|
+
process.stderr.write(
|
|
2308
|
+
`telegram gateway: captured-prose recovery-net arm failed: ${
|
|
2309
|
+
(netErr as Error).message
|
|
2310
|
+
} (chat=${chatId} origin=${originTurnId})\n`,
|
|
2311
|
+
)
|
|
2312
|
+
// Could not even record the undelivered turn — do NOT claim exhaustion
|
|
2313
|
+
// (firing a fallback we can't justify). Fail safe: leave recovery to
|
|
2314
|
+
// the obligation represent / next Stop hook.
|
|
2315
|
+
return { exhausted: false }
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2320
|
+
// Exhaustion-boundary gap (#3228): the send FAILED on the attempt where the
|
|
2321
|
+
// Stop-hook re-prompt budget was already spent, so recordUndeliveredTurnEnd
|
|
2322
|
+
// cleared the state and the re-prompt can no longer recover the answer — and
|
|
2323
|
+
// the obligation was already closed by the interim-ack teardown. Without this
|
|
2324
|
+
// the user gets NEITHER the answer NOR the apology. Deliver a user-facing
|
|
2325
|
+
// fallback, preferring the REAL answer as plain text (a non-rich send often
|
|
2326
|
+
// survives the markdown/parse error the rich send threw on) before the
|
|
2327
|
+
// generic apology.
|
|
2328
|
+
if (outcome === 'failed' && settlement.exhausted) {
|
|
2329
|
+
process.stderr.write(
|
|
2330
|
+
`telegram gateway: WARN captured-prose exhausted-boundary fallback — rich send ` +
|
|
2331
|
+
`failed with the re-prompt budget already spent; attempting a plain-text ` +
|
|
2332
|
+
`delivery of the recovered answer before the generic apology ` +
|
|
2333
|
+
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
2334
|
+
)
|
|
2335
|
+
const plain = redactOutboundText(text, 'captured_prose')
|
|
2336
|
+
const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
|
|
2337
|
+
try {
|
|
2338
|
+
let liveThreadId: number | undefined = threadId
|
|
2339
|
+
for (const c of plainChunks) {
|
|
2340
|
+
// Plain sendMessage — NO parse_mode / rich rendering — so a markdown
|
|
2341
|
+
// construct that made sendRichMessage 400 is sent verbatim instead.
|
|
2342
|
+
const sent = await retryWithThreadFallback(
|
|
2343
|
+
robustApiCall,
|
|
2344
|
+
(tid) =>
|
|
2345
|
+
bot.api.sendMessage(
|
|
2346
|
+
chatId,
|
|
2347
|
+
c,
|
|
2348
|
+
tid != null ? { message_thread_id: tid } : {},
|
|
2349
|
+
),
|
|
2350
|
+
{ threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
|
|
2351
|
+
)
|
|
2352
|
+
if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
|
|
2353
|
+
liveThreadId = undefined
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
// The real answer reached the user via plain text — record it so a late
|
|
2357
|
+
// reply-tool retry with the same content is deduped at its send site.
|
|
2358
|
+
outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
|
|
2359
|
+
process.stderr.write(
|
|
2360
|
+
`telegram gateway: captured-prose recovered via plain-text fallback ` +
|
|
2361
|
+
`(chat=${chatId} origin=${originTurnId})\n`,
|
|
2362
|
+
)
|
|
2363
|
+
} catch (plainErr) {
|
|
2364
|
+
// Plain text ALSO failed — post the generic apology so the turn is never
|
|
2365
|
+
// silent (mirrors the non-captured exhausted path, gateway turn_end #1161).
|
|
2366
|
+
process.stderr.write(
|
|
2367
|
+
`telegram gateway: captured-prose plain-text fallback ALSO failed: ${
|
|
2368
|
+
(plainErr as Error).message
|
|
2369
|
+
} — posting the generic silent-end apology (chat=${chatId} origin=${originTurnId})\n`,
|
|
2370
|
+
)
|
|
2371
|
+
void retryWithThreadFallback(
|
|
2372
|
+
robustApiCall,
|
|
2373
|
+
(tid) =>
|
|
2374
|
+
bot.api.sendMessage(
|
|
2375
|
+
chatId,
|
|
2376
|
+
silentEndFallbackText(turnDurationMs),
|
|
2377
|
+
tid != null ? { message_thread_id: tid } : {},
|
|
2378
|
+
),
|
|
2379
|
+
{ threadId, chat_id: chatId, verb: 'captured-prose-apology-fallback.sendMessage' },
|
|
2380
|
+
).catch((err) => {
|
|
2381
|
+
process.stderr.write(
|
|
2382
|
+
`telegram gateway: captured-prose apology fallback send failed: ${
|
|
2383
|
+
err instanceof Error ? err.message : String(err)
|
|
2384
|
+
}\n`,
|
|
2385
|
+
)
|
|
2386
|
+
})
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|