switchroom 0.20.8 → 0.20.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/dist/agent-scheduler/index.js +16 -13
- package/dist/auth-broker/index.js +51 -29
- package/dist/cli/autoaccept-poll.js +5 -3
- package/dist/cli/drive-write-pretool.mjs +5 -3
- package/dist/cli/ms-365-write-pretool.mjs +5 -3
- package/dist/cli/notion-write-pretool.mjs +6 -6
- package/dist/cli/switchroom.js +12 -10
- package/dist/host-control/main.js +7 -7
- package/dist/vault/approvals/kernel-server.js +6 -6
- package/dist/vault/broker/server.js +6 -6
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +12 -13
- package/telegram-plugin/ask-user.ts +6 -7
- package/telegram-plugin/dist/gateway/gateway.js +183 -66
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +4 -2
- package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
- package/telegram-plugin/gateway/gateway.ts +8 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
- package/telegram-plugin/render/emphasis-guard.ts +92 -12
- package/telegram-plugin/render/line-start-guard.ts +27 -2
- package/telegram-plugin/sticker-aliases.ts +12 -14
- package/telegram-plugin/tests/ask-user.test.ts +15 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
- package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
- package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
- package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
- package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
- package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
- package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
- package/telegram-plugin/throttle-tier.ts +59 -0
|
@@ -183,6 +183,17 @@ export interface SubagentHandbackDecisionInput {
|
|
|
183
183
|
* the built inbound's `meta.subagent_jsonl_id`. See
|
|
184
184
|
* `SubagentHandbackContext.jsonlAgentId` for the dedup rationale. */
|
|
185
185
|
jsonlAgentId?: string
|
|
186
|
+
/**
|
|
187
|
+
* Double-wake dedup (v0.20.8 candidate): true iff the session tail already
|
|
188
|
+
* observed a TERMINAL CLI `<task-notification>` for EXACTLY this sub-agent's
|
|
189
|
+
* task id within `TASK_NOTIFICATION_DEDUP_TTL_MS` — i.e. the CLI itself has
|
|
190
|
+
* already woken (or queued the wake of) the parent with this completion, so
|
|
191
|
+
* a second gateway-synthesized wake would duplicate it. The caller resolves
|
|
192
|
+
* this from `CliTaskNotificationLedger.seenRecently(agentId, now)`
|
|
193
|
+
* (subagent-handback-marker.ts). FAIL-OPEN: omitted/false → deliver; only a
|
|
194
|
+
* confirmed exact-id in-window hit suppresses.
|
|
195
|
+
*/
|
|
196
|
+
cliTaskNotificationSeen?: boolean
|
|
186
197
|
/** Deterministic clock for tests. */
|
|
187
198
|
nowMs?: number
|
|
188
199
|
}
|
|
@@ -192,6 +203,7 @@ export type SubagentHandbackSkipReason =
|
|
|
192
203
|
| 'env-disabled'
|
|
193
204
|
| 'outcome-not-terminal'
|
|
194
205
|
| 'foreground'
|
|
206
|
+
| 'cli-task-notification'
|
|
195
207
|
| 'no-chat'
|
|
196
208
|
|
|
197
209
|
export type SubagentHandbackDecision =
|
|
@@ -208,7 +220,12 @@ export type SubagentHandbackDecision =
|
|
|
208
220
|
* stale historical-at-boot row, not a fresh completion.
|
|
209
221
|
* 3. foreground — a foreground sub-agent already handed its result
|
|
210
222
|
* back as the Task tool result in the parent's own turn.
|
|
211
|
-
* 4.
|
|
223
|
+
* 4. cli-task-notification — the claude CLI's OWN `<task-notification>`
|
|
224
|
+
* for exactly this task id was already observed in-window, so the CLI
|
|
225
|
+
* has already woken the parent with this completion; a second
|
|
226
|
+
* gateway-synthesized wake would double it (the double-message bug).
|
|
227
|
+
* Fail-open: only a confirmed exact-id hit skips.
|
|
228
|
+
* 5. no-chat — neither the fleet entry nor the owner chat resolved,
|
|
212
229
|
* so there is nowhere to deliver.
|
|
213
230
|
*/
|
|
214
231
|
export function decideSubagentHandback(
|
|
@@ -223,6 +240,9 @@ export function decideSubagentHandback(
|
|
|
223
240
|
if (!input.isBackground) {
|
|
224
241
|
return { deliver: false, reason: 'foreground' }
|
|
225
242
|
}
|
|
243
|
+
if (input.cliTaskNotificationSeen === true) {
|
|
244
|
+
return { deliver: false, reason: 'cli-task-notification' }
|
|
245
|
+
}
|
|
226
246
|
const chatId = input.fleetChatId || input.ownerChatId
|
|
227
247
|
if (!chatId) {
|
|
228
248
|
return { deliver: false, reason: 'no-chat' }
|
|
@@ -220,6 +220,100 @@ export function stampsHandbackMarker(source: string | null | undefined): boolean
|
|
|
220
220
|
*/
|
|
221
221
|
export const HANDBACK_RECENCY_WINDOW_MS = 60_000
|
|
222
222
|
|
|
223
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
224
|
+
// CLI task-notification dedup ledger (double-wake fix, v0.20.8 candidate)
|
|
225
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
226
|
+
//
|
|
227
|
+
// One background sub-agent completion used to produce TWO independent wakes of
|
|
228
|
+
// the parent session:
|
|
229
|
+
// 1. The claude CLI's OWN `<task-notification>` — the CLI proactively
|
|
230
|
+
// enqueues it into the parent session when a backgrounded task/agent
|
|
231
|
+
// completes (projected as a `task_notification` SessionEvent at the
|
|
232
|
+
// queue-operation enqueue line, session-tail.ts). The parent wakes,
|
|
233
|
+
// sees the notification + summary, and typically reports to the user.
|
|
234
|
+
// 2. The gateway-synthesized `subagent_handback` inbound (subagent-watcher
|
|
235
|
+
// `onFinish` → pendingInboundBuffer.push) — switchroom's deliberate
|
|
236
|
+
// beat-4 wake, added because older CLIs surfaced a background result
|
|
237
|
+
// only on the parent's NEXT user turn.
|
|
238
|
+
// Nothing linked them, so a single completion fanned out to two turns and two
|
|
239
|
+
// user-visible replies. The CLI-native wake cannot be suppressed (it is the
|
|
240
|
+
// CLI's internal queue); the ONE lever switchroom holds is the handback
|
|
241
|
+
// enqueue. This ledger records every terminal `<task-notification>` the
|
|
242
|
+
// session tail observes, keyed by its `<task-id>` — which is EXACTLY the
|
|
243
|
+
// sub-agent watcher's `agentId` (both are the `agent-<id>.jsonl` stem;
|
|
244
|
+
// verified against live transcripts: `<task-id>a204deeaedb27b580</task-id>`
|
|
245
|
+
// ↔ `subagents/agent-a204deeaedb27b580.jsonl`). `decideSubagentHandback`
|
|
246
|
+
// then skips the redundant handback ONLY on an exact-id hit inside a short
|
|
247
|
+
// TTL.
|
|
248
|
+
//
|
|
249
|
+
// FAIL-OPEN by construction — every uncertain path DELIVERS the handback:
|
|
250
|
+
// - no notification seen for this exact id → deliver (a dropped real wake
|
|
251
|
+
// means a silent worker and a user waiting forever; an occasional double
|
|
252
|
+
// is strictly better);
|
|
253
|
+
// - notification older than the TTL → deliver (a resumed worker's second
|
|
254
|
+
// completion must not be swallowed by its first completion's entry);
|
|
255
|
+
// - non-terminal notification status → never recorded, so → deliver;
|
|
256
|
+
// - gateway restart (ledger is in-memory) → boot-replayed handbacks
|
|
257
|
+
// deliver;
|
|
258
|
+
// - the CLI notification landing AFTER the handback enqueue (lost race) →
|
|
259
|
+
// deliver (the residual occasional double, accepted).
|
|
260
|
+
// The liveness consumer (background-shell-liveness.ts) is untouched: the same
|
|
261
|
+
// `task_notification` event still drives noteBackgroundShellDead —
|
|
262
|
+
// recording here is an independent, additive read of the event.
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* How long a recorded terminal `<task-notification>` suppresses the matching
|
|
266
|
+
* `subagent_handback`. The real gap between the CLI's notification enqueue
|
|
267
|
+
* and the watcher's `onFinish` (its ~1s jsonl rescan) is a few seconds; 30s
|
|
268
|
+
* covers scheduler jitter with a wide margin while staying far below any
|
|
269
|
+
* plausible resume-and-complete-again cycle for the same agent id, so a
|
|
270
|
+
* resumed worker's second completion falls outside the window → fail-open.
|
|
271
|
+
*/
|
|
272
|
+
export const TASK_NOTIFICATION_DEDUP_TTL_MS = 30_000
|
|
273
|
+
|
|
274
|
+
/** `<task-notification>` statuses that mean the task genuinely ended and the
|
|
275
|
+
* CLI woke (or will wake) the parent with the completion. Mirrors the
|
|
276
|
+
* liveness consumer's terminal set (background-shell-liveness.ts). */
|
|
277
|
+
const NOTIF_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* In-memory seen-set of terminal CLI `<task-notification>` task ids.
|
|
281
|
+
* Deliberately process-local: absence after a restart fails open (deliver).
|
|
282
|
+
*/
|
|
283
|
+
export class CliTaskNotificationLedger {
|
|
284
|
+
private readonly seen = new Map<string, number>()
|
|
285
|
+
|
|
286
|
+
/** Record a parsed `task_notification` session event. Non-terminal
|
|
287
|
+
* statuses and empty ids are ignored (they must never suppress). */
|
|
288
|
+
record(taskId: string, status: string, now: number): void {
|
|
289
|
+
if (taskId.length === 0 || !NOTIF_TERMINAL_STATUSES.has(status)) return
|
|
290
|
+
this.seen.set(taskId, now)
|
|
291
|
+
// Bounded: prune expired entries on write so the map tracks only the
|
|
292
|
+
// live window (a handful of ids), never the process lifetime.
|
|
293
|
+
for (const [id, ts] of this.seen) {
|
|
294
|
+
if (now - ts > TASK_NOTIFICATION_DEDUP_TTL_MS) this.seen.delete(id)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** True iff a terminal notification for EXACTLY `taskId` was recorded
|
|
299
|
+
* within the TTL. Anything else → false → the handback delivers. */
|
|
300
|
+
seenRecently(taskId: string, now: number): boolean {
|
|
301
|
+
const ts = this.seen.get(taskId)
|
|
302
|
+
return ts != null && now - ts <= TASK_NOTIFICATION_DEDUP_TTL_MS
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The gateway's process-wide ledger instance (one gateway process per agent,
|
|
308
|
+
* one main session — a module singleton keeps the gateway.ts wiring to two
|
|
309
|
+
* lines under the anti-inflation ratchet, #2996). Written at the gateway's
|
|
310
|
+
* `onSessionEvent` (OUTSIDE the currentTurn guard — the CLI enqueues the
|
|
311
|
+
* notification while the parent is typically idle with no live gateway turn);
|
|
312
|
+
* read at the `onFinish` handback decide site. Tests construct their own
|
|
313
|
+
* `CliTaskNotificationLedger` instances.
|
|
314
|
+
*/
|
|
315
|
+
export const cliTaskNotifLedger = new CliTaskNotificationLedger()
|
|
316
|
+
|
|
223
317
|
/** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
|
|
224
318
|
const MAIN_THREAD_KEY = '<main>'
|
|
225
319
|
|
|
@@ -35,6 +35,18 @@
|
|
|
35
35
|
* (`LastFleetRoll` docstring in src/auth/broker/server.ts), which also
|
|
36
36
|
* covers PINNED (non-fleet-active) account rolls — then nudges the resume
|
|
37
37
|
* immediately through the same turn-safety guards.
|
|
38
|
+
*
|
|
39
|
+
* Two entrypoints (#failover-429-corroborate):
|
|
40
|
+
* - `fire` — the FULL path above, for an ACCOUNT-SCOPED 429 (wording that
|
|
41
|
+
* names the account's own usage limit). Records `throttled_until`, notices,
|
|
42
|
+
* nudges, escalates.
|
|
43
|
+
* - `fireProbeOnly` — for a GENERIC-TRANSIENT 429 (bare `rate_limit_error`
|
|
44
|
+
* wording that neither names the account nor is proxy-local). Runs ONLY the
|
|
45
|
+
* broker's rate-bounded escalation probe: a corroborated wall escalates
|
|
46
|
+
* exactly like `fire`, but a HEALTHY probe is account-inert — no notice, no
|
|
47
|
+
* nudge, no `throttled_until`, no second card. The gateway's calm
|
|
48
|
+
* rate-limited card stays the only user-visible output. The two share the
|
|
49
|
+
* escalation announce/resume via `announceEscalation`.
|
|
38
50
|
*/
|
|
39
51
|
|
|
40
52
|
import {
|
|
@@ -56,7 +68,7 @@ export const THROTTLE_RETRY_NUDGE_JITTER_MAX_MS = 30_000
|
|
|
56
68
|
/** The narrow broker surface the runner needs (structurally satisfied by
|
|
57
69
|
* the gateway's AuthBrokerClient). */
|
|
58
70
|
export interface ThrottleBrokerClient {
|
|
59
|
-
markThrottled(until: number): Promise<{
|
|
71
|
+
markThrottled(until: number, probeOnly?: boolean): Promise<{
|
|
60
72
|
account: string
|
|
61
73
|
throttled_until: number
|
|
62
74
|
escalated: boolean
|
|
@@ -92,10 +104,21 @@ export interface ThrottleTierRunnerDeps {
|
|
|
92
104
|
|
|
93
105
|
export interface ThrottleTierRunner {
|
|
94
106
|
/**
|
|
95
|
-
* Run the throttle path for one terminal
|
|
96
|
-
*
|
|
107
|
+
* Run the FULL throttle path for one terminal account-scoped 429: record
|
|
108
|
+
* `throttled_until`, post one deduped notice, arm the retry nudge, and
|
|
109
|
+
* escalate to failover when the first-hit probe corroborates a wall.
|
|
110
|
+
* Fire-and-forget from the caller's perspective — never throws.
|
|
97
111
|
*/
|
|
98
112
|
fire(triggerAgent: string, throttledUntilMs: number, resetParsed: boolean): Promise<void>
|
|
113
|
+
/**
|
|
114
|
+
* PROBE-ONLY path for a generic-transient 429 (#failover-429-corroborate).
|
|
115
|
+
* Runs ONLY the broker's rate-bounded escalation probe: a corroborated wall
|
|
116
|
+
* escalates (announce + resume nudge) exactly like `fire`; a HEALTHY probe is
|
|
117
|
+
* account-inert — NO notice, NO nudge, NO `throttled_until` soft-defer, NO
|
|
118
|
+
* second card. The calm rate-limited card the gateway already emitted stays
|
|
119
|
+
* the only user-visible output. Fire-and-forget; never throws.
|
|
120
|
+
*/
|
|
121
|
+
fireProbeOnly(triggerAgent: string): Promise<void>
|
|
99
122
|
/** Test/debug view of internal state. */
|
|
100
123
|
inspect(): { noticeState: ThrottleNoticeState; nudgePending: boolean }
|
|
101
124
|
}
|
|
@@ -180,6 +203,33 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
|
|
206
|
+
/**
|
|
207
|
+
* The escalated outcome, shared by `fire` and `fireProbeOnly`: the broker
|
|
208
|
+
* corroborated a genuine wall via a live probe and already ran mark-exhausted
|
|
209
|
+
* + roll (fleet active AND pinned accounts alike). The RAISING gateway
|
|
210
|
+
* announces — reactive-path doctrine — fleet-deduped so N gateways sharing
|
|
211
|
+
* the account produce one copy per chat, then nudges the resume.
|
|
212
|
+
*/
|
|
213
|
+
async function announceEscalation(
|
|
214
|
+
client: ThrottleBrokerClient | null,
|
|
215
|
+
account: string | null,
|
|
216
|
+
rolledTo: string | null,
|
|
217
|
+
triggerAgent: string,
|
|
218
|
+
armedAtMs: number,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
deps.log(
|
|
221
|
+
`[throttle-tier] escalated to wall account=${account ?? '?'} ` +
|
|
222
|
+
`rolledTo=${rolledTo ?? 'none (all blocked)'}`,
|
|
223
|
+
)
|
|
224
|
+
await broadcastDeduped(
|
|
225
|
+
client,
|
|
226
|
+
'throttle-escalation',
|
|
227
|
+
account,
|
|
228
|
+
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
|
|
229
|
+
)
|
|
230
|
+
if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
|
|
231
|
+
}
|
|
232
|
+
|
|
183
233
|
async function fire(
|
|
184
234
|
triggerAgent: string,
|
|
185
235
|
throttledUntilMs: number,
|
|
@@ -209,21 +259,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
209
259
|
}
|
|
210
260
|
|
|
211
261
|
if (escalated) {
|
|
212
|
-
|
|
213
|
-
// ran mark-exhausted + roll (fleet active AND pinned accounts alike).
|
|
214
|
-
// The RAISING gateway announces — reactive-path doctrine — fleet-
|
|
215
|
-
// deduped so N gateways sharing the account produce one copy per chat.
|
|
216
|
-
deps.log(
|
|
217
|
-
`[throttle-tier] escalated to wall account=${account ?? '?'} ` +
|
|
218
|
-
`rolledTo=${rolledTo ?? 'none (all blocked)'}`,
|
|
219
|
-
)
|
|
220
|
-
await broadcastDeduped(
|
|
221
|
-
client,
|
|
222
|
-
'throttle-escalation',
|
|
223
|
-
account,
|
|
224
|
-
renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
|
|
225
|
-
)
|
|
226
|
-
if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
|
|
262
|
+
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
227
263
|
return
|
|
228
264
|
}
|
|
229
265
|
|
|
@@ -261,8 +297,47 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
|
|
|
261
297
|
}, delayMs)
|
|
262
298
|
}
|
|
263
299
|
|
|
300
|
+
async function fireProbeOnly(triggerAgent: string): Promise<void> {
|
|
301
|
+
const armedAtMs = now()
|
|
302
|
+
let client: ThrottleBrokerClient | null = null
|
|
303
|
+
let account: string | null = null
|
|
304
|
+
let escalated = false
|
|
305
|
+
let rolledTo: string | null = null
|
|
306
|
+
try {
|
|
307
|
+
client = await deps.getBrokerClient()
|
|
308
|
+
if (client) {
|
|
309
|
+
// `until` is ignored by the broker in probeOnly mode (nothing is
|
|
310
|
+
// recorded) but the protocol requires a positive int — pass a nominal.
|
|
311
|
+
const r = await client.markThrottled(now() + 1, true)
|
|
312
|
+
account = r.account
|
|
313
|
+
escalated = r.escalated
|
|
314
|
+
rolledTo = r.rolledTo ?? null
|
|
315
|
+
} else {
|
|
316
|
+
deps.log(
|
|
317
|
+
`[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
deps.log(
|
|
322
|
+
`[throttle-tier] probe-only markThrottled failed agent=${triggerAgent}: ${(err as Error)?.message ?? err}`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (escalated) {
|
|
327
|
+
await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// HEALTHY / rate-bounded / broker-down: account-inert. NOTHING else — no
|
|
332
|
+
// notice, no retry nudge, no throttled_until soft-defer. The calm
|
|
333
|
+
// rate-limited card the gateway already emitted is the only user-visible
|
|
334
|
+
// output, exactly as before the escalation probe was wired.
|
|
335
|
+
deps.log(`[throttle-tier] generic-transient probe-only inert (no wall) agent=${triggerAgent}`)
|
|
336
|
+
}
|
|
337
|
+
|
|
264
338
|
return {
|
|
265
339
|
fire,
|
|
340
|
+
fireProbeOnly,
|
|
266
341
|
inspect: () => ({ noticeState, nudgePending: pendingNudge != null }),
|
|
267
342
|
}
|
|
268
343
|
}
|
|
@@ -34,19 +34,38 @@
|
|
|
34
34
|
// `__x__` double runs are excluded for free: the inner neighbour of each `*`
|
|
35
35
|
// in `a**b` is another `*` (not alphanumeric), so neither half matches.
|
|
36
36
|
//
|
|
37
|
+
// A SECOND, always-safe arm (added for issue #3464 alongside the line-start
|
|
38
|
+
// glued-`#` guard) escapes a `*` that is flanked by WHITESPACE OR A STRING
|
|
39
|
+
// BOUNDARY on BOTH immediate sides (`a * b`, ` * `, a bare `*`, a trailing
|
|
40
|
+
// `rm *`). Under GFM flanking rules such a `*` is neither left- nor right-
|
|
41
|
+
// flanking, so it can NEVER open OR close emphasis; escaping it therefore
|
|
42
|
+
// cannot break an intended `*italic*` / `**bold**` (whose delimiters are
|
|
43
|
+
// word-adjacent on their INNER side, so never whitespace-flanked on both
|
|
44
|
+
// sides). Because this `*` can never pair, the arm fires on a single
|
|
45
|
+
// occurrence — it needs no 2+ threshold. Live Telegram UAT for #3464 confirmed
|
|
46
|
+
// the non-spec Bot API parser can still surface a stray lone `*` in this
|
|
47
|
+
// position, so neutralising it deterministically is the durable fix.
|
|
48
|
+
//
|
|
49
|
+
// The ONE boundary-flanked position this arm must NOT touch is a line-leading
|
|
50
|
+
// `* ` — that is an unordered-list BULLET (`* item`), not an inert operator.
|
|
51
|
+
// Escaping it would break `*`-bullets on every outbound message AND (because
|
|
52
|
+
// this arm runs before `guardAccidentalHeading`) disarm the glued-`#` fix for
|
|
53
|
+
// `* #4382`. See `isLineLeadingBullet`. A lone `*` on its own line has no
|
|
54
|
+
// trailing space, is not a bullet, and is still escaped.
|
|
55
|
+
//
|
|
37
56
|
// What we DELIBERATELY LEAVE ALONE (conservative false-negatives, per the
|
|
38
57
|
// "when in doubt, leave it" doctrine inherited from the dollar guard):
|
|
39
|
-
// -
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
58
|
+
// - Boundary-flanked delimiters with a NON-whitespace neighbour on the other
|
|
59
|
+
// side (`*.ts`, leading-`_` `_private`): these are INDISTINGUISHABLE from
|
|
60
|
+
// an intended `*glob*` / `_italic_` opener (`*.ts is a glob*` is a
|
|
61
|
+
// legitimate italic). Escaping them would risk breaking intended emphasis —
|
|
62
|
+
// the one thing this guard must never do — so a glob/leading-underscore
|
|
63
|
+
// that pairs into an accidental span is accepted as a rare false-negative
|
|
64
|
+
// rather than risked as a false-positive. (The whitespace-on-BOTH-sides
|
|
65
|
+
// form `rm *` is NOT in this class — a `*` with whitespace/boundary on both
|
|
66
|
+
// sides is provably inert, so the new arm above escapes it safely.)
|
|
67
|
+
// `_` in this position is likewise left alone; the boundary-flanked arm is
|
|
68
|
+
// `*`-only.
|
|
50
69
|
//
|
|
51
70
|
// Idempotent: the escape is expressed as an intra-word match, so an
|
|
52
71
|
// already-escaped `\_`/`\*` has a backslash (not an alphanumeric) immediately
|
|
@@ -91,6 +110,47 @@ const INTRA_WORD_UNDERSCORE = /(?<=[A-Za-z0-9])_(?=[A-Za-z0-9])/g;
|
|
|
91
110
|
* neighbour is `*` (not alnum). Idempotent for the same reason as above. */
|
|
92
111
|
const INTRA_WORD_ASTERISK = /(?<=[A-Za-z0-9])\*(?=[A-Za-z0-9])/g;
|
|
93
112
|
|
|
113
|
+
/** A `*` flanked by WHITESPACE OR A STRING BOUNDARY on BOTH immediate sides
|
|
114
|
+
* (`a * b`, ` * `, a bare `*`, `rm *`). Neither left- nor right-flanking under
|
|
115
|
+
* GFM, so it can never open or close emphasis — escaping it is always safe and
|
|
116
|
+
* can never touch an intended `*italic*` / `**bold**` (word-adjacent on the
|
|
117
|
+
* inner side). Idempotent: after escaping, the `*` is preceded by `\` (neither
|
|
118
|
+
* whitespace nor `^`), so the lookbehind no longer matches. `\n` is whitespace,
|
|
119
|
+
* so a `*` alone on its own line is covered without a multiline flag.
|
|
120
|
+
*
|
|
121
|
+
* CRITICAL EXCLUSION — a line-leading `* ` is an unordered-LIST BULLET, not an
|
|
122
|
+
* inert operator, and MUST NOT be escaped (`isLineLeadingBullet` below). Two
|
|
123
|
+
* reasons: (a) `*`-bullets are extremely common in replies and escaping the
|
|
124
|
+
* marker would break the list on EVERY outbound message (an asymmetric
|
|
125
|
+
* regression — `-`/`+` bullets are untouched); (b) this guard runs BEFORE
|
|
126
|
+
* `guardAccidentalHeading` in the `guardAccidentalFormatting` pipeline
|
|
127
|
+
* (rich-send.ts), and the heading guard's `ACCIDENTAL_HEADING_AFTER_MARKER`
|
|
128
|
+
* needs the LITERAL `*` marker to fire — escaping the marker here would silently
|
|
129
|
+
* disarm the glued-`#` fix for the `* #4382` case (#3464). A lone `*` on its own
|
|
130
|
+
* line (`\n*\n`) is NOT a bullet — no trailing space — so it is still escaped. */
|
|
131
|
+
const BOUNDARY_FLANKED_ASTERISK = /(?<=^|\s)\*(?=\s|$)/g;
|
|
132
|
+
|
|
133
|
+
/** True iff the `*` at `starIndex` is a line-leading unordered-list bullet: a
|
|
134
|
+
* line start (string start or after `\n`) + up to 3 spaces/tabs of indent, then
|
|
135
|
+
* the `*`, then a space/tab. This is the ONE boundary-flanked position we leave
|
|
136
|
+
* alone (see BOUNDARY_FLANKED_ASTERISK). Note: a segment that begins mid-line
|
|
137
|
+
* (right after an inline code span) has its start treated as a line start here,
|
|
138
|
+
* which errs toward PRESERVING an ambiguous leading `*` — the safe direction,
|
|
139
|
+
* since wrongly escaping a bullet is the failure this exclusion exists to stop. */
|
|
140
|
+
function isLineLeadingBullet(text: string, starIndex: number): boolean {
|
|
141
|
+
// A bullet requires a space/tab immediately after the marker.
|
|
142
|
+
const next = text[starIndex + 1];
|
|
143
|
+
if (next !== " " && next !== "\t") return false;
|
|
144
|
+
// Walk left over up to 3 indent spaces/tabs; the run must reach a line start.
|
|
145
|
+
let i = starIndex - 1;
|
|
146
|
+
let indent = 0;
|
|
147
|
+
while (i >= 0 && (text[i] === " " || text[i] === "\t")) {
|
|
148
|
+
if (++indent > 3) return false;
|
|
149
|
+
i--;
|
|
150
|
+
}
|
|
151
|
+
return i < 0 || text[i] === "\n";
|
|
152
|
+
}
|
|
153
|
+
|
|
94
154
|
/** Every unescaped `_` / `*` in prose (the pair-threshold input). An emphasis
|
|
95
155
|
* span needs a MATCHING pair of the same delimiter, so a delimiter can only
|
|
96
156
|
* mis-render when 2+ of it exist. The `(?<!\\)` keeps the count idempotent. */
|
|
@@ -129,22 +189,37 @@ export function guardAccidentalEmphasis(text: string): string {
|
|
|
129
189
|
// 2+ of that delimiter exist (so a pair — hence a mis-render — is possible).
|
|
130
190
|
let hasIntraUnderscore = false;
|
|
131
191
|
let hasIntraAsterisk = false;
|
|
192
|
+
let hasBoundaryAsterisk = false;
|
|
132
193
|
let underscoreCount = 0;
|
|
133
194
|
let asteriskCount = 0;
|
|
134
195
|
for (const seg of segments) {
|
|
135
196
|
if (seg.code) continue;
|
|
136
197
|
if (INTRA_WORD_UNDERSCORE.test(seg.text)) hasIntraUnderscore = true;
|
|
137
198
|
if (INTRA_WORD_ASTERISK.test(seg.text)) hasIntraAsterisk = true;
|
|
199
|
+
// Arm only on a boundary-flanked `*` that is NOT a line-leading bullet, so a
|
|
200
|
+
// segment whose only such `*` is a bullet keeps the guard a strict no-op.
|
|
201
|
+
if (!hasBoundaryAsterisk) {
|
|
202
|
+
for (const m of seg.text.matchAll(BOUNDARY_FLANKED_ASTERISK)) {
|
|
203
|
+
if (!isLineLeadingBullet(seg.text, m.index ?? 0)) {
|
|
204
|
+
hasBoundaryAsterisk = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
138
209
|
underscoreCount += seg.text.match(ANY_UNDERSCORE)?.length ?? 0;
|
|
139
210
|
asteriskCount += seg.text.match(ANY_ASTERISK)?.length ?? 0;
|
|
140
211
|
}
|
|
141
212
|
// Reset lastIndex — the /g regexes above are stateful across .test() calls.
|
|
142
213
|
INTRA_WORD_UNDERSCORE.lastIndex = 0;
|
|
143
214
|
INTRA_WORD_ASTERISK.lastIndex = 0;
|
|
215
|
+
BOUNDARY_FLANKED_ASTERISK.lastIndex = 0;
|
|
144
216
|
|
|
145
217
|
const armUnderscore = hasIntraUnderscore && underscoreCount >= 2;
|
|
146
218
|
const armAsterisk = hasIntraAsterisk && asteriskCount >= 2;
|
|
147
|
-
|
|
219
|
+
// The boundary-flanked `*` can NEVER pair (neither flanking), so no 2+
|
|
220
|
+
// threshold: a single occurrence is escaped on its own.
|
|
221
|
+
const armBoundaryAsterisk = hasBoundaryAsterisk;
|
|
222
|
+
if (!armUnderscore && !armAsterisk && !armBoundaryAsterisk) return text;
|
|
148
223
|
|
|
149
224
|
return segments
|
|
150
225
|
.map((seg) => {
|
|
@@ -152,6 +227,11 @@ export function guardAccidentalEmphasis(text: string): string {
|
|
|
152
227
|
let out = seg.text;
|
|
153
228
|
if (armUnderscore) out = out.replace(INTRA_WORD_UNDERSCORE, "\\_");
|
|
154
229
|
if (armAsterisk) out = out.replace(INTRA_WORD_ASTERISK, "\\*");
|
|
230
|
+
if (armBoundaryAsterisk) {
|
|
231
|
+
out = out.replace(BOUNDARY_FLANKED_ASTERISK, (m, offset: number, str: string) =>
|
|
232
|
+
isLineLeadingBullet(str, offset) ? m : "\\*",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
155
235
|
return out;
|
|
156
236
|
})
|
|
157
237
|
.join("");
|
|
@@ -187,13 +187,38 @@ export function guardAccidentalBlockConstructs(text: string): string {
|
|
|
187
187
|
* `#{1,6}` no longer sits at the (post-indent) line start. */
|
|
188
188
|
const ACCIDENTAL_HEADING = /^([ \t]{0,3})(#{1,6})(?=[^\s#])/;
|
|
189
189
|
|
|
190
|
+
/** Same accidental-heading run, but sitting AFTER one or more line-start LIST or
|
|
191
|
+
* BLOCKQUOTE markers on the SAME line (`- #4382`, `* #4382`, `+ #4382`,
|
|
192
|
+
* `1. #4382`, `> #4382`, and nested combinations like `- > #4382`) — issue
|
|
193
|
+
* #3464. `guardAccidentalHeading`'s original `^`-anchored pattern only sees a
|
|
194
|
+
* `#` at the true (post-indent) line start, so a `#` glued after a marker slips
|
|
195
|
+
* through and Telegram's non-spec Bot API parser promotes it to a heading just
|
|
196
|
+
* as it does at a bare line start (confirmed by live UAT for #3464). The prefix
|
|
197
|
+
* is `([ \t]{0,3}<marker>+)`: up to 3 leading spaces, then one-or-more markers,
|
|
198
|
+
* each an unordered bullet (`-`/`*`/`+`) or short ordered marker (`1.`/`1)`,
|
|
199
|
+
* 1–3 digits) followed by ≥1 space/tab, or a blockquote `>` with optional
|
|
200
|
+
* spaces. Group 1 captures the WHOLE prefix so the replacement re-emits it
|
|
201
|
+
* verbatim and escapes ONLY the first `#` of group 2. The same `(?=[^\s#])`
|
|
202
|
+
* lookahead keeps a real nested heading (`- # Heading`, space AFTER the `#`)
|
|
203
|
+
* and a bare `#`/`##` untouched. Idempotent: after escaping, the `#` sits
|
|
204
|
+
* behind a `\` (`- \#4382`), so the `(#{1,6})` no longer follows the prefix. */
|
|
205
|
+
const ACCIDENTAL_HEADING_AFTER_MARKER =
|
|
206
|
+
/^([ \t]{0,3}(?:(?:[-*+]|\d{1,3}[.)])[ \t]+|>[ \t]*)+)(#{1,6})(?=[^\s#])/;
|
|
207
|
+
|
|
190
208
|
/**
|
|
191
209
|
* Escape the accidental heading trigger at the start of ONE line (the string
|
|
192
210
|
* must NOT contain a newline; the caller guarantees a true line start). A no-op
|
|
193
|
-
* unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char
|
|
211
|
+
* unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char
|
|
212
|
+
* — either at the true line start (`#4382`) or immediately after one or more
|
|
213
|
+
* line-start list/blockquote markers (`- #4382`, `> #4382`, `1. #4382`, #3464).
|
|
214
|
+
* The two patterns are mutually exclusive on any given line (the bare one needs
|
|
215
|
+
* a `#` right after the indent; the marker one needs a marker first), so running
|
|
216
|
+
* both replaces can never double-escape.
|
|
194
217
|
*/
|
|
195
218
|
function escapeAccidentalHeadingLine(line: string): string {
|
|
196
|
-
return line
|
|
219
|
+
return line
|
|
220
|
+
.replace(ACCIDENTAL_HEADING, "$1\\$2")
|
|
221
|
+
.replace(ACCIDENTAL_HEADING_AFTER_MARKER, "$1\\$2");
|
|
197
222
|
}
|
|
198
223
|
|
|
199
224
|
/**
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* surface uses: trust-by-explicit-listing.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { parseSourceMessageId } from './gateway/source-message-id.js'
|
|
22
|
+
|
|
21
23
|
export interface StickerAliasMap {
|
|
22
24
|
/** alias name → Telegram file_id. Both validated on read. */
|
|
23
25
|
[alias: string]: string
|
|
@@ -132,13 +134,12 @@ export function resolveStickerSendArgs(
|
|
|
132
134
|
throw new Error('send_sticker: message_thread_id must be a positive integer string')
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
137
|
+
// #4368 — route the agent-supplied reply anchor through the canonical
|
|
138
|
+
// guard. A fabricated (synthetic / out-of-int32) message id — e.g. an agent
|
|
139
|
+
// echoing a boot-resume/handback inbound's `Date.now()`-scale id — yields
|
|
140
|
+
// null, so the sticker sends UNANCHORED rather than 400ing the whole send on
|
|
141
|
+
// `reply_parameters.message_id` (which Telegram hard-rejects out of range).
|
|
142
|
+
const replyTo = parseSourceMessageId(raw.reply_to) ?? undefined
|
|
142
143
|
|
|
143
144
|
return {
|
|
144
145
|
chatId: raw.chat_id,
|
|
@@ -230,13 +231,10 @@ export function resolveGifSendArgs(raw: GifSendArgs): ValidatedGifSendArgs {
|
|
|
230
231
|
throw new Error('send_gif: message_thread_id must be a positive integer string')
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
throw new Error('send_gif: reply_to must be a positive integer string')
|
|
238
|
-
}
|
|
239
|
-
}
|
|
234
|
+
// #4368 — route the agent-supplied reply anchor through the canonical guard
|
|
235
|
+
// (see resolveStickerSendArgs). A fabricated / out-of-int32 id sends the GIF
|
|
236
|
+
// UNANCHORED instead of 400ing on `reply_parameters.message_id`.
|
|
237
|
+
const replyTo = parseSourceMessageId(raw.reply_to) ?? undefined
|
|
240
238
|
|
|
241
239
|
return {
|
|
242
240
|
chatId: raw.chat_id,
|
|
@@ -106,6 +106,21 @@ describe('validateAskUserArgs — optional fields', () => {
|
|
|
106
106
|
const r = validateAskUserArgs({ chat_id: '1', question: 'q', options: ['a', 'b'], reply_to: '99' })
|
|
107
107
|
expect(r.replyTo).toBe(99)
|
|
108
108
|
})
|
|
109
|
+
|
|
110
|
+
// #4368 — a fabricated reply anchor (a synthetic boot-resume/handback/cron
|
|
111
|
+
// inbound id at `Date.now()` scale) is out of the signed-int32 range Telegram
|
|
112
|
+
// accepts for `reply_parameters.message_id`. It must be DROPPED so the
|
|
113
|
+
// ask_user prompt still sends (unanchored) rather than 400ing the whole send.
|
|
114
|
+
// Before the fix `Number(args.reply_to)` let a 13-digit id through unchanged.
|
|
115
|
+
it('drops an out-of-int32 reply_to so the prompt sends unanchored (#4368)', () => {
|
|
116
|
+
const r = validateAskUserArgs({
|
|
117
|
+
chat_id: '1',
|
|
118
|
+
question: 'q',
|
|
119
|
+
options: ['a', 'b'],
|
|
120
|
+
reply_to: String(1_785_000_000_000),
|
|
121
|
+
})
|
|
122
|
+
expect(r.replyTo).toBeUndefined()
|
|
123
|
+
})
|
|
109
124
|
})
|
|
110
125
|
|
|
111
126
|
describe('validateAskUserArgs — timeout clamping', () => {
|
|
@@ -146,6 +146,27 @@ describe('native payloads — correct Bot API 9.1 shape', () => {
|
|
|
146
146
|
expect(JSON.stringify(payload)).not.toContain('is_completed')
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
+
// #4368 — a fabricated reply anchor (a synthetic boot-resume/handback/cron
|
|
150
|
+
// inbound id at `Date.now()` scale) is out of the signed-int32 range Telegram
|
|
151
|
+
// accepts for `reply_parameters.message_id`. The builder must DROP it so the
|
|
152
|
+
// native checklist sends UNANCHORED rather than 400ing the whole send. Before
|
|
153
|
+
// the fix the builder emitted `reply_parameters.message_id` verbatim.
|
|
154
|
+
it('drops an out-of-int32 replyToMessageId — no reply_parameters (#4368)', () => {
|
|
155
|
+
const payload = buildNativeChecklistPayload({
|
|
156
|
+
businessConnectionId: 'bc1',
|
|
157
|
+
chatId: 42,
|
|
158
|
+
title: 'T',
|
|
159
|
+
tasks: buildChecklistTasks([{ text: 'a' }]),
|
|
160
|
+
replyToMessageId: 1_785_000_000_000,
|
|
161
|
+
})
|
|
162
|
+
expect(payload.reply_parameters).toBeUndefined()
|
|
163
|
+
expect(payload).toEqual({
|
|
164
|
+
business_connection_id: 'bc1',
|
|
165
|
+
chat_id: 42,
|
|
166
|
+
checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }] },
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
149
170
|
it('editMessageChecklist sends the FULL checklist (native edits replace it)', () => {
|
|
150
171
|
const payload = buildNativeEditChecklistPayload({
|
|
151
172
|
businessConnectionId: 'bc1',
|