switchroom 0.21.0 → 0.21.3
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/auth-broker/index.js +54 -34
- package/dist/cli/switchroom.js +765 -367
- package/dist/host-control/main.js +8 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +174 -18
- package/telegram-plugin/external-spend.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +8 -0
- package/telegram-plugin/gateway/system-message-observer.ts +189 -13
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/card-history-lane.test.ts +121 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +11 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sent-text-capture.ts — the card-body FALLBACK: stamp a send's REQUEST body
|
|
3
|
+
* onto the `Message` Telegram returned for it (#4571 / #4576 follow-up).
|
|
4
|
+
*
|
|
5
|
+
* Read this first: it is NOT the primary source of the stored card body.
|
|
6
|
+
* -----------------------------------------------------------------------
|
|
7
|
+
* `system-message-observer.ts` takes the body off the RESPONSE — `rich_message`
|
|
8
|
+
* (Telegram's own rendered block tree) first, then `text` / `caption`. That is
|
|
9
|
+
* the more faithful source, and it covers every send verb the gateway writes a
|
|
10
|
+
* history row for. This module supplies the LAST tier of that precedence
|
|
11
|
+
* chain, for responses that carry no renderable body at all: a rich send whose
|
|
12
|
+
* blocks flatten to nothing (a media-only card), or a future verb whose
|
|
13
|
+
* response omits the body.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately last, because the body it captures is NOT the body as written.
|
|
16
|
+
* The dominant card path is `sendRichMessage(chat, richMessage(body))`, and
|
|
17
|
+
* `richMessage()` applies `guardAccidentalFormatting` in the CALLER
|
|
18
|
+
* (`rich-send.ts`), long before any transformer seam. So what this module sees
|
|
19
|
+
* on that path is already wire-escaped: `sent_text_capture.ts` arrives as
|
|
20
|
+
* `sent\_text\_capture.ts`, `$12.40` as `\$12.40`. Escaped-but-present beats
|
|
21
|
+
* empty, which is why the tier exists at all — but it must never win over a
|
|
22
|
+
* response that resolved those escapes.
|
|
23
|
+
*
|
|
24
|
+
* The bug this exists to backstop
|
|
25
|
+
* -------------------------------
|
|
26
|
+
* #4576 gave every gateway card a `role='system'` history row so a quote-reply
|
|
27
|
+
* to it resolves. The row carried the right `message_id`, chat, thread and
|
|
28
|
+
* `kind` — and an EMPTY `text`, on 100% of the rows, on every agent in the
|
|
29
|
+
* fleet. `resolveReplyToFromBuffer` only sets `reply_to_text` when the stored
|
|
30
|
+
* body is non-empty (`inbound-router.ts`), so the agent learned WHICH card was
|
|
31
|
+
* tapped and still could not see WHAT IT SAID. That was the entire point.
|
|
32
|
+
*
|
|
33
|
+
* Root cause: the observer read ONE body field off the response
|
|
34
|
+
* (`msg.text ?? msg.caption`), and every card goes out through Bot API 10.1
|
|
35
|
+
* `sendRichMessage`, whose response is a `Message.RichMessageMessage` — the
|
|
36
|
+
* body lives under `rich_message: { blocks }`, and `text` / `caption` are
|
|
37
|
+
* simply absent (`@grammyjs/types` `message.d.ts:94`, `:180`; Bot API
|
|
38
|
+
* `RichMessage.blocks` = "Content of the message"). So the extractor fell
|
|
39
|
+
* through to `''` every single time. Reading `rich_message` is the fix; this
|
|
40
|
+
* module is the belt to that pair of braces.
|
|
41
|
+
*
|
|
42
|
+
* The mechanism
|
|
43
|
+
* -------------
|
|
44
|
+
* Capture the body from the REQUEST at the one seam no outbound call can
|
|
45
|
+
* bypass: a grammY API transformer (`bot.api.config.use`) — the same seam
|
|
46
|
+
* `installRichMarkdownGuard` already uses, and the reason that guard is
|
|
47
|
+
* universal where `richMessage()` is not.
|
|
48
|
+
*
|
|
49
|
+
* The transformer sees the outbound payload AND the resolved response in one
|
|
50
|
+
* call, so it can pair them with zero bookkeeping: no id→text map, no eviction,
|
|
51
|
+
* no cross-call race. It stamps the body onto the returned `Message` under a
|
|
52
|
+
* non-enumerable, `Symbol.for`-keyed property, which:
|
|
53
|
+
* - survives grammY's `callApi` unwrapping — the transformer chain resolves
|
|
54
|
+
* with the raw `{ok, result}` envelope and `callApi` returns `data.result`
|
|
55
|
+
* BY REFERENCE (grammy 1.44.0 `out/core/client.js:95-99`), so the object
|
|
56
|
+
* the caller receives is the object we stamped;
|
|
57
|
+
* - is invisible to `JSON.stringify`, `Object.keys`, spreads and structural
|
|
58
|
+
* equality, so nothing that reads a `Message` today can observe it.
|
|
59
|
+
*
|
|
60
|
+
* Non-negotiable: this must never break the send it observes. Every step is
|
|
61
|
+
* defensive and the transformer's only unconditional act is `return prev(...)`.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type { Bot } from 'grammy'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The stamp key. `Symbol.for` (not a module-local `Symbol()`) deliberately:
|
|
68
|
+
* the plugin is consumed both from source and from a bundle, and a duplicated
|
|
69
|
+
* module instance would otherwise mint a second, non-matching symbol and
|
|
70
|
+
* silently reopen the exact hole this file closes.
|
|
71
|
+
*/
|
|
72
|
+
export const SENT_TEXT = Symbol.for('switchroom.telegram.sentText')
|
|
73
|
+
|
|
74
|
+
/** Depth cap for the rich-block walk — a hostile/odd payload cannot recurse us. */
|
|
75
|
+
const MAX_BLOCK_DEPTH = 8
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Best-effort readable text out of an OUTBOUND `InputRichMessage.blocks` array.
|
|
79
|
+
*
|
|
80
|
+
* Nothing in this repo builds `{ blocks }` today (every rich send goes through
|
|
81
|
+
* `richMessage()` → `{ markdown }`), so this is purely the guard against a
|
|
82
|
+
* future adopter silently re-emptying the card lane. Bounded, allocation-shy,
|
|
83
|
+
* never throws.
|
|
84
|
+
*/
|
|
85
|
+
function flattenInputRichBlocks(blocks: unknown, depth: number): string {
|
|
86
|
+
if (!Array.isArray(blocks) || depth > MAX_BLOCK_DEPTH) return ''
|
|
87
|
+
const parts: string[] = []
|
|
88
|
+
for (const block of blocks) {
|
|
89
|
+
if (block == null || typeof block !== 'object') continue
|
|
90
|
+
const b = block as Record<string, unknown>
|
|
91
|
+
for (const key of ['markdown', 'html', 'text', 'caption'] as const) {
|
|
92
|
+
const v = b[key]
|
|
93
|
+
if (typeof v === 'string' && v.length > 0) parts.push(v)
|
|
94
|
+
}
|
|
95
|
+
const nested = flattenInputRichBlocks(b.blocks, depth + 1)
|
|
96
|
+
if (nested.length > 0) parts.push(nested)
|
|
97
|
+
}
|
|
98
|
+
return parts.join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The body a Telegram API request is about to POST, or null when the payload
|
|
103
|
+
* carries no user-visible text (pins, deletes, reactions, `getUpdates`, …).
|
|
104
|
+
*
|
|
105
|
+
* Shape verified against grammy 1.44.0 `out/core/api.js` and the payload notes
|
|
106
|
+
* in `installRichMarkdownGuard`: `sendRichMessage` / rich `editMessageText`
|
|
107
|
+
* put the body at `payload.rich_message.markdown`, plain sends at
|
|
108
|
+
* `payload.text`, media sends at `payload.caption`. Pure.
|
|
109
|
+
*/
|
|
110
|
+
export function outboundPayloadText(payload: unknown): string | null {
|
|
111
|
+
if (payload == null || typeof payload !== 'object') return null
|
|
112
|
+
const p = payload as Record<string, unknown>
|
|
113
|
+
const rich = p.rich_message
|
|
114
|
+
if (rich != null && typeof rich === 'object') {
|
|
115
|
+
const r = rich as Record<string, unknown>
|
|
116
|
+
if (typeof r.markdown === 'string') return r.markdown
|
|
117
|
+
if (typeof r.html === 'string') return r.html
|
|
118
|
+
const flat = flattenInputRichBlocks(r.blocks, 0)
|
|
119
|
+
if (flat.length > 0) return flat
|
|
120
|
+
}
|
|
121
|
+
if (typeof p.text === 'string') return p.text
|
|
122
|
+
if (typeof p.caption === 'string') return p.caption
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stamp `text` onto a resolved API response envelope's `Message` result.
|
|
128
|
+
*
|
|
129
|
+
* Takes the raw `{ok, result}` envelope (what a transformer sees), not the
|
|
130
|
+
* unwrapped message, and no-ops on anything that isn't a single Message —
|
|
131
|
+
* `true` (pins / dropped edits / `editMessageText` on an inline message),
|
|
132
|
+
* arrays, `{ok:false}` rejections. Never throws.
|
|
133
|
+
*/
|
|
134
|
+
export function attachSentText(envelope: unknown, text: string): void {
|
|
135
|
+
try {
|
|
136
|
+
if (envelope == null || typeof envelope !== 'object') return
|
|
137
|
+
const env = envelope as { ok?: unknown; result?: unknown }
|
|
138
|
+
if (env.ok !== true) return
|
|
139
|
+
const result = env.result
|
|
140
|
+
if (result == null || typeof result !== 'object' || Array.isArray(result)) return
|
|
141
|
+
Object.defineProperty(result, SENT_TEXT, {
|
|
142
|
+
value: text,
|
|
143
|
+
enumerable: false,
|
|
144
|
+
configurable: true,
|
|
145
|
+
writable: true,
|
|
146
|
+
})
|
|
147
|
+
} catch {
|
|
148
|
+
/* stamping must never break the send */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the body stamped by {@link installSentTextCapture} off a `Message`, or
|
|
154
|
+
* null when the message did not transit a capture-installed bot (a test double,
|
|
155
|
+
* a second Bot instance, a hand-built fixture). Pure.
|
|
156
|
+
*/
|
|
157
|
+
export function readSentText(message: unknown): string | null {
|
|
158
|
+
if (message == null || typeof message !== 'object') return null
|
|
159
|
+
const v = (message as Record<symbol, unknown>)[SENT_TEXT]
|
|
160
|
+
return typeof v === 'string' ? v : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Install the capture transformer on the production Bot.
|
|
165
|
+
*
|
|
166
|
+
* Install it AFTER `installRichMarkdownGuard` so it composes OUTSIDE the guard
|
|
167
|
+
* (grammY's last-installed transformer runs first) and therefore captures the
|
|
168
|
+
* payload before the guard's backslash escapes are applied.
|
|
169
|
+
*
|
|
170
|
+
* Be precise about what that does and does not buy. It only avoids the
|
|
171
|
+
* TRANSFORMER's escaping pass, which matters for the call sites that build a
|
|
172
|
+
* raw `{ markdown }` and go straight to `sendRichMessage` / `editMessageText`
|
|
173
|
+
* (banners, approval and folder-picker edits — see `richMessage()`'s docblock
|
|
174
|
+
* for the list). It does NOT recover a pre-escape body for the dominant path,
|
|
175
|
+
* because `richMessage()` escapes in the CALLER, upstream of every transformer.
|
|
176
|
+
* There is no seam that can. That is precisely why this capture is the LAST
|
|
177
|
+
* tier of the observer's precedence chain rather than the first.
|
|
178
|
+
*/
|
|
179
|
+
export function installSentTextCapture(bot: Bot): void {
|
|
180
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
181
|
+
let text: string | null = null
|
|
182
|
+
try {
|
|
183
|
+
text = outboundPayloadText(payload)
|
|
184
|
+
} catch {
|
|
185
|
+
text = null
|
|
186
|
+
}
|
|
187
|
+
const res = await prev(method, payload, signal)
|
|
188
|
+
if (text != null) attachSentText(res, text)
|
|
189
|
+
return res
|
|
190
|
+
})
|
|
191
|
+
}
|
|
@@ -49,6 +49,10 @@ import {
|
|
|
49
49
|
type EnvelopeBuildParams,
|
|
50
50
|
} from '../gateway/inbound-router.js'
|
|
51
51
|
import { makeSystemMessageObserver } from '../gateway/system-message-observer.js'
|
|
52
|
+
import { Bot } from 'grammy'
|
|
53
|
+
import { installRichMarkdownGuard } from '../shared/bot-runtime.js'
|
|
54
|
+
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
55
|
+
import { richMessage } from '../rich-send.js'
|
|
52
56
|
|
|
53
57
|
const CHAT = '5550001'
|
|
54
58
|
const REPLY_TO_TEXT_MAX = 200
|
|
@@ -62,11 +66,58 @@ function makeObserver(now?: () => number) {
|
|
|
62
66
|
})
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* A Telegram Message response, as `robustApiCall` resolves with.
|
|
71
|
+
*
|
|
72
|
+
* NOTE (#4576): this PLAIN shape is what `sendMessage` returns. No CARD send
|
|
73
|
+
* uses it — cards go out via `sendRichMessage`, whose response carries no
|
|
74
|
+
* `text` at all. Tests that assert the card BODY must use `sentRichCard()`
|
|
75
|
+
* below; a hand-built `{ text }` fixture cannot fail on the empty-body bug.
|
|
76
|
+
*/
|
|
66
77
|
function sentMessage(messageId: number, text: string) {
|
|
67
78
|
return { message_id: messageId, chat: { id: Number(CHAT) }, text }
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The result of a REAL `sendRichMessage` through the production transformer
|
|
83
|
+
* stack (fmt guard + `installSentTextCapture`), transport stubbed to answer
|
|
84
|
+
* with the true `Message.RichMessageMessage` shape: `rich_message` blocks, and
|
|
85
|
+
* NO `text` / `caption`.
|
|
86
|
+
*
|
|
87
|
+
* This is the fixture the #4576 defect demanded: every `role='system'` row on
|
|
88
|
+
* every agent in the fleet had `length(text)=0`, because the lane's tests fed
|
|
89
|
+
* the observer a response shape production never produces.
|
|
90
|
+
*/
|
|
91
|
+
async function sentRichCard(messageId: number, body: string): Promise<unknown> {
|
|
92
|
+
const fakeFetch = (async () =>
|
|
93
|
+
({
|
|
94
|
+
ok: true,
|
|
95
|
+
status: 200,
|
|
96
|
+
json: async () => ({
|
|
97
|
+
ok: true,
|
|
98
|
+
result: {
|
|
99
|
+
message_id: messageId,
|
|
100
|
+
date: 0,
|
|
101
|
+
chat: { id: Number(CHAT), type: 'private' },
|
|
102
|
+
rich_message: { blocks: [{ type: 'paragraph', text: { text: body } }] },
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
}) as unknown as Response) as unknown as typeof fetch
|
|
106
|
+
|
|
107
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
108
|
+
botInfo: {
|
|
109
|
+
id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
|
|
110
|
+
can_join_groups: false, can_read_all_group_messages: false,
|
|
111
|
+
supports_inline_queries: false, can_connect_to_business: false,
|
|
112
|
+
has_main_web_app: false,
|
|
113
|
+
},
|
|
114
|
+
client: { fetch: fakeFetch },
|
|
115
|
+
})
|
|
116
|
+
installRichMarkdownGuard(bot)
|
|
117
|
+
installSentTextCapture(bot)
|
|
118
|
+
return bot.api.sendRichMessage(Number(CHAT), richMessage(body))
|
|
119
|
+
}
|
|
120
|
+
|
|
70
121
|
/** The reply-antecedent lookup as gateway.ts binds it (#4571: includeSystem). */
|
|
71
122
|
function boundLookup(messageId: number) {
|
|
72
123
|
return lookupMessageRoleAndText(CHAT, messageId, { includeSystem: true })
|
|
@@ -197,6 +248,75 @@ describe('a quote-reply to the live activity card is UNDERSTOOD, end to end', ()
|
|
|
197
248
|
})
|
|
198
249
|
})
|
|
199
250
|
|
|
251
|
+
describe('#4576: the stored card body is NOT empty — real sendRichMessage response', () => {
|
|
252
|
+
it('a rich card write leaves a non-empty row, and the quote-reply carries the body', async () => {
|
|
253
|
+
const observe = makeObserver()
|
|
254
|
+
const CARD_ID = 20395
|
|
255
|
+
const BODY = '⚙️ Working — reading history.ts, 3 tools'
|
|
256
|
+
|
|
257
|
+
// The exact production path: sendRichMessage → transformer stack → observer.
|
|
258
|
+
// Its response has NO `text` field, which is what made every fleet row empty.
|
|
259
|
+
const result = await sentRichCard(CARD_ID, BODY)
|
|
260
|
+
expect((result as { text?: unknown }).text).toBeUndefined()
|
|
261
|
+
observe(result, { chat_id: CHAT, verb: 'activity-summary.send' })
|
|
262
|
+
|
|
263
|
+
const row = lookupMessageRoleAndText(CHAT, CARD_ID, { includeSystem: true })
|
|
264
|
+
expect(row?.role).toBe('system')
|
|
265
|
+
expect(row?.kind).toBe('activity-summary')
|
|
266
|
+
// The assertion the shipped bug fails: a stored body, not ''.
|
|
267
|
+
expect(row?.text.length).toBeGreaterThan(0)
|
|
268
|
+
expect(row?.text).toBe(BODY)
|
|
269
|
+
|
|
270
|
+
const resolved = resolveReplyToFromBuffer({
|
|
271
|
+
replyToMessageId: CARD_ID,
|
|
272
|
+
replyToText: undefined,
|
|
273
|
+
replyToTextEscaped: undefined,
|
|
274
|
+
historyEnabled: true,
|
|
275
|
+
replyToTextMax: REPLY_TO_TEXT_MAX,
|
|
276
|
+
lookup: boundLookup,
|
|
277
|
+
})
|
|
278
|
+
expect(resolved.replyToText).toBe(BODY)
|
|
279
|
+
|
|
280
|
+
const msg = buildInboundEnvelope(
|
|
281
|
+
makeEnvelopeParams({
|
|
282
|
+
replyToMessageId: CARD_ID,
|
|
283
|
+
replyToTextEscaped: resolved.replyToTextEscaped,
|
|
284
|
+
replyToRole: resolved.replyToRole,
|
|
285
|
+
replyToKind: resolved.replyToKind,
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
expect(msg.meta?.reply_to_text).toBe(BODY)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('holds for every card verb observed on the live fleet, not just the activity card', async () => {
|
|
292
|
+
const observe = makeObserver()
|
|
293
|
+
// The `kind` values measured on live agents' history.db after v0.21.0.
|
|
294
|
+
const verbs: [string, number, string][] = [
|
|
295
|
+
['boot-card', 5082, '🟢 **overlord** is up — sonnet, 3 skills'],
|
|
296
|
+
['worker-feed', 20404, '🛠 Worker — scoping the card-persistence defect'],
|
|
297
|
+
['issues-card', 20405, '**3 open issues** — #4571, #4576, #4580'],
|
|
298
|
+
['rollout-status-post', 20402, 'Rolling v0.21.0 — 4/12 agents done'],
|
|
299
|
+
['quota-watch.fleet-roll', 20407, 'Quota 61% — resets 09:00'],
|
|
300
|
+
['permission_request', 20406, '**Approve** `docker restart switchroom-clerk`?'],
|
|
301
|
+
]
|
|
302
|
+
for (const [verb, id, body] of verbs) {
|
|
303
|
+
observe(await sentRichCard(id, body), { chat_id: CHAT, verb })
|
|
304
|
+
}
|
|
305
|
+
for (const [verb, id, body] of verbs) {
|
|
306
|
+
const row = lookupMessageRoleAndText(CHAT, id, { includeSystem: true })
|
|
307
|
+
expect(`${verb}:${row?.text.length ?? 0}`).not.toBe(`${verb}:0`)
|
|
308
|
+
expect(row?.text).toBe(body)
|
|
309
|
+
}
|
|
310
|
+
// No card row anywhere in the buffer is bodiless — the fleet-wide invariant
|
|
311
|
+
// the shipped release violated on 100% of its rows.
|
|
312
|
+
const cards = query({ chat_id: CHAT, limit: 100, include_system: true }).filter(
|
|
313
|
+
(r) => r.role === 'system',
|
|
314
|
+
)
|
|
315
|
+
expect(cards).toHaveLength(verbs.length)
|
|
316
|
+
expect(cards.filter((r) => r.text.length === 0)).toEqual([])
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
200
320
|
describe('the card lane does not pollute normal history reads', () => {
|
|
201
321
|
it('query() (get_recent_messages) lists the conversation, never the cards', () => {
|
|
202
322
|
const observe = makeObserver()
|