switchroom 0.19.14 → 0.19.15

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.
Files changed (31) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1002 -504
  7. package/telegram-plugin/dist/server.js +1 -1
  8. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  9. package/telegram-plugin/gateway/gateway.ts +4 -0
  10. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  11. package/telegram-plugin/gateway/outbox-sweep.ts +32 -2
  12. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  13. package/telegram-plugin/gateway/stream-render.ts +107 -15
  14. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  15. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  16. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  17. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  18. package/telegram-plugin/narrative-flush.ts +35 -0
  19. package/telegram-plugin/outbox.ts +73 -3
  20. package/telegram-plugin/shown-ledger.ts +145 -0
  21. package/telegram-plugin/silent-end.ts +42 -0
  22. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  23. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  24. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  25. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  26. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  27. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  29. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  30. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  31. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -247,6 +247,25 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
247
247
  expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
248
248
  })
249
249
 
250
+ it('channel origin emits forwarded_message_id so the agent can deep-link the source post (G2)', () => {
251
+ const meta = buildForwardOriginMeta([
252
+ { name: 'Release Notes (@relnotes)', type: 'channel', id: -100400500, date: DATE, messageId: 555 },
253
+ ])
254
+ expect(Object.keys(meta)).toEqual([
255
+ 'forwarded_from',
256
+ 'forwarded_from_type',
257
+ 'forwarded_from_id',
258
+ 'forwarded_date',
259
+ 'forwarded_message_id',
260
+ ])
261
+ expect(meta.forwarded_message_id).toBe('555')
262
+ })
263
+
264
+ it('non-channel origins (no messageId) omit forwarded_message_id entirely', () => {
265
+ const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
266
+ expect(meta.forwarded_message_id).toBeUndefined()
267
+ })
268
+
250
269
  it('no origins → empty record (no attrs on a normal message)', () => {
251
270
  expect(buildForwardOriginMeta([])).toEqual({})
252
271
  })
@@ -305,6 +324,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
305
324
  'forwarded_from_type_2',
306
325
  'forwarded_from_id_2',
307
326
  'forwarded_date_2',
327
+ 'forwarded_message_id_2',
308
328
  ])
309
329
  expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
310
330
  expect(meta.forwarded_from_type_2).toBe('channel')
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Outcome regression: forwarded-message body must reach the agent — never a
3
+ * placeholder (carrie history.db row message_id=944, 2026-07-24 06:03).
4
+ *
5
+ * THE BUG: a forwarded BOT message arrives as Bot API 10.1 `rich_message`
6
+ * content (the gateway sends everything via sendRichMessage) with NO
7
+ * top-level text/caption. Live gateway log (carrie, update_id=417526125):
8
+ * content_keys=[forward_from,forward_date,rich_message] action=turn
9
+ * It matched no registered `message:*` handler, fell to the terminal
10
+ * catch-all, and the agent received
11
+ * "(unhandled message content: forward_from)"
12
+ * instead of the forwarded token list. Two defects composed:
13
+ * 1. no `message:rich_message` registration (grammy ^1.44 supports it);
14
+ * 2. legacy `forward_*` wire keys were not in MESSAGE_ENVELOPE_KEYS, so
15
+ * the placeholder was mislabeled with provenance, not content.
16
+ *
17
+ * These tests drive a REAL grammy 1.44 Bot through the REAL production
18
+ * modules (rich-message-handler + unhandled-message catch-all) in the
19
+ * gateway's registration order and assert the DELIVERED text — the outcome
20
+ * the agent actually receives. The key regression test was verified to FAIL
21
+ * on unfixed main (delivered text was the placeholder) and pass here.
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach } from 'vitest'
25
+ import { Bot, type Context } from 'grammy'
26
+ import type { Update } from 'grammy/types'
27
+ import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
28
+ import { installUnhandledMessageCatchAll } from '../gateway/unhandled-message.js'
29
+ import {
30
+ handleRichMessageMessage,
31
+ extractRichMessageText,
32
+ RICH_MESSAGE_EMPTY_TEXT,
33
+ } from '../gateway/rich-message-handler.js'
34
+ import type { MediaEnvelopeDeps } from '../gateway/media-message-handlers.js'
35
+ import { parseForwardOrigin, buildForwardOriginMeta } from '../gateway/forward-origin.js'
36
+
37
+ const UNHANDLED_PLACEHOLDER_RE = /^\(unhandled message content: /
38
+
39
+ interface Delivered {
40
+ via: string
41
+ text: string
42
+ }
43
+
44
+ /** Wire a real grammy Bot in the gateway's registration order: message:text,
45
+ * message:rich_message (real production handler), terminal catch-all LAST. */
46
+ function buildHarness() {
47
+ const delivered: Delivered[] = []
48
+ const logLines: string[] = []
49
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
50
+ bot.botInfo = {
51
+ id: 999,
52
+ is_bot: true,
53
+ first_name: 'TestBot',
54
+ username: 'test_bot',
55
+ can_join_groups: true,
56
+ can_read_all_group_messages: false,
57
+ supports_inline_queries: false,
58
+ can_connect_to_business: false,
59
+ has_main_web_app: false,
60
+ }
61
+
62
+ bot.on('message:text', async ctx => {
63
+ delivered.push({ via: 'text', text: ctx.message.text })
64
+ })
65
+ const richDeps: MediaEnvelopeDeps = {
66
+ handleInbound: async (_ctx, text) => {
67
+ delivered.push({ via: 'rich_message', text })
68
+ },
69
+ handleAckOnly: async () => {},
70
+ handleRefusal: async () => {},
71
+ log: line => logLines.push(line),
72
+ }
73
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, richDeps))
74
+ installUnhandledMessageCatchAll(
75
+ bot,
76
+ async (_ctx: Context, text: string) => {
77
+ delivered.push({ via: 'catch-all', text })
78
+ },
79
+ line => logLines.push(line),
80
+ )
81
+ return { bot, delivered, logLines }
82
+ }
83
+
84
+ /** The forwarded-bot-message shape observed live (update_id=417526125):
85
+ * forward_origin (modern) + legacy forward_from/forward_date siblings +
86
+ * rich_message content, NO top-level text/caption. */
87
+ function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
88
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
89
+ return {
90
+ update_id,
91
+ message: {
92
+ message_id: 944,
93
+ chat: { id: -1004223464247, type: 'supergroup', title: 'ProductOS', is_forum: true },
94
+ from: { id: 777, is_bot: false, first_name: 'Ken' },
95
+ date: 1784837003,
96
+ forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
97
+ forward_from: originUser,
98
+ forward_date: 1784830000,
99
+ rich_message: { blocks },
100
+ },
101
+ } as unknown as Update
102
+ }
103
+
104
+ const TOKEN_LIST_BLOCKS = [
105
+ { type: 'paragraph', text: 'Try these tokens' },
106
+ {
107
+ type: 'list',
108
+ items: [
109
+ { label: '-', blocks: [{ type: 'paragraph', text: [{ type: 'code', text: 'tok_alpha_123' }] }] },
110
+ { label: '-', blocks: [{ type: 'paragraph', text: [{ type: 'code', text: 'tok_beta_456' }] }] },
111
+ ],
112
+ },
113
+ ]
114
+
115
+ describe('forwarded rich (bot) message — the row-944 regression oracle', () => {
116
+ beforeEach(() => resetUpdateCounters())
117
+
118
+ it('delivers the REAL forwarded body — not the unhandled placeholder, not empty', async () => {
119
+ const { bot, delivered } = buildHarness()
120
+ await bot.handleUpdate(makeForwardedRichUpdate(417526125, TOKEN_LIST_BLOCKS))
121
+
122
+ expect(delivered).toHaveLength(1)
123
+ const turn = delivered[0]
124
+ // Bug-catcher oracle: real content in, real content out.
125
+ expect(turn.text.length).toBeGreaterThan(0)
126
+ expect(turn.text).not.toMatch(UNHANDLED_PLACEHOLDER_RE)
127
+ expect(turn.text).not.toContain('forward_from')
128
+ // The actual forwarded body the agent must receive.
129
+ expect(turn.text).toContain('Try these tokens')
130
+ expect(turn.text).toContain('tok_alpha_123')
131
+ expect(turn.text).toContain('tok_beta_456')
132
+ })
133
+
134
+ it('forward-origin metadata CO-ARRIVES on the trusted attr lane (not injected into the body)', async () => {
135
+ const { bot, delivered } = buildHarness()
136
+ const update = makeForwardedRichUpdate(417526126, TOKEN_LIST_BLOCKS)
137
+ await bot.handleUpdate(update)
138
+
139
+ // The same server-stamped forward_origin the enqueue path parses
140
+ // (gateway.ts → parseForwardOrigin → buildForwardOriginMeta → meta attrs).
141
+ const origin = parseForwardOrigin(
142
+ (update as unknown as { message: { forward_origin: never } }).message.forward_origin,
143
+ )
144
+ const meta = buildForwardOriginMeta([origin!])
145
+ expect(meta.forwarded_from).toBe('Klanker (@meken_klanker_bot)')
146
+ expect(meta.forwarded_from_type).toBe('user')
147
+ expect(meta.forwarded_date).toBeDefined()
148
+ // Trusted-lane separation (#3162): the delivered BODY carries no
149
+ // provenance strings — those live only in the channel attrs.
150
+ expect(delivered[0].text).not.toContain('Klanker')
151
+ })
152
+
153
+ it('a rich message with genuinely no extractable text still yields an honest turn', async () => {
154
+ const { bot, delivered } = buildHarness()
155
+ await bot.handleUpdate(
156
+ makeForwardedRichUpdate(417526127, [{ type: 'divider' }, { type: 'anchor', name: 'top' }]),
157
+ )
158
+ expect(delivered).toHaveLength(1)
159
+ // Divider renders as ---; a fully empty tree gets the honest fallback.
160
+ expect(delivered[0].via).toBe('rich_message')
161
+ expect(delivered[0].text).not.toMatch(UNHANDLED_PLACEHOLDER_RE)
162
+ })
163
+
164
+ it('a truly empty rich message delivers the named fallback, never the mislabeled placeholder', async () => {
165
+ const { bot, delivered } = buildHarness()
166
+ await bot.handleUpdate(makeForwardedRichUpdate(417526128, []))
167
+ expect(delivered).toHaveLength(1)
168
+ expect(delivered[0].text).toBe(RICH_MESSAGE_EMPTY_TEXT)
169
+ })
170
+
171
+ it('a NON-forwarded rich message (bot-to-bot, rich DM) also delivers its body', async () => {
172
+ const { bot, delivered } = buildHarness()
173
+ const update = {
174
+ update_id: 417526129,
175
+ message: {
176
+ message_id: 950,
177
+ chat: { id: 777, type: 'private' },
178
+ from: { id: 777, is_bot: false, first_name: 'Ken' },
179
+ date: 1784837100,
180
+ rich_message: { blocks: [{ type: 'heading', size: 2, text: 'Release plan' }, { type: 'paragraph', text: 'Ship Friday.' }] },
181
+ },
182
+ } as unknown as Update
183
+ await bot.handleUpdate(update)
184
+ expect(delivered[0].text).toContain('Release plan')
185
+ expect(delivered[0].text).toContain('Ship Friday.')
186
+ })
187
+ })
188
+
189
+ describe('working shapes stay unchanged (no regression on existing forwards)', () => {
190
+ beforeEach(() => resetUpdateCounters())
191
+
192
+ it('plain user-text forward: full body via message:text + co-arriving origin meta', async () => {
193
+ const { bot, delivered } = buildHarness()
194
+ const update = makeMessageUpdate({ text: 'here is the brief I forwarded', update_id: 7001 })
195
+ const msg = (update as unknown as { message: Record<string, unknown> }).message
196
+ msg.forward_origin = {
197
+ type: 'user',
198
+ date: 1_700_000_000,
199
+ sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
200
+ }
201
+ await bot.handleUpdate(update)
202
+
203
+ expect(delivered).toHaveLength(1)
204
+ expect(delivered[0]).toMatchObject({ via: 'text', text: 'here is the brief I forwarded' })
205
+ const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
206
+ expect(meta.forwarded_from).toBe('Ken Thompson')
207
+ expect(meta.forwarded_date).toBeDefined()
208
+ })
209
+
210
+ it('hidden_user forward: full body, type=hidden_user, NO forwarded_from_id', async () => {
211
+ const { bot, delivered } = buildHarness()
212
+ const update = makeMessageUpdate({ text: 'anon tip: check the logs', update_id: 7002 })
213
+ const msg = (update as unknown as { message: Record<string, unknown> }).message
214
+ msg.forward_origin = { type: 'hidden_user', date: 1_700_000_000, sender_user_name: 'Mystery Sender' }
215
+ await bot.handleUpdate(update)
216
+
217
+ expect(delivered[0].text).toBe('anon tip: check the logs')
218
+ const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
219
+ expect(meta.forwarded_from).toBe('Mystery Sender')
220
+ expect(meta.forwarded_from_type).toBe('hidden_user')
221
+ expect(meta.forwarded_from_id).toBeUndefined()
222
+ })
223
+
224
+ it('channel caption/media forward: caption is the body and forwarded_message_id deep-links the post (G2)', () => {
225
+ // The photo handler's body contract is `caption ?? '(photo)'`
226
+ // (photo-message-handler.ts) — assert the origin-meta side here.
227
+ const origin = parseForwardOrigin({
228
+ type: 'channel',
229
+ date: 1_700_000_000,
230
+ message_id: 555,
231
+ chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
232
+ })
233
+ const meta = buildForwardOriginMeta([origin!])
234
+ expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
235
+ expect(meta.forwarded_from_type).toBe('channel')
236
+ expect(meta.forwarded_message_id).toBe('555')
237
+ })
238
+ })
239
+
240
+ describe('rich block rendering — deterministic text extraction', () => {
241
+ it('renders headings, lists with checkboxes, pre blocks, quotes, and tables', () => {
242
+ const text = extractRichMessageText({
243
+ blocks: [
244
+ { type: 'heading', size: 1, text: 'Plan' },
245
+ {
246
+ type: 'list',
247
+ items: [
248
+ { label: '1.', blocks: [{ type: 'paragraph', text: 'first' }], has_checkbox: true, is_checked: true },
249
+ { label: '2.', blocks: [{ type: 'paragraph', text: 'second' }], has_checkbox: true },
250
+ ],
251
+ },
252
+ { type: 'pre', language: 'python', text: 'print(1)' },
253
+ { type: 'blockquote', blocks: [{ type: 'paragraph', text: 'quoted line' }], credit: 'someone' },
254
+ { type: 'table', cells: [[{ text: 'A', align: 'left', valign: 'top' }, { text: 'B', align: 'left', valign: 'top' }]] },
255
+ { type: 'photo', photo: [], caption: { text: 'the screenshot' } },
256
+ ],
257
+ })
258
+ expect(text).toContain('Plan')
259
+ expect(text).toContain('1. [x] first')
260
+ expect(text).toContain('2. [ ] second')
261
+ expect(text).toContain('```python\nprint(1)\n```')
262
+ expect(text).toContain('> quoted line')
263
+ expect(text).toContain('> — someone')
264
+ expect(text).toContain('A | B')
265
+ expect(text).toContain('[photo] the screenshot')
266
+ })
267
+
268
+ it('flattens nested inline rich text (bold/url/custom emoji/math) to content', () => {
269
+ const text = extractRichMessageText({
270
+ blocks: [{
271
+ type: 'paragraph',
272
+ text: [
273
+ 'see ',
274
+ { type: 'bold', text: [{ type: 'url', text: 'the docs', url: 'https://x' }] },
275
+ ' ',
276
+ { type: 'custom_emoji', custom_emoji_id: '1', alternative_text: '👍' },
277
+ ' ',
278
+ { type: 'mathematical_expression', expression: 'E=mc^2' },
279
+ ],
280
+ }],
281
+ })
282
+ expect(text).toBe('see the docs 👍 E=mc^2')
283
+ })
284
+
285
+ it('an unknown future block type surfaces its text instead of vanishing', () => {
286
+ expect(extractRichMessageText({ blocks: [{ type: 'hologram', text: 'future words' }] }))
287
+ .toBe('future words')
288
+ })
289
+
290
+ it('malformed / hostile payloads return undefined instead of throwing', () => {
291
+ expect(extractRichMessageText(undefined)).toBeUndefined()
292
+ expect(extractRichMessageText('nope')).toBeUndefined()
293
+ expect(extractRichMessageText({ blocks: 'nope' })).toBeUndefined()
294
+ // Deep self-nesting stops at the recursion cap, no stack overflow.
295
+ const deep: { type: string; blocks: unknown[] } = { type: 'blockquote', blocks: [] }
296
+ let cur = deep
297
+ for (let i = 0; i < 200; i++) {
298
+ const next = { type: 'blockquote', blocks: [] as unknown[] }
299
+ cur.blocks.push(next)
300
+ cur = next
301
+ }
302
+ cur.blocks.push({ type: 'paragraph', text: 'buried' })
303
+ expect(() => extractRichMessageText({ blocks: [deep] })).not.toThrow()
304
+ })
305
+ })
@@ -107,6 +107,7 @@ const GOLDEN_REGISTRATIONS: readonly string[] = [
107
107
  'on:message:checklist_tasks_done',
108
108
  'on:message:checklist_tasks_added',
109
109
  'on:message:pinned_message',
110
+ 'on:message:rich_message',
110
111
  'helper:installUnhandledMessageCatchAll',
111
112
  'on:message_reaction',
112
113
  'catch',