switchroom 0.19.40 → 0.19.41
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/cli/switchroom.js +39 -8
- package/dist/host-control/main.js +72 -17
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +240 -46
- package/telegram-plugin/dist/server.js +2 -2
- package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
- package/telegram-plugin/gateway/gateway.ts +69 -69
- package/telegram-plugin/gateway/turn-record-status.ts +80 -0
- package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
- package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checklist send/update with graceful degradation.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's native `sendChecklist` / `editMessageChecklist` (Bot API 9.1)
|
|
5
|
+
* are **Business-account-only**: both REQUIRE a `business_connection_id`,
|
|
6
|
+
* and the payload nests `title`/`tasks` inside a single `checklist` object
|
|
7
|
+
* (`InputChecklist`) whose tasks each carry a REQUIRED integer `id` and NO
|
|
8
|
+
* completion flag (`InputChecklistTask = { id, text }` — a native checklist
|
|
9
|
+
* cannot be pre-marked done). The original wrapper sent a FLAT
|
|
10
|
+
* `{ chat_id, title, tasks: [{ text, is_completed? }] }` payload, which
|
|
11
|
+
* Telegram rejected with `400: parameter "checklist" is required` — and even
|
|
12
|
+
* a well-formed payload fails for ordinary bot DMs/groups (no business
|
|
13
|
+
* connection), which is every chat this fleet uses.
|
|
14
|
+
*
|
|
15
|
+
* So the tool degrades gracefully instead of surfacing a raw 400:
|
|
16
|
+
* 1. If a business connection id is configured AND the Bot API supports
|
|
17
|
+
* checklists → build the CORRECT native payload and send natively.
|
|
18
|
+
* 2. Otherwise (the normal case) → render the checklist as a formatted
|
|
19
|
+
* text message (bold title + ✅/⬜ task lines — text CAN show the
|
|
20
|
+
* `done` flag) and send via the normal message path. The tool result
|
|
21
|
+
* carries `degraded: "text"` so the caller knows it is not interactive.
|
|
22
|
+
* 3. A failed native attempt falls back to text rather than erroring.
|
|
23
|
+
*
|
|
24
|
+
* Updates work against a per-message in-memory state store (the gateway has
|
|
25
|
+
* no other record of a text-rendered checklist's tasks): a patch is applied
|
|
26
|
+
* to the stored state and the message is edited (natively or as text to
|
|
27
|
+
* match how it was sent). State is process-local — after a gateway restart
|
|
28
|
+
* an update to a pre-restart checklist returns a graceful
|
|
29
|
+
* `unknown_checklist` result unless the patch itself carries a full
|
|
30
|
+
* replacement (title + all task texts), never a raw Telegram error.
|
|
31
|
+
*
|
|
32
|
+
* Pure/deps-injected so the orchestration is unit-testable without a bot
|
|
33
|
+
* (same pattern as checklist-message-handler.ts).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface ChecklistTaskState {
|
|
37
|
+
/** 1-based sequential id assigned at send time (mirrors the native API's
|
|
38
|
+
* required per-task integer id; doubles as the patch handle in text mode). */
|
|
39
|
+
id: number
|
|
40
|
+
text: string
|
|
41
|
+
done: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChecklistState {
|
|
45
|
+
title: string
|
|
46
|
+
tasks: ChecklistTaskState[]
|
|
47
|
+
/** How the message was actually delivered — updates must match it. */
|
|
48
|
+
mode: 'native' | 'text'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ChecklistPatchTask {
|
|
52
|
+
id?: string | number
|
|
53
|
+
text?: string
|
|
54
|
+
done?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ChecklistPatch {
|
|
58
|
+
title?: string
|
|
59
|
+
tasks?: ChecklistPatchTask[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const CHECKLIST_MAX_TASKS = 30
|
|
63
|
+
|
|
64
|
+
/** Assign sequential 1-based ids and normalize the `done` flag. */
|
|
65
|
+
export function buildChecklistTasks(
|
|
66
|
+
tasks: ReadonlyArray<{ text: string; done?: boolean }>,
|
|
67
|
+
): ChecklistTaskState[] {
|
|
68
|
+
if (tasks.length > CHECKLIST_MAX_TASKS) {
|
|
69
|
+
throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
|
|
70
|
+
}
|
|
71
|
+
return tasks.map((t, i) => ({ id: i + 1, text: t.text, done: t.done === true }))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render a checklist as a text message: title header + one ✅/⬜ line per
|
|
76
|
+
* task. Rich (default) output is GFM markdown for `richMessage()`; pass
|
|
77
|
+
* `literal: true` when the access config pins `parseMode: 'text'` (plain
|
|
78
|
+
* sends, no markdown parsing) so the title doesn't ship raw asterisks.
|
|
79
|
+
*/
|
|
80
|
+
export function renderChecklistText(
|
|
81
|
+
cl: { title: string; tasks: ReadonlyArray<{ text: string; done: boolean }> },
|
|
82
|
+
opts?: { literal?: boolean },
|
|
83
|
+
): string {
|
|
84
|
+
const header = opts?.literal ? cl.title : `**${cl.title}**`
|
|
85
|
+
const lines = cl.tasks.map((t) => `${t.done ? '✅' : '⬜'} ${t.text}`)
|
|
86
|
+
return [header, ...lines].join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Apply an update_checklist patch: `title` replaces the title; a task with
|
|
91
|
+
* an `id` matching an existing task updates its text/done in place; a task
|
|
92
|
+
* without an `id` (or with an unknown id) is appended. Returns a NEW state
|
|
93
|
+
* (input untouched). Removal is not supported by the patch shape.
|
|
94
|
+
*/
|
|
95
|
+
export function applyChecklistPatch(
|
|
96
|
+
cl: { title: string; tasks: ReadonlyArray<ChecklistTaskState> },
|
|
97
|
+
patch: ChecklistPatch,
|
|
98
|
+
): { title: string; tasks: ChecklistTaskState[] } {
|
|
99
|
+
const tasks = cl.tasks.map((t) => ({ ...t }))
|
|
100
|
+
for (const p of patch.tasks ?? []) {
|
|
101
|
+
const pid = p.id != null ? Number(p.id) : undefined
|
|
102
|
+
const existing = pid != null ? tasks.find((t) => t.id === pid) : undefined
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (p.text != null) existing.text = p.text
|
|
105
|
+
if (p.done != null) existing.done = p.done
|
|
106
|
+
} else {
|
|
107
|
+
const nextId = pid != null && Number.isFinite(pid)
|
|
108
|
+
? pid
|
|
109
|
+
: tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1
|
|
110
|
+
tasks.push({ id: nextId, text: p.text ?? '', done: p.done === true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (tasks.length > CHECKLIST_MAX_TASKS) {
|
|
114
|
+
throw new Error(`checklist exceeds ${CHECKLIST_MAX_TASKS}-task limit (got ${tasks.length})`)
|
|
115
|
+
}
|
|
116
|
+
return { title: patch.title ?? cl.title, tasks }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build a CORRECT native `sendChecklist` payload per Bot API 9.1:
|
|
121
|
+
* `business_connection_id` is required, `title`/`tasks` nest inside a single
|
|
122
|
+
* `checklist` object (`InputChecklist`), each task carries its integer `id`,
|
|
123
|
+
* and there is NO completion field (`InputChecklistTask` has none).
|
|
124
|
+
*/
|
|
125
|
+
export function buildNativeChecklistPayload(p: {
|
|
126
|
+
businessConnectionId: string
|
|
127
|
+
chatId: number
|
|
128
|
+
title: string
|
|
129
|
+
tasks: ReadonlyArray<ChecklistTaskState>
|
|
130
|
+
replyToMessageId?: number
|
|
131
|
+
protectContent?: boolean
|
|
132
|
+
}): Record<string, unknown> {
|
|
133
|
+
return {
|
|
134
|
+
business_connection_id: p.businessConnectionId,
|
|
135
|
+
chat_id: p.chatId,
|
|
136
|
+
checklist: {
|
|
137
|
+
title: p.title,
|
|
138
|
+
tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
|
|
139
|
+
},
|
|
140
|
+
...(p.replyToMessageId != null ? { reply_parameters: { message_id: p.replyToMessageId } } : {}),
|
|
141
|
+
...(p.protectContent === true ? { protect_content: true } : {}),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a native `editMessageChecklist` payload. The native edit REPLACES
|
|
147
|
+
* the whole checklist, so the FULL post-patch state is sent; reusing the
|
|
148
|
+
* stored task ids preserves user tick state for unchanged tasks.
|
|
149
|
+
*/
|
|
150
|
+
export function buildNativeEditChecklistPayload(p: {
|
|
151
|
+
businessConnectionId: string
|
|
152
|
+
chatId: number
|
|
153
|
+
messageId: number
|
|
154
|
+
title: string
|
|
155
|
+
tasks: ReadonlyArray<ChecklistTaskState>
|
|
156
|
+
}): Record<string, unknown> {
|
|
157
|
+
return {
|
|
158
|
+
business_connection_id: p.businessConnectionId,
|
|
159
|
+
chat_id: p.chatId,
|
|
160
|
+
message_id: p.messageId,
|
|
161
|
+
checklist: {
|
|
162
|
+
title: p.title,
|
|
163
|
+
tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bounded per-message checklist state store (insertion-order eviction). */
|
|
169
|
+
export interface ChecklistStore {
|
|
170
|
+
get(key: string): ChecklistState | undefined
|
|
171
|
+
set(key: string, state: ChecklistState): void
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createChecklistStore(cap = 500): ChecklistStore {
|
|
175
|
+
const map = new Map<string, ChecklistState>()
|
|
176
|
+
return {
|
|
177
|
+
get: (key) => map.get(key),
|
|
178
|
+
set: (key, state) => {
|
|
179
|
+
if (map.has(key)) map.delete(key) // refresh insertion order
|
|
180
|
+
map.set(key, state)
|
|
181
|
+
while (map.size > cap) {
|
|
182
|
+
const oldest = map.keys().next().value as string | undefined
|
|
183
|
+
if (oldest == null) break
|
|
184
|
+
map.delete(oldest)
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function checklistStoreKey(chatId: string, messageId: string | number): string {
|
|
191
|
+
return `${chatId}:${messageId}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Orchestration (deps-injected, unit-testable) ─────────────────────────
|
|
195
|
+
|
|
196
|
+
export interface SendChecklistDeps {
|
|
197
|
+
/** Business connection id, when the operator has one configured. Native
|
|
198
|
+
* checklists are impossible without it (required API parameter). */
|
|
199
|
+
businessConnectionId: string | undefined
|
|
200
|
+
/** Boot-probe result: the connected grammY/Bot API exposes sendChecklist. */
|
|
201
|
+
nativeAvailable: boolean
|
|
202
|
+
sendNative: (payload: Record<string, unknown>) => Promise<{ message_id: number }>
|
|
203
|
+
sendText: (text: string) => Promise<{ message_id: number }>
|
|
204
|
+
/** access.parseMode === 'text' — render without markdown. */
|
|
205
|
+
literalText: boolean
|
|
206
|
+
log: (line: string) => void
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface SendChecklistResult {
|
|
210
|
+
message_id: number
|
|
211
|
+
mode: 'native' | 'text'
|
|
212
|
+
state: ChecklistState
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Send a checklist: natively when possible, degrading to a formatted text
|
|
217
|
+
* message otherwise. NEVER lets a native-send failure escape — the fallback
|
|
218
|
+
* is the text render. (A text-send failure still throws: there is nothing
|
|
219
|
+
* further to degrade to, and the caller's normal error path applies.)
|
|
220
|
+
*/
|
|
221
|
+
export async function performSendChecklist(
|
|
222
|
+
deps: SendChecklistDeps,
|
|
223
|
+
args: {
|
|
224
|
+
title: string
|
|
225
|
+
tasks: ReadonlyArray<{ text: string; done?: boolean }>
|
|
226
|
+
chatId: number
|
|
227
|
+
replyToMessageId?: number
|
|
228
|
+
protectContent?: boolean
|
|
229
|
+
},
|
|
230
|
+
): Promise<SendChecklistResult> {
|
|
231
|
+
const tasks = buildChecklistTasks(args.tasks)
|
|
232
|
+
if (deps.nativeAvailable && deps.businessConnectionId) {
|
|
233
|
+
try {
|
|
234
|
+
const sent = await deps.sendNative(buildNativeChecklistPayload({
|
|
235
|
+
businessConnectionId: deps.businessConnectionId,
|
|
236
|
+
chatId: args.chatId,
|
|
237
|
+
title: args.title,
|
|
238
|
+
tasks,
|
|
239
|
+
replyToMessageId: args.replyToMessageId,
|
|
240
|
+
protectContent: args.protectContent,
|
|
241
|
+
}))
|
|
242
|
+
return {
|
|
243
|
+
message_id: sent.message_id,
|
|
244
|
+
mode: 'native',
|
|
245
|
+
state: { title: args.title, tasks, mode: 'native' },
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
deps.log(`native sendChecklist failed — falling back to text render: ${err}\n`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const text = renderChecklistText({ title: args.title, tasks }, { literal: deps.literalText })
|
|
252
|
+
const sent = await deps.sendText(text)
|
|
253
|
+
return {
|
|
254
|
+
message_id: sent.message_id,
|
|
255
|
+
mode: 'text',
|
|
256
|
+
state: { title: args.title, tasks, mode: 'text' },
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Tool-result text for send_checklist. The degraded shape carries
|
|
261
|
+
* `degraded: "text"` + a note so the calling agent knows the checklist is a
|
|
262
|
+
* formatted message, not a natively tickable one. */
|
|
263
|
+
export function sendChecklistToolText(r: SendChecklistResult): string {
|
|
264
|
+
return JSON.stringify(
|
|
265
|
+
r.mode === 'native'
|
|
266
|
+
? { ok: true, message_id: r.message_id, mode: 'native' }
|
|
267
|
+
: {
|
|
268
|
+
ok: true,
|
|
269
|
+
message_id: r.message_id,
|
|
270
|
+
mode: 'text',
|
|
271
|
+
degraded: 'text',
|
|
272
|
+
note: 'rendered as a formatted text message (native Telegram checklists require a Business connection) — tasks are not tappable; use update_checklist with task ids 1..N to tick them',
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface UpdateChecklistDeps {
|
|
278
|
+
/** Stored state for this chat:message, if the gateway still has it. */
|
|
279
|
+
state: ChecklistState | undefined
|
|
280
|
+
businessConnectionId: string | undefined
|
|
281
|
+
nativeAvailable: boolean
|
|
282
|
+
editNative: (payload: Record<string, unknown>) => Promise<void>
|
|
283
|
+
editText: (text: string) => Promise<void>
|
|
284
|
+
literalText: boolean
|
|
285
|
+
log: (line: string) => void
|
|
286
|
+
chatId: number
|
|
287
|
+
messageId: number
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export type UpdateChecklistResult =
|
|
291
|
+
| { ok: true; mode: 'native' | 'text'; state: ChecklistState }
|
|
292
|
+
| { ok: false; reason: 'unknown_checklist' | 'edit_failed'; hint: string }
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Update a checklist message. Applies the patch to the stored state and
|
|
296
|
+
* re-renders in the mode the message was originally sent in. Failures come
|
|
297
|
+
* back as structured `{ ok: false, reason, hint }` results — never a raw
|
|
298
|
+
* Telegram error thrown at the caller.
|
|
299
|
+
*/
|
|
300
|
+
export async function performUpdateChecklist(
|
|
301
|
+
deps: UpdateChecklistDeps,
|
|
302
|
+
patch: ChecklistPatch,
|
|
303
|
+
): Promise<UpdateChecklistResult> {
|
|
304
|
+
let base = deps.state
|
|
305
|
+
if (!base) {
|
|
306
|
+
// State lost (gateway restart / evicted). If the patch is a full
|
|
307
|
+
// replacement — a title plus tasks that all carry text — we can rebuild
|
|
308
|
+
// and edit as text (the fleet-normal mode). Otherwise we cannot know
|
|
309
|
+
// the existing tasks; degrade gracefully.
|
|
310
|
+
const fullReplacement =
|
|
311
|
+
patch.title != null &&
|
|
312
|
+
(patch.tasks?.length ?? 0) > 0 &&
|
|
313
|
+
(patch.tasks ?? []).every((t) => t.text != null)
|
|
314
|
+
if (!fullReplacement) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: 'unknown_checklist',
|
|
318
|
+
hint: 'no stored state for this checklist (gateway restarted?). Re-send it with send_checklist, or pass a full replacement (title + every task\'s text) to update_checklist.',
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
base = { title: patch.title!, tasks: [], mode: 'text' }
|
|
322
|
+
}
|
|
323
|
+
let next: { title: string; tasks: ChecklistTaskState[] }
|
|
324
|
+
try {
|
|
325
|
+
next = applyChecklistPatch(base, patch)
|
|
326
|
+
} catch (err) {
|
|
327
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
328
|
+
}
|
|
329
|
+
const state: ChecklistState = { title: next.title, tasks: next.tasks, mode: base.mode }
|
|
330
|
+
if (base.mode === 'native') {
|
|
331
|
+
// A native checklist message cannot be edited into plain text — there is
|
|
332
|
+
// no text fallback here; a failure surfaces as a structured result.
|
|
333
|
+
if (!deps.nativeAvailable || !deps.businessConnectionId) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'edit_failed',
|
|
337
|
+
hint: 'this checklist was sent natively but the business connection / checklist API is no longer available.',
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
await deps.editNative(buildNativeEditChecklistPayload({
|
|
342
|
+
businessConnectionId: deps.businessConnectionId,
|
|
343
|
+
chatId: deps.chatId,
|
|
344
|
+
messageId: deps.messageId,
|
|
345
|
+
title: state.title,
|
|
346
|
+
tasks: state.tasks,
|
|
347
|
+
}))
|
|
348
|
+
return { ok: true, mode: 'native', state }
|
|
349
|
+
} catch (err) {
|
|
350
|
+
deps.log(`native editMessageChecklist failed: ${err}\n`)
|
|
351
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await deps.editText(renderChecklistText(state, { literal: deps.literalText }))
|
|
356
|
+
return { ok: true, mode: 'text', state }
|
|
357
|
+
} catch (err) {
|
|
358
|
+
deps.log(`checklist text edit failed: ${err}\n`)
|
|
359
|
+
return { ok: false, reason: 'edit_failed', hint: String(err) }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Tool-result text for update_checklist — structured for both outcomes. */
|
|
364
|
+
export function updateChecklistToolText(r: UpdateChecklistResult, messageId: number): string {
|
|
365
|
+
return JSON.stringify(
|
|
366
|
+
r.ok
|
|
367
|
+
? { ok: true, message_id: messageId, mode: r.mode, ...(r.mode === 'text' ? { degraded: 'text' } : {}) }
|
|
368
|
+
: { ok: false, reason: r.reason, hint: r.hint },
|
|
369
|
+
)
|
|
370
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type AskUserOutcome,
|
|
36
36
|
} from '../ask-user.js'
|
|
37
37
|
import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
|
|
38
|
+
import { createChecklistStore, checklistStoreKey, performSendChecklist, performUpdateChecklist, sendChecklistToolText, updateChecklistToolText } from './checklist-fallback.js'
|
|
38
39
|
import { parseInterruptMarker } from '../interrupt-marker.js'
|
|
39
40
|
import {
|
|
40
41
|
ToolFlightTracker,
|
|
@@ -1383,65 +1384,29 @@ let _rawEditMessageChecklist: unknown
|
|
|
1383
1384
|
/** True when the connected Telegram Bot API supports native checklists. Set in initGatewayBot() (#2996 P0b). */
|
|
1384
1385
|
let CHECKLIST_API_AVAILABLE = false
|
|
1385
1386
|
|
|
1386
|
-
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
chat_id: string
|
|
1393
|
-
title: string
|
|
1394
|
-
tasks: Array<{ text: string; done?: boolean }>
|
|
1395
|
-
message_thread_id?: number
|
|
1396
|
-
reply_to_message_id?: number
|
|
1397
|
-
protect_content?: boolean
|
|
1398
|
-
}): Promise<{ message_id: number }> {
|
|
1399
|
-
if (!CHECKLIST_API_AVAILABLE) {
|
|
1400
|
-
throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1401
|
-
}
|
|
1402
|
-
const MAX_TASKS = 30
|
|
1403
|
-
if (args.tasks.length > MAX_TASKS) {
|
|
1404
|
-
throw new Error(`checklist exceeds ${MAX_TASKS}-task limit (got ${args.tasks.length})`)
|
|
1405
|
-
}
|
|
1406
|
-
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)({
|
|
1407
|
-
chat_id: Number(args.chat_id),
|
|
1408
|
-
title: args.title,
|
|
1409
|
-
tasks: args.tasks.map(t => ({ text: t.text, ...(t.done != null ? { is_completed: t.done } : {}) })),
|
|
1410
|
-
...(args.message_thread_id != null ? { message_thread_id: args.message_thread_id } : {}),
|
|
1411
|
-
...(args.reply_to_message_id != null ? { reply_to_message_id: args.reply_to_message_id } : {}),
|
|
1412
|
-
...(args.protect_content === true ? { protect_content: true } : {}),
|
|
1413
|
-
})
|
|
1387
|
+
/** bot.api.raw.sendChecklist with a payload pre-built by checklist-fallback.ts
|
|
1388
|
+
* (nested `checklist` object, per-task integer ids, business_connection_id —
|
|
1389
|
+
* the old flat shape got `400: parameter "checklist" is required`). */
|
|
1390
|
+
async function rawSendChecklist(payload: Record<string, unknown>): Promise<{ message_id: number }> {
|
|
1391
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
|
|
1392
|
+
const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)(payload)
|
|
1414
1393
|
return { message_id: result.message_id }
|
|
1415
1394
|
}
|
|
1416
1395
|
|
|
1417
|
-
/**
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)({
|
|
1432
|
-
chat_id: Number(args.chat_id),
|
|
1433
|
-
message_id: Number(args.message_id),
|
|
1434
|
-
...(args.title != null ? { title: args.title } : {}),
|
|
1435
|
-
...(args.tasks != null
|
|
1436
|
-
? {
|
|
1437
|
-
tasks: args.tasks.map(t => ({
|
|
1438
|
-
...(t.id != null ? { id: Number(t.id) } : {}),
|
|
1439
|
-
...(t.text != null ? { text: t.text } : {}),
|
|
1440
|
-
...(t.done != null ? { is_completed: t.done } : {}),
|
|
1441
|
-
})),
|
|
1442
|
-
}
|
|
1443
|
-
: {}),
|
|
1444
|
-
})
|
|
1396
|
+
/** bot.api.raw.editMessageChecklist with a pre-built payload (the native edit REPLACES the whole checklist, so checklist-fallback.ts sends full state). */
|
|
1397
|
+
async function rawEditMessageChecklist(payload: Record<string, unknown>): Promise<void> {
|
|
1398
|
+
if (!CHECKLIST_API_AVAILABLE) throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
|
|
1399
|
+
await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)(payload)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Per-message checklist state — update_checklist re-renders from it (process-local; post-restart updates degrade gracefully). */
|
|
1403
|
+
const checklistStore = createChecklistStore()
|
|
1404
|
+
|
|
1405
|
+
/** Native checklists are Telegram-Business-only (sendChecklist REQUIRES a business_connection_id).
|
|
1406
|
+
* No fleet config plumbing exists yet; this env var is the opt-in — unset (the normal case) means text fallback. */
|
|
1407
|
+
function resolveBusinessConnectionId(): string | undefined {
|
|
1408
|
+
const v = process.env.SWITCHROOM_TELEGRAM_BUSINESS_CONNECTION_ID?.trim()
|
|
1409
|
+
return v ? v : undefined
|
|
1445
1410
|
}
|
|
1446
1411
|
|
|
1447
1412
|
const chatLock = createChatLock()
|
|
@@ -5038,7 +5003,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
|
|
|
5038
5003
|
startedAt: turn.startedAt,
|
|
5039
5004
|
toolCallCount: turn.toolCallCount ?? 0,
|
|
5040
5005
|
turnId: turn.turnId,
|
|
5041
|
-
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
5006
|
+
finalAnswerDelivered: turn.finalAnswerDelivered, replyCalled: turn.replyCalled, // replyCalled: honest delivery-route signal (turn-record-status.ts computeTurnRoute)
|
|
5042
5007
|
deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
|
|
5043
5008
|
},
|
|
5044
5009
|
endedAt,
|
|
@@ -12083,17 +12048,35 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
12083
12048
|
(t) => redactOutboundText(t, 'send_checklist'),
|
|
12084
12049
|
)
|
|
12085
12050
|
|
|
12086
|
-
|
|
12087
|
-
|
|
12088
|
-
|
|
12089
|
-
|
|
12051
|
+
// Graceful degradation: native sendChecklist is Business-account-only, so
|
|
12052
|
+
// ordinary chats (the fleet norm) get a formatted text render instead of a
|
|
12053
|
+
// raw Telegram 400 (rationale + orchestration in checklist-fallback.ts).
|
|
12054
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12055
|
+
const sendOpts = {
|
|
12090
12056
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
12091
|
-
...(replyTo != null ? {
|
|
12057
|
+
...(replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}),
|
|
12092
12058
|
...(protectContent ? { protect_content: true } : {}),
|
|
12093
|
-
}
|
|
12059
|
+
}
|
|
12060
|
+
const result = await performSendChecklist({
|
|
12061
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12062
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12063
|
+
sendNative: rawSendChecklist,
|
|
12064
|
+
sendText: (text) => robustApiCall(
|
|
12065
|
+
(): Promise<{ message_id: number }> =>
|
|
12066
|
+
literal
|
|
12067
|
+
// allow-raw-bot-api: literal checklist text-fallback send routed through robustApiCall
|
|
12068
|
+
? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
|
|
12069
|
+
// allow-raw-bot-api: checklist text-fallback send routed through robustApiCall
|
|
12070
|
+
: lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
|
|
12071
|
+
{ verb: 'sendMessage', chat_id, threadId },
|
|
12072
|
+
),
|
|
12073
|
+
literalText: literal,
|
|
12074
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12075
|
+
}, { title: redactedTitle!, tasks: redactedTasks!, chatId: Number(chat_id), replyToMessageId: replyTo, protectContent })
|
|
12094
12076
|
|
|
12095
|
-
|
|
12096
|
-
|
|
12077
|
+
checklistStore.set(checklistStoreKey(chat_id, result.message_id), result.state)
|
|
12078
|
+
process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${result.message_id} tasks=${tasks.length} mode=${result.mode}\n`)
|
|
12079
|
+
return { content: [{ type: 'text', text: sendChecklistToolText(result) }] }
|
|
12097
12080
|
}
|
|
12098
12081
|
|
|
12099
12082
|
/**
|
|
@@ -12180,10 +12163,27 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
12180
12163
|
(t) => redactOutboundText(t, 'update_checklist'),
|
|
12181
12164
|
)
|
|
12182
12165
|
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12166
|
+
// Graceful degradation (checklist-fallback.ts): the patch is applied to the
|
|
12167
|
+
// stored per-message state and the message re-rendered in the mode it was
|
|
12168
|
+
// sent in. Failures come back structured, never as a raw Telegram 400.
|
|
12169
|
+
const literal = (loadAccess().parseMode ?? 'html') === 'text'
|
|
12170
|
+
const key = checklistStoreKey(chat_id, message_id)
|
|
12171
|
+
const result = await performUpdateChecklist({
|
|
12172
|
+
state: checklistStore.get(key),
|
|
12173
|
+
businessConnectionId: resolveBusinessConnectionId(),
|
|
12174
|
+
nativeAvailable: CHECKLIST_API_AVAILABLE,
|
|
12175
|
+
editNative: rawEditMessageChecklist,
|
|
12176
|
+
// allow-raw-bot-api: checklist text-fallback edit routed through robustApiCall
|
|
12177
|
+
editText: async (text) => { await robustApiCall(() => lockedBot.api.editMessageText(chat_id, Number(message_id), literal ? text : richMessage(text))) },
|
|
12178
|
+
literalText: literal,
|
|
12179
|
+
log: (l) => process.stderr.write(`telegram gateway: ${l}`),
|
|
12180
|
+
chatId: Number(chat_id),
|
|
12181
|
+
messageId: Number(message_id),
|
|
12182
|
+
}, { title: redactedTitle, tasks: redactedTasks })
|
|
12183
|
+
|
|
12184
|
+
if (result.ok) checklistStore.set(key, result.state)
|
|
12185
|
+
process.stderr.write(`telegram gateway: update_checklist: ${result.ok ? `updated mode=${result.mode}` : `failed reason=${result.reason}`} chatId=${chat_id} messageId=${message_id}\n`)
|
|
12186
|
+
return { content: [{ type: 'text', text: updateChecklistToolText(result, Number(message_id)) }] }
|
|
12187
12187
|
}
|
|
12188
12188
|
|
|
12189
12189
|
/**
|
|
@@ -27,6 +27,38 @@ export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
|
|
|
27
27
|
/** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
|
|
28
28
|
export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How this turn's answer actually reached (or failed to reach) the user — the
|
|
32
|
+
* honest delivery route, recorded alongside `status` so the fleet-health
|
|
33
|
+
* detector can tell a flush-recovered turn apart from a genuine silent no-op.
|
|
34
|
+
*
|
|
35
|
+
* - `reply` — the reply tool delivered the answer (the normal path), OR the
|
|
36
|
+
* flush short-circuited because reply had already delivered.
|
|
37
|
+
* - `stream` — the answer landed via the streaming/draft path without a reply
|
|
38
|
+
* tool call (final answer delivered, `replyCalled` false).
|
|
39
|
+
* - `flush` — a turn-flush / outbox-sweep backstop delivered the answer after
|
|
40
|
+
* the reply tool was bypassed (terminal prose, `tools:0`). This is
|
|
41
|
+
* the case that used to masquerade as a silent no-op.
|
|
42
|
+
* - `none` — nothing reached the user (send failed, or a genuine no-reply).
|
|
43
|
+
*/
|
|
44
|
+
export type TurnRoute = 'reply' | 'stream' | 'flush' | 'none'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Epoch of when the honest `route` field shipped on turns.jsonl rows — a FIXED
|
|
48
|
+
* literal cutoff the fleet-health detector uses to age out the pre-route legacy
|
|
49
|
+
* backlog (rows written before this field existed carry no `route`, so the
|
|
50
|
+
* detector cannot classify them and must not escalate them as silent no-ops).
|
|
51
|
+
*
|
|
52
|
+
* UNIX SECONDS, not milliseconds: turns.jsonl `ts` is written as
|
|
53
|
+
* `Math.floor(endedAt / 1000)` (see `buildTurnRecord`), and the detector
|
|
54
|
+
* compares this constant against that seconds-valued `ts`. It intentionally
|
|
55
|
+
* mirrors the units of `SILENT_NOOP_FLOOR_TS` in `src/fleet-health/detect.ts`.
|
|
56
|
+
*
|
|
57
|
+
* Value = 2026-07-31T00:00:00Z (`date -u -d @1785456000`). Fixed, not rolling:
|
|
58
|
+
* a rolling window would hide a genuine ongoing regression that drops the field.
|
|
59
|
+
*/
|
|
60
|
+
export const ROUTE_FIELD_SHIP_TS = 1_785_456_000
|
|
61
|
+
|
|
30
62
|
/**
|
|
31
63
|
* Derive the recorded turn status from the turn's flags.
|
|
32
64
|
*
|
|
@@ -58,6 +90,41 @@ export function computeTurnStatus(turn: {
|
|
|
58
90
|
}
|
|
59
91
|
}
|
|
60
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Derive the honest delivery `route` from the SAME resolved delivery state
|
|
95
|
+
* `computeTurnStatus` reads — never speculative. `deliveryOutcome` (when
|
|
96
|
+
* present) is authoritative; when absent we fall back to the legacy
|
|
97
|
+
* `finalAnswerDelivered` / `replyCalled` reading, exactly as `computeTurnStatus`
|
|
98
|
+
* does for `status`.
|
|
99
|
+
*
|
|
100
|
+
* failed → none (nothing reached the user)
|
|
101
|
+
* delivered → flush (a backstop send delivered the answer)
|
|
102
|
+
* suppressed → reply (flush short-circuited; reply already delivered)
|
|
103
|
+
* undefined (legacy) → finalAnswerDelivered
|
|
104
|
+
* ? (replyCalled ? reply : stream)
|
|
105
|
+
* : none
|
|
106
|
+
*/
|
|
107
|
+
export function computeTurnRoute(turn: {
|
|
108
|
+
finalAnswerDelivered: boolean
|
|
109
|
+
replyCalled: boolean
|
|
110
|
+
deliveryOutcome?: DeliveryOutcome
|
|
111
|
+
}): TurnRoute {
|
|
112
|
+
switch (turn.deliveryOutcome) {
|
|
113
|
+
case 'failed':
|
|
114
|
+
return 'none'
|
|
115
|
+
case 'delivered':
|
|
116
|
+
return 'flush'
|
|
117
|
+
case 'suppressed':
|
|
118
|
+
return 'reply'
|
|
119
|
+
default:
|
|
120
|
+
return turn.finalAnswerDelivered
|
|
121
|
+
? turn.replyCalled
|
|
122
|
+
? 'reply'
|
|
123
|
+
: 'stream'
|
|
124
|
+
: 'none'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
61
128
|
/**
|
|
62
129
|
* Resolve a backstop send's outcome from what actually happened on the wire.
|
|
63
130
|
* A throw is a failure; a no-throw send that delivered fewer chunks than it
|
|
@@ -148,6 +215,13 @@ export interface TurnRecordRow {
|
|
|
148
215
|
tools: number
|
|
149
216
|
status: TurnStatus
|
|
150
217
|
turn_id: string
|
|
218
|
+
/**
|
|
219
|
+
* The honest delivery route for this turn (see `TurnRoute`). Recorded on every
|
|
220
|
+
* row so the fleet-health detector can distinguish a flush-recovered turn
|
|
221
|
+
* (`route: 'flush'`, answer delivered by a backstop after the reply tool was
|
|
222
|
+
* bypassed) from a genuine silent no-op (`route: 'none'`). Always emitted.
|
|
223
|
+
*/
|
|
224
|
+
route: TurnRoute
|
|
151
225
|
/**
|
|
152
226
|
* How many landed message ids of this turn's backstop delivery the read-back
|
|
153
227
|
* probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
|
|
@@ -178,6 +252,7 @@ export function buildTurnRecord(
|
|
|
178
252
|
toolCallCount: number
|
|
179
253
|
turnId: string
|
|
180
254
|
finalAnswerDelivered: boolean
|
|
255
|
+
replyCalled?: boolean
|
|
181
256
|
deliveryOutcome?: DeliveryOutcome
|
|
182
257
|
landedUnconfirmed?: number
|
|
183
258
|
},
|
|
@@ -190,6 +265,11 @@ export function buildTurnRecord(
|
|
|
190
265
|
tools: turn.toolCallCount ?? 0,
|
|
191
266
|
status: computeTurnStatus(turn),
|
|
192
267
|
turn_id: turn.turnId,
|
|
268
|
+
route: computeTurnRoute({
|
|
269
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
270
|
+
replyCalled: turn.replyCalled ?? false,
|
|
271
|
+
deliveryOutcome: turn.deliveryOutcome,
|
|
272
|
+
}),
|
|
193
273
|
// Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
|
|
194
274
|
...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
|
|
195
275
|
? { landed_unconfirmed: turn.landedUnconfirmed }
|