switchroom 0.18.8 → 0.18.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
|
@@ -7,17 +7,41 @@
|
|
|
7
7
|
* (b) execute the returned effects against real I/O. This module owns
|
|
8
8
|
* step (b) for the cutover.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* - drainBuffer →
|
|
12
|
-
* - redeliverPersistedPermVerdicts →
|
|
10
|
+
* Effects wired to real executors (all effect kinds now handled):
|
|
11
|
+
* - drainBuffer → redeliverBufferedInbound
|
|
12
|
+
* - redeliverPersistedPermVerdicts → pendingPermissionBuffer.drain → send
|
|
13
|
+
* - deliverToBridge → client.send / ipcServer.sendToAgent
|
|
14
|
+
* - bufferInbound → pendingInboundBuffer.push
|
|
15
|
+
* - persistInbound → inboundSpool.put
|
|
16
|
+
* - deliverPermVerdict → client.send / ipcServer.sendToAgent
|
|
17
|
+
* - persistPermVerdict → pendingPermissionBuffer.push
|
|
18
|
+
* - setTurnStarted / clearTurnStarted → onSetTurnStarted / onClearTurnStarted
|
|
19
|
+
* - noteOutbound → onNoteOutbound
|
|
20
|
+
* - firePoke → onFirePoke
|
|
13
21
|
* - logTrace → executed
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
23
|
+
* Live call sites (post PR3c flip, #2794):
|
|
24
|
+
* - bridgeUp (gateway onClientRegistered): drainBuffer /
|
|
25
|
+
* redeliverPersistedPermVerdicts / logTrace.
|
|
26
|
+
* - handleInbound DEFERRED_INBOUND_EMIT: the inbound-routing effects
|
|
27
|
+
* (deliverToBridge / bufferInbound / persistInbound / setTurnStarted)
|
|
28
|
+
* are AUTHORITATIVE when the cutover is on — the imperative twin in
|
|
29
|
+
* gateway.ts runs only under the kill switch or the documented
|
|
30
|
+
* carve-outs (interrupt-while-in-turn, bridge_dead). The twin is
|
|
31
|
+
* deleted in PR4 after the 48h bake (see the "Hard removal plan"
|
|
32
|
+
* checklist in the RFC: PR3b step 2 / PR4 remain open).
|
|
33
|
+
* - turnEnd lifecycle effects remain shadow-only: the imperative
|
|
34
|
+
* turn-end sites (purgeReactionTracking / releaseTurnBufferGate)
|
|
35
|
+
* already execute the effect-equivalents with gates the machine does
|
|
36
|
+
* not model yet (serialize-until-replied drain); PR3b step 2 tracks
|
|
37
|
+
* that flip.
|
|
38
|
+
*
|
|
39
|
+
* The gateway-internal turn/poke effects (setTurnStarted,
|
|
40
|
+
* clearTurnStarted, noteOutbound, firePoke) map to gateway-scope state
|
|
41
|
+
* (`claudeBusyKeys`, the silence-poke ladder) that this decoupled module
|
|
42
|
+
* cannot reach directly, so they are dispatched through OPTIONAL
|
|
43
|
+
* callbacks on the ctx. When a callback is absent the effect logs an
|
|
44
|
+
* `unwired` trace rather than silently no-opping — the trace is the gate.
|
|
21
45
|
*
|
|
22
46
|
* Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
|
|
23
47
|
* dispatcher execution and the gateway falls back to imperative-only.
|
|
@@ -26,10 +50,11 @@
|
|
|
26
50
|
|
|
27
51
|
import type {
|
|
28
52
|
Effect,
|
|
53
|
+
ChatKey,
|
|
29
54
|
InboundMessage as MachineInboundMessage,
|
|
30
55
|
} from './inbound-delivery-machine.js'
|
|
31
56
|
import type { IpcServer, IpcClient } from './ipc-server.js'
|
|
32
|
-
import type { InboundMessage } from './ipc-protocol.js'
|
|
57
|
+
import type { InboundMessage, PermissionEvent } from './ipc-protocol.js'
|
|
33
58
|
import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
|
|
34
59
|
import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
|
|
35
60
|
import type { InboundSpool } from './inbound-spool.js'
|
|
@@ -54,6 +79,26 @@ export interface DispatchCtx {
|
|
|
54
79
|
* (clerk lost-message incident, 2026-06-03.)
|
|
55
80
|
*/
|
|
56
81
|
readonly onUserInboundDelivered?: (merged: InboundMessage) => void
|
|
82
|
+
// ── Gateway-internal turn/poke effect callbacks ──────────────────
|
|
83
|
+
// These effects map to gateway-scope state (`claudeBusyKeys`, the
|
|
84
|
+
// silence-poke ladder) that this decoupled module cannot reach. The
|
|
85
|
+
// gateway supplies them when it flips inbound routing through the
|
|
86
|
+
// machine (PR3c). Absent → the effect logs an `unwired` trace.
|
|
87
|
+
readonly onSetTurnStarted?: (key: ChatKey, at: number) => void
|
|
88
|
+
/**
|
|
89
|
+
* Optional: observe the outcome of a `deliverToBridge` effect. The
|
|
90
|
+
* imperative twin branches on `sendToAgent`'s boolean (delivered →
|
|
91
|
+
* steer-ack + busy-mark + delivery-confirm tracking; miss → release
|
|
92
|
+
* reservation + durable-buffer + restart notice). The machine cannot
|
|
93
|
+
* model a send that fails while the bridge is nominally alive, so the
|
|
94
|
+
* PR3c live call site needs the result to run the same post-send
|
|
95
|
+
* branches the twin runs. Called once per deliverToBridge effect,
|
|
96
|
+
* after the send attempt, before `onUserInboundDelivered` enrolment.
|
|
97
|
+
*/
|
|
98
|
+
readonly onDeliverResult?: (key: ChatKey, ok: boolean, msg: InboundMessage) => void
|
|
99
|
+
readonly onClearTurnStarted?: (key: ChatKey) => void
|
|
100
|
+
readonly onNoteOutbound?: (key: ChatKey, at: number) => void
|
|
101
|
+
readonly onFirePoke?: (key: ChatKey, level: 'soft' | 'firm' | 'fallback') => void
|
|
57
102
|
}
|
|
58
103
|
|
|
59
104
|
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
|
|
@@ -168,20 +213,133 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
|
|
|
168
213
|
return
|
|
169
214
|
}
|
|
170
215
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
216
|
+
case 'deliverToBridge': {
|
|
217
|
+
// Route a single inbound to the live bridge. The machine's
|
|
218
|
+
// InboundMessage.payload carries the real ipc-protocol
|
|
219
|
+
// InboundMessage; the machine treats it as opaque.
|
|
220
|
+
const msg = effect.msg.payload as InboundMessage
|
|
221
|
+
let ok = false
|
|
222
|
+
try {
|
|
223
|
+
if (ctx.client) {
|
|
224
|
+
ctx.client.send(msg)
|
|
225
|
+
ok = true
|
|
226
|
+
} else {
|
|
227
|
+
ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
log(
|
|
231
|
+
`telegram gateway: dispatch deliverToBridge send threw agent=${ctx.selfAgent} ` +
|
|
232
|
+
`key=${effect.key}: ${(err as Error).message}\n`,
|
|
233
|
+
)
|
|
234
|
+
ok = false
|
|
235
|
+
}
|
|
236
|
+
if (ctx.onDeliverResult) {
|
|
237
|
+
try {
|
|
238
|
+
ctx.onDeliverResult(effect.key, ok, msg)
|
|
239
|
+
} catch {
|
|
240
|
+
/* observer is best-effort; never breaks delivery */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (ok && ctx.onUserInboundDelivered) {
|
|
244
|
+
// Enrol in the deliver-until-acked sweep — a socket write is not
|
|
245
|
+
// proof claude consumed it (see onUserInboundDelivered doc).
|
|
246
|
+
// Pass UNCONDITIONALLY, mirroring the drainBuffer path above:
|
|
247
|
+
// the callback self-gates via shouldTrackDelivery (inbound-
|
|
248
|
+
// delivery-confirm.ts), which REJECTS sourced messages and
|
|
249
|
+
// tracks real user inbounds (meta.source undefined) — exactly
|
|
250
|
+
// the messages the sweep protects. Do NOT pre-filter on
|
|
251
|
+
// meta.source here; an inverted guard would skip user inbounds
|
|
252
|
+
// and enrol only messages the inner gate discards.
|
|
253
|
+
try {
|
|
254
|
+
ctx.onUserInboundDelivered(msg)
|
|
255
|
+
} catch {
|
|
256
|
+
/* enrolment is best-effort; never breaks delivery */
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
log(`gw-trace dispatch deliverToBridge key=${effect.key} ok=${ok}\n`)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'bufferInbound': {
|
|
264
|
+
const msg = effect.msg.payload as InboundMessage
|
|
265
|
+
const buffered = ctx.pendingInboundBuffer.push(ctx.selfAgent, msg)
|
|
266
|
+
log(`gw-trace dispatch bufferInbound key=${effect.key} buffered=${buffered}\n`)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case 'persistInbound': {
|
|
271
|
+
// Durable spool. `pendingInboundBuffer.push` already spools when a
|
|
272
|
+
// spool is attached to the buffer, so a persistInbound paired with
|
|
273
|
+
// a bufferInbound is idempotent (put() is idempotent by spoolId).
|
|
274
|
+
const msg = effect.msg.payload as InboundMessage
|
|
275
|
+
const put = ctx.inboundSpool ? ctx.inboundSpool.put(ctx.selfAgent, msg) : false
|
|
276
|
+
log(`gw-trace dispatch persistInbound key=${effect.key} put=${put}\n`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
case 'deliverPermVerdict': {
|
|
281
|
+
// Branch order differs from the imperative twin
|
|
282
|
+
// (gateway.ts dispatchPermissionVerdict), which always goes via
|
|
283
|
+
// `ipcServer.sendToAgent` — it runs outside any bridgeUp context,
|
|
284
|
+
// so it has no just-registered client handle. Here `ctx.client`
|
|
285
|
+
// is preferred WHEN PRESENT for the same reason as drainBuffer /
|
|
286
|
+
// redeliverPersistedPermVerdicts above: on the bridgeUp path the
|
|
287
|
+
// registry lookup may not yet observe the just-registered client,
|
|
288
|
+
// and a direct send to the connecting socket is the reliable
|
|
289
|
+
// route. PR3c's live call site for machine-driven permVerdict
|
|
290
|
+
// events (non-bridgeUp) must construct the ctx WITHOUT `client`,
|
|
291
|
+
// which makes this branch behave exactly like the twin
|
|
292
|
+
// (sendToAgent). Note the twin additionally buffers on a failed
|
|
293
|
+
// send; the machine models that as a separate persistPermVerdict
|
|
294
|
+
// effect (bridge_dead state), so no fallback push here.
|
|
295
|
+
const ev = effect.verdict.payload as PermissionEvent
|
|
296
|
+
let ok = false
|
|
297
|
+
try {
|
|
298
|
+
if (ctx.client) {
|
|
299
|
+
ctx.client.send(ev as never)
|
|
300
|
+
ok = true
|
|
301
|
+
} else {
|
|
302
|
+
ok = ctx.ipcServer.sendToAgent(ctx.selfAgent, ev as never)
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
log(
|
|
306
|
+
`telegram gateway: dispatch deliverPermVerdict send threw agent=${ctx.selfAgent} ` +
|
|
307
|
+
`request=${effect.verdict.requestId}: ${(err as Error).message}\n`,
|
|
308
|
+
)
|
|
309
|
+
ok = false
|
|
310
|
+
}
|
|
311
|
+
log(`gw-trace dispatch deliverPermVerdict request=${effect.verdict.requestId} ok=${ok}\n`)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
183
315
|
case 'persistPermVerdict': {
|
|
184
|
-
|
|
316
|
+
const ev = effect.verdict.payload as PermissionEvent
|
|
317
|
+
const pushed = ctx.pendingPermissionBuffer.push(ctx.selfAgent, ev)
|
|
318
|
+
log(`gw-trace dispatch persistPermVerdict request=${effect.verdict.requestId} pushed=${pushed}\n`)
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'setTurnStarted': {
|
|
323
|
+
if (ctx.onSetTurnStarted) ctx.onSetTurnStarted(effect.key, effect.at)
|
|
324
|
+
else log(`gw-trace dispatch unwired effect=setTurnStarted key=${effect.key}\n`)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'clearTurnStarted': {
|
|
329
|
+
if (ctx.onClearTurnStarted) ctx.onClearTurnStarted(effect.key)
|
|
330
|
+
else log(`gw-trace dispatch unwired effect=clearTurnStarted key=${effect.key}\n`)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'noteOutbound': {
|
|
335
|
+
if (ctx.onNoteOutbound) ctx.onNoteOutbound(effect.key, effect.at)
|
|
336
|
+
else log(`gw-trace dispatch unwired effect=noteOutbound key=${effect.key}\n`)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'firePoke': {
|
|
341
|
+
if (ctx.onFirePoke) ctx.onFirePoke(effect.key, effect.level)
|
|
342
|
+
else log(`gw-trace dispatch unwired effect=firePoke key=${effect.key} level=${effect.level}\n`)
|
|
185
343
|
return
|
|
186
344
|
}
|
|
187
345
|
}
|
|
@@ -261,6 +261,14 @@ export function transition(state: State, event: Event): Transition {
|
|
|
261
261
|
const alive = state.global.kind !== 'bridge_dead'
|
|
262
262
|
|
|
263
263
|
if (!alive) {
|
|
264
|
+
// ANCHORED STRING — `inbound_bridge_dead_buffer` is load-bearing:
|
|
265
|
+
// gateway.ts's PR3c bridge-dead carve-out keys off this exact stage
|
|
266
|
+
// string to route the inbound to the imperative twin (whose
|
|
267
|
+
// shouldTrackDelivery drop semantics + restart notice are the
|
|
268
|
+
// contract the machine doesn't model). Renaming it silently reroutes
|
|
269
|
+
// that carve-out; a pin test in inbound-delivery-cutover-flip.test.ts
|
|
270
|
+
// breaks on rename. Both the string key-off and this anchor are
|
|
271
|
+
// deleted in PR4 with the twin.
|
|
264
272
|
return {
|
|
265
273
|
state,
|
|
266
274
|
effects: [
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Outbound send-path — deterministic text pipeline + chunking core (#2996).
|
|
2
|
+
//
|
|
3
|
+
// Phase 2 of the gateway.ts decomposition (issue #2996, plan §3B). This
|
|
4
|
+
// module owns the pure, side-effect-free heart of the reply/stream/turn-flush
|
|
5
|
+
// outbound pipeline: the normalize → redact → punctuation/bold → voice-scrub
|
|
6
|
+
// text transform, the effective-text spacing decision, the length-limit
|
|
7
|
+
// chunking, and the oversize-chunk re-split. These are exactly the transforms
|
|
8
|
+
// where the recent oversize / redaction / voice-scrub regressions landed, and
|
|
9
|
+
// extracting them here makes them unit-testable in isolation (see
|
|
10
|
+
// outbound-send-path.test.ts golden snapshots).
|
|
11
|
+
//
|
|
12
|
+
// Deliberately NOT moved here (they stay in gateway.ts, delegating to this
|
|
13
|
+
// module): the side-effecting send orchestration — currentTurn pinning,
|
|
14
|
+
// emission-authority / over-ping decisions, activity-card finalize, voice
|
|
15
|
+
// synthesis + sends, typing loops, history recording, the shared
|
|
16
|
+
// `outboundDedup` singleton check/record, and the raw bot.api send loop with
|
|
17
|
+
// its partial-failure contract. Those read gateway module state and are not
|
|
18
|
+
// byte-identically relocatable without an invocable-executeReply harness that
|
|
19
|
+
// this pure-core extraction is itself the prerequisite for.
|
|
20
|
+
//
|
|
21
|
+
// currentTurn coupling (#1067/#1664): this module NEVER reads the currentTurn
|
|
22
|
+
// global. Every function here is pure over its arguments — turn identity is
|
|
23
|
+
// pinned by the caller and never observed here.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
repairEscapedWhitespace,
|
|
27
|
+
normalizeParagraphBreaks,
|
|
28
|
+
normalizePunctuation,
|
|
29
|
+
stripExcessBold,
|
|
30
|
+
addParagraphSpacers,
|
|
31
|
+
splitMarkdownChunks,
|
|
32
|
+
hardSliceToCap,
|
|
33
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
34
|
+
} from '../format.js'
|
|
35
|
+
import { scrubVoice } from '../text-voice-scrub.js'
|
|
36
|
+
import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
|
|
37
|
+
|
|
38
|
+
/** The redactor the caller injects. In gateway this is `redactOutboundText`,
|
|
39
|
+
* which wraps `redact()` and logs (never the secret value) when a mask fires.
|
|
40
|
+
* Injected rather than imported so the redaction structural-wiring test
|
|
41
|
+
* (`gateway-outbound-redact.test.ts`) keeps pinning the helper in gateway.ts,
|
|
42
|
+
* and so this module stays free of the stderr side effect. */
|
|
43
|
+
export type RedactFn = (text: string, site: string) => string
|
|
44
|
+
|
|
45
|
+
export interface NormalizeOutboundResult {
|
|
46
|
+
/** The fully-normalized text. This is the value used downstream as the
|
|
47
|
+
* dedup key, the Telegraph threshold input, and (after effective-text
|
|
48
|
+
* spacing) the chunk source. Callers apply it exactly as the pre-#2996
|
|
49
|
+
* inline pipeline did. */
|
|
50
|
+
text: string
|
|
51
|
+
/** Number of voice-scrub replacements applied (dashes → commas/periods,
|
|
52
|
+
* opener strips). >0 means the voice scrub mutated the text; the caller
|
|
53
|
+
* emits the `voice_scrub_applied` runtime metric on that condition. */
|
|
54
|
+
voiceReplaced: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stage 1 — the deterministic outbound text transform, byte-identical to the
|
|
59
|
+
* inline pipeline at the entry of executeReply (and mirrored on the
|
|
60
|
+
* answer-stream + turn-flush paths):
|
|
61
|
+
*
|
|
62
|
+
* 1. repairEscapedWhitespace — undo LLM JSON-escape bungles
|
|
63
|
+
* 2. normalizeParagraphBreaks — promote lone prose breaks to GFM hard breaks
|
|
64
|
+
* 3. redact (injected) — outbound secret scrub (#2044), BEFORE the
|
|
65
|
+
* punctuation/bold normalizers so a secret with
|
|
66
|
+
* an em-dash or `**` is matched literally
|
|
67
|
+
* 4. stripExcessBold∘normalizePunctuation — fleet-consistent formatting
|
|
68
|
+
* 5. scrubVoice — em/en dash → comma/period (#1683)
|
|
69
|
+
*
|
|
70
|
+
* The order is load-bearing and MUST NOT change (each step's comment in the
|
|
71
|
+
* former inline site documents why). Pure over its arguments.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeOutboundBody(
|
|
74
|
+
rawText: string,
|
|
75
|
+
site: string,
|
|
76
|
+
redact: RedactFn,
|
|
77
|
+
): NormalizeOutboundResult {
|
|
78
|
+
let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
|
|
79
|
+
text = redact(text, site)
|
|
80
|
+
text = stripExcessBold(normalizePunctuation(text))
|
|
81
|
+
let voiceReplaced = 0
|
|
82
|
+
const scrub = scrubVoice(text)
|
|
83
|
+
if (scrub.replaced > 0) {
|
|
84
|
+
text = scrub.scrubbed
|
|
85
|
+
voiceReplaced = scrub.replaced
|
|
86
|
+
}
|
|
87
|
+
return { text, voiceReplaced }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Effective-text spacing (#2669 rich-message regression fix). The rich GFM
|
|
92
|
+
* renderer collapses `\n\n` gaps tight, so prose paragraphs render jammed.
|
|
93
|
+
* Inject a visible blank-line spacer on the rich path only; the literal
|
|
94
|
+
* (`format:'text'`) path stays byte-exact. Pure.
|
|
95
|
+
*/
|
|
96
|
+
export function computeEffectiveText(text: string, literalText: boolean): string {
|
|
97
|
+
return literalText ? text : addParagraphSpacers(text)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Length-limit chunking. The literal path uses the newline/length `chunk()`
|
|
102
|
+
* splitter; the rich path uses `splitMarkdownChunks` (markdown-boundary-aware).
|
|
103
|
+
* Pure. `chunk` is passed in so the splitter (moved here as `chunkText`) and
|
|
104
|
+
* this decision stay colocated without a circular gateway import.
|
|
105
|
+
*/
|
|
106
|
+
export function computeReplyChunks(args: {
|
|
107
|
+
effectiveText: string
|
|
108
|
+
literalText: boolean
|
|
109
|
+
limit: number
|
|
110
|
+
chunkMode: 'length' | 'newline'
|
|
111
|
+
}): string[] {
|
|
112
|
+
const { effectiveText, literalText, limit, chunkMode } = args
|
|
113
|
+
return literalText
|
|
114
|
+
? chunkText(effectiveText, limit, chunkMode)
|
|
115
|
+
: splitMarkdownChunks(effectiveText, limit)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Oversize-chunk re-split (length-error recovery). A single pre-computed chunk
|
|
120
|
+
* can still exceed the wire cap when `splitMarkdownChunks` hit an indivisible
|
|
121
|
+
* region and emitted it whole (a giant fenced block, a no-boundary blob).
|
|
122
|
+
* Re-split at the hard `RICH_MESSAGE_MAX_CHARS` cap; for a truly indivisible
|
|
123
|
+
* block, fall back to a hard character cut so each delivered piece stays under
|
|
124
|
+
* the wire cap. Byte-identical to the inline `sendChunkResplit` piece
|
|
125
|
+
* computation. Pure.
|
|
126
|
+
*/
|
|
127
|
+
export function resplitOversizeChunk(piece: string): string[] {
|
|
128
|
+
const subPieces = splitMarkdownChunks(piece, RICH_MESSAGE_MAX_CHARS)
|
|
129
|
+
return subPieces.length > 1 ? subPieces : hardSliceToCap(piece, RICH_MESSAGE_MAX_CHARS)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Length/newline text splitter (relocated verbatim from gateway.ts). Splits
|
|
134
|
+
* `text` into <= `limit`-char pieces. In `newline` mode it prefers a paragraph
|
|
135
|
+
* break, then a line break, then a space past the halfway point; `length` mode
|
|
136
|
+
* cuts hard at the limit. Pure.
|
|
137
|
+
*/
|
|
138
|
+
export function chunkText(text: string, limit: number, mode: 'length' | 'newline'): string[] {
|
|
139
|
+
if (text.length <= limit) return [text]
|
|
140
|
+
const out: string[] = []
|
|
141
|
+
let rest = text
|
|
142
|
+
while (rest.length > limit) {
|
|
143
|
+
let cut = limit
|
|
144
|
+
if (mode === 'newline') {
|
|
145
|
+
const para = rest.lastIndexOf('\n\n', limit)
|
|
146
|
+
const line = rest.lastIndexOf('\n', limit)
|
|
147
|
+
const space = rest.lastIndexOf(' ', limit)
|
|
148
|
+
cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
|
|
149
|
+
}
|
|
150
|
+
out.push(rest.slice(0, cut))
|
|
151
|
+
rest = rest.slice(cut).replace(/^\n+/, '')
|
|
152
|
+
}
|
|
153
|
+
if (rest) out.push(rest)
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Send orchestration (#2996 step 1) ────────────────────────────────────
|
|
158
|
+
//
|
|
159
|
+
// The reply chunk-send loop, relocated VERBATIM from executeReply so it can be
|
|
160
|
+
// driven from a unit test against a fake bot API (see
|
|
161
|
+
// outbound-send-path.test.ts). This is the highest-bug-density mechanic of the
|
|
162
|
+
// send path — the recent oversize / wire-cap / parse-reject / THREAD_NOT_FOUND
|
|
163
|
+
// fallback fixes all landed HERE — and it was previously reachable only through
|
|
164
|
+
// the non-importable gateway monolith (gateway.ts runs boot logic + Bun.listen
|
|
165
|
+
// at import, so `executeReply` cannot be invoked from vitest/bun in-place).
|
|
166
|
+
//
|
|
167
|
+
// The module stays bot-agnostic: every Telegram send is an INJECTED function
|
|
168
|
+
// dep, so the raw `bot.api.*` calls (and their retry wrapping + allow-raw-bot-api
|
|
169
|
+
// markers) remain in gateway.ts, and a test passes fakes. The caller keeps
|
|
170
|
+
// building the per-chunk send/edit option objects (so the option shape stays
|
|
171
|
+
// byte-identical to the inline site and the deps surface stays small — 8), pins
|
|
172
|
+
// the turn, owns dedup/voice/history, and threads the shared `sentIds` array by
|
|
173
|
+
// reference. currentTurn is NEVER read here (#1067/#1664).
|
|
174
|
+
|
|
175
|
+
/** Injected Telegram send surface + logging for {@link sendReplyChunks}. In
|
|
176
|
+
* gateway these are thin adapters over `lockedBot.api.*` (retry-wrapped where
|
|
177
|
+
* the inline site wrapped them); in tests they are fakes recording call shape. */
|
|
178
|
+
export interface ReplyChunkSendDeps {
|
|
179
|
+
/** robustApiCall-wrapped rich send. Adapter: `sendRichMessage(richMessage(s))`.
|
|
180
|
+
* `threadId` (the live value) is passed into the robustApiCall meta so a
|
|
181
|
+
* thread-not-found 400 is converted to THREAD_NOT_FOUND, exactly as inline. */
|
|
182
|
+
sendRich: (opts: Record<string, unknown>, richBody: unknown, threadId: number | undefined) => Promise<{ message_id: number }>
|
|
183
|
+
/** robustApiCall-wrapped literal send. Adapter: `sendMessage(chunk)`. */
|
|
184
|
+
sendLiteral: (opts: Record<string, unknown>, text: string, threadId: number | undefined) => Promise<{ message_id: number }>
|
|
185
|
+
/** UNwrapped literal send (last-resort fallbacks that must NOT re-enter the
|
|
186
|
+
* retry policy that just rejected the payload). Adapter: raw `sendMessage`. */
|
|
187
|
+
sendLiteralRaw: (opts: Record<string, unknown>, text: string) => Promise<{ message_id: number }>
|
|
188
|
+
/** UNwrapped rich send (length-error re-split last resort). Adapter: raw
|
|
189
|
+
* `sendRichMessage(richMessage(piece))`. */
|
|
190
|
+
sendRichRaw: (opts: Record<string, unknown>, richBody: unknown) => Promise<{ message_id: number }>
|
|
191
|
+
/** robustApiCall-wrapped preview edit-in-place. */
|
|
192
|
+
editPreview: (messageId: number, body: unknown, opts: Record<string, unknown>, threadId: number | undefined) => Promise<unknown>
|
|
193
|
+
/** rich-markdown wrapper (`richMessage`). Applied to a chunk/piece string. */
|
|
194
|
+
richMessage: (s: string) => unknown
|
|
195
|
+
/** outbound logger (`logOutbound`). */
|
|
196
|
+
logOutbound: (path: 'reply', chatId: string, messageId: number, chars: number, extra?: string) => void
|
|
197
|
+
/** delete a stale preview message (`deleteStalePreview`). */
|
|
198
|
+
deleteStalePreview: (id: number) => Promise<void>
|
|
199
|
+
/** stderr sink (`process.stderr.write`). */
|
|
200
|
+
stderr: (s: string) => void
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Mutable send state + per-chunk option builders for {@link sendReplyChunks}.
|
|
204
|
+
* The caller owns option shape (byte-identical to the inline site). */
|
|
205
|
+
export interface ReplyChunkSendState {
|
|
206
|
+
chatId: string
|
|
207
|
+
chunks: string[]
|
|
208
|
+
literalText: boolean
|
|
209
|
+
/** voice-only mode with a full synthesis skips the text body entirely. */
|
|
210
|
+
suppressText: boolean
|
|
211
|
+
/** current thread id; re-split/fallbacks may drop it (THREAD_NOT_FOUND). */
|
|
212
|
+
threadId: number | undefined
|
|
213
|
+
/** a stale draft-stream preview to edit-in-place on the first chunk, or null. */
|
|
214
|
+
previewMessageId: number | null
|
|
215
|
+
/** shared results array — appended in place (voice/file sends push too). */
|
|
216
|
+
sentIds: number[]
|
|
217
|
+
/** build the send-options object for chunk `i` (last-chunk flag + live thread). */
|
|
218
|
+
buildSendOpts: (i: number, isLastChunk: boolean, threadId: number | undefined) => Record<string, unknown>
|
|
219
|
+
/** build the preview edit-in-place options for the first chunk. */
|
|
220
|
+
buildPreviewEditOpts: (isLastChunk: boolean) => Record<string, unknown>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface ReplyChunkSendResult {
|
|
224
|
+
/** thread id after any THREAD_NOT_FOUND fallback (used by later file sends). */
|
|
225
|
+
threadId: number | undefined
|
|
226
|
+
/** preview id after consumption (null once edited/deleted). */
|
|
227
|
+
previewMessageId: number | null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Send the pre-computed reply chunks. Relocated verbatim from executeReply's
|
|
232
|
+
* chunk loop. Appends message ids to `state.sentIds` in order. On an
|
|
233
|
+
* unrecoverable send error it throws the raw error — the caller wraps it into
|
|
234
|
+
* the `reply failed after N of M chunk(s) sent` partial-failure contract and
|
|
235
|
+
* runs the typing-loop `finally`, exactly as before.
|
|
236
|
+
*/
|
|
237
|
+
export async function sendReplyChunks(
|
|
238
|
+
deps: ReplyChunkSendDeps,
|
|
239
|
+
state: ReplyChunkSendState,
|
|
240
|
+
): Promise<ReplyChunkSendResult> {
|
|
241
|
+
const { chatId, chunks, literalText, suppressText, sentIds } = state
|
|
242
|
+
let threadId = state.threadId
|
|
243
|
+
let previewMessageId = state.previewMessageId
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
246
|
+
// PR-C2: voice-only mode with a successful synthesis suppresses the
|
|
247
|
+
// text body — the spoken voice note IS the reply. Bail before the
|
|
248
|
+
// first chunk send (sentIds stays empty for text); the voice send
|
|
249
|
+
// below lands the answer. Any other mode (voice+text, or voice-only
|
|
250
|
+
// that fell back) sends the text chunks as normal.
|
|
251
|
+
if (suppressText) break
|
|
252
|
+
const isLastChunk = i === chunks.length - 1
|
|
253
|
+
const sendOpts = state.buildSendOpts(i, isLastChunk, threadId)
|
|
254
|
+
|
|
255
|
+
if (i === 0 && previewMessageId != null) {
|
|
256
|
+
const editOpts = state.buildPreviewEditOpts(isLastChunk)
|
|
257
|
+
try {
|
|
258
|
+
await deps.editPreview(previewMessageId!, literalText ? chunks[i] : deps.richMessage(chunks[i]), editOpts, threadId)
|
|
259
|
+
sentIds.push(previewMessageId!)
|
|
260
|
+
previewMessageId = null
|
|
261
|
+
continue
|
|
262
|
+
} catch (err) {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
264
|
+
if (/not modified/i.test(msg)) {
|
|
265
|
+
sentIds.push(previewMessageId!)
|
|
266
|
+
previewMessageId = null
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
deps.stderr(`telegram gateway: preview edit-in-place failed (${msg}), sending fresh\n`)
|
|
270
|
+
await deps.deleteStalePreview(previewMessageId!)
|
|
271
|
+
previewMessageId = null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Last-resort: resend this chunk as plain text (no rich wrapper, so
|
|
276
|
+
// the markdown parser never runs). Keeps thread / reply / markup
|
|
277
|
+
// params; only the formatting is sacrificed. Used when Telegram
|
|
278
|
+
// rejects our markdown — better an unformatted answer than a
|
|
279
|
+
// vanished one. The raw markdown source is itself readable prose, so
|
|
280
|
+
// we send it verbatim rather than strip anything.
|
|
281
|
+
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
282
|
+
const plain =
|
|
283
|
+
chunks[i].length > 0
|
|
284
|
+
? chunks[i]
|
|
285
|
+
: '⚠️ (a fragment could not be rendered for Telegram)'
|
|
286
|
+
const sent = await deps.sendLiteralRaw(opts, plain)
|
|
287
|
+
sentIds.push(sent.message_id)
|
|
288
|
+
deps.logOutbound('reply', chatId, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
289
|
+
deps.stderr(
|
|
290
|
+
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Literal `format:'text'` sends bypass the rich parser entirely
|
|
295
|
+
// (plain sendMessage, no markdown). The default path ships rich
|
|
296
|
+
// markdown via sendRichMessage. Both resolve to a Message with a
|
|
297
|
+
// message_id, which is all the caller reads.
|
|
298
|
+
//
|
|
299
|
+
// `wrapped` selects the retry-wrapped adapter (first attempt) vs the
|
|
300
|
+
// UNwrapped adapter (THREAD_NOT_FOUND retry). The inline site wrapped only
|
|
301
|
+
// the first attempt in robustApiCall; the retry called the raw send
|
|
302
|
+
// deliberately, so re-attempting after a dropped thread never re-enters the
|
|
303
|
+
// retry policy. Preserving that split keeps behavior byte-identical.
|
|
304
|
+
const sendChunk = (opts: Record<string, unknown>, wrapped: boolean): Promise<{ message_id: number }> => {
|
|
305
|
+
if (literalText) {
|
|
306
|
+
return wrapped ? deps.sendLiteral(opts, chunks[i], threadId) : deps.sendLiteralRaw(opts, chunks[i])
|
|
307
|
+
}
|
|
308
|
+
// sendRichMessage does NOT accept link_preview_options (rich messages
|
|
309
|
+
// control previews via entity detection) — drop it for the rich path.
|
|
310
|
+
const richOpts = { ...opts }
|
|
311
|
+
delete (richOpts as { link_preview_options?: unknown }).link_preview_options
|
|
312
|
+
const richBody = deps.richMessage(chunks[i])
|
|
313
|
+
return wrapped ? deps.sendRich(richOpts, richBody, threadId) : deps.sendRichRaw(richOpts, richBody)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Length-error recovery: a single pre-computed chunk can still exceed the
|
|
317
|
+
// wire cap when splitMarkdownChunks hit an indivisible region and emitted
|
|
318
|
+
// it whole (a giant fenced block, a no-boundary blob). Telegram answers
|
|
319
|
+
// with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
|
|
320
|
+
// at a harder boundary and send each piece, rather than misclassifying it
|
|
321
|
+
// as a parse-reject (which would resend the same oversized payload as
|
|
322
|
+
// plain text) or surfacing the raw 400.
|
|
323
|
+
const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
|
|
324
|
+
// Re-split at the same cap; for a truly indivisible block this still
|
|
325
|
+
// yields one oversized piece, but a hard character-cut on the rendered
|
|
326
|
+
// markdown at least keeps each delivered piece under the wire cap.
|
|
327
|
+
const pieces = resplitOversizeChunk(chunks[i])
|
|
328
|
+
for (let p = 0; p < pieces.length; p++) {
|
|
329
|
+
let sent: { message_id: number }
|
|
330
|
+
if (literalText) {
|
|
331
|
+
sent = await deps.sendLiteralRaw(opts, pieces[p])
|
|
332
|
+
} else {
|
|
333
|
+
const ro = { ...opts }
|
|
334
|
+
delete (ro as { link_preview_options?: unknown }).link_preview_options
|
|
335
|
+
sent = await deps.sendRichRaw(ro, deps.richMessage(pieces[p]))
|
|
336
|
+
}
|
|
337
|
+
sentIds.push(sent.message_id)
|
|
338
|
+
deps.logOutbound('reply', chatId, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
|
|
339
|
+
}
|
|
340
|
+
deps.stderr(
|
|
341
|
+
`telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const sent = await sendChunk(sendOpts, true)
|
|
347
|
+
sentIds.push(sent.message_id)
|
|
348
|
+
deps.logOutbound('reply', chatId, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
|
|
351
|
+
threadId = undefined
|
|
352
|
+
const retryOpts = { ...sendOpts }
|
|
353
|
+
delete (retryOpts as Record<string, unknown>).message_thread_id
|
|
354
|
+
try {
|
|
355
|
+
const sent = await sendChunk(retryOpts, false)
|
|
356
|
+
sentIds.push(sent.message_id)
|
|
357
|
+
} catch (retryErr) {
|
|
358
|
+
// Thread dropped, AND another failure: length → re-split,
|
|
359
|
+
// parse-reject → plain text, else propagate.
|
|
360
|
+
if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
|
|
361
|
+
else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
362
|
+
else throw retryErr
|
|
363
|
+
}
|
|
364
|
+
} else if (isMessageTooLongError(err)) {
|
|
365
|
+
await sendChunkResplit(sendOpts)
|
|
366
|
+
} else if (isHtmlParseRejectError(err)) {
|
|
367
|
+
await sendChunkPlainText(sendOpts)
|
|
368
|
+
} else {
|
|
369
|
+
throw err
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { threadId, previewMessageId }
|
|
375
|
+
}
|