switchroom 0.19.14 → 0.19.16

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 (49) 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 +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Outcome regression (#3516 follow-up): a forwarded rich (bot) message MUST
3
+ * coalesce through the same sliding window as `message:text`.
4
+ *
5
+ * THE BUG: PR #3516 registered `message:rich_message` but wired it with
6
+ * `mediaEnvelopeDeps`, whose `handleInbound` is the BARE (non-coalescing)
7
+ * inbound function — the same surface the cluster-A media-envelope handlers
8
+ * (contact/location/poll/…) use. The sibling text/photo/voice/attachment
9
+ * handlers all route through `handleInboundCoalesced`. Result: a forwarded
10
+ * bot message arriving in the same coalesce window as another inbound
11
+ * message did NOT fold into one turn — it bypassed the window entirely,
12
+ * contradicting rich-message-handler.ts's own docstring ("hands it to the
13
+ * normal COALESCING inbound pipeline … identical to message:text").
14
+ *
15
+ * Two guards, both anchored to the real production code:
16
+ *
17
+ * 1. WIRING (source-parse, deterministic RED/GREEN): parse gateway.ts and
18
+ * assert the deps object passed to the `message:rich_message`
19
+ * registration binds `handleInbound` to `handleInboundCoalesced` (the
20
+ * coalescing entry point), the SAME path `message:text` takes. This
21
+ * guard is RED on the pre-fix wiring (`mediaEnvelopeDeps`, bare
22
+ * `handleInbound`) and GREEN after it.
23
+ *
24
+ * 2. OUTCOME (real grammy Bot + real handler + real coalescer): drive the
25
+ * production `handleRichMessageMessage` and `message:text` through one
26
+ * shared `createInboundCoalescer` (the exact module gateway.ts uses),
27
+ * and assert a rich message + a text message in the same window flush
28
+ * as ONE ordered turn — and that the bare (non-coalescing) surface the
29
+ * bug used would instead emit TWO turns.
30
+ */
31
+
32
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
33
+ import { readFileSync } from 'node:fs'
34
+ import { fileURLToPath } from 'node:url'
35
+ import { dirname, resolve } from 'node:path'
36
+ import ts from 'typescript'
37
+ import { Bot } from 'grammy'
38
+ import type { Update } from 'grammy/types'
39
+ import { resetUpdateCounters } from './update-factory.js'
40
+ import { handleRichMessageMessage } from '../gateway/rich-message-handler.js'
41
+ import type { MediaEnvelopeDeps } from '../gateway/media-message-handlers.js'
42
+ import {
43
+ createInboundCoalescer,
44
+ inboundCoalesceKey,
45
+ } from '../gateway/inbound-coalesce.js'
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Guard 1 — wiring source-parse
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url))
52
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
53
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
54
+ const sourceFile = ts.createSourceFile(
55
+ GATEWAY_PATH,
56
+ GATEWAY_SRC,
57
+ ts.ScriptTarget.Latest,
58
+ true,
59
+ ts.ScriptKind.TS,
60
+ )
61
+
62
+ /** The deps argument (arg 2) of `bot.on('message:rich_message', ctx =>
63
+ * handleRichMessageMessage(ctx, DEPS))` as an AST node, or null. */
64
+ function findRichMessageDepsArg(): ts.Expression | null {
65
+ let found: ts.Expression | null = null
66
+ const visit = (node: ts.Node): void => {
67
+ if (found) return
68
+ if (
69
+ ts.isCallExpression(node) &&
70
+ ts.isPropertyAccessExpression(node.expression) &&
71
+ ts.isIdentifier(node.expression.expression) &&
72
+ node.expression.expression.text === 'bot' &&
73
+ node.expression.name.text === 'on'
74
+ ) {
75
+ const arg0 = node.arguments[0]
76
+ const evName = arg0 && ts.isStringLiteralLike(arg0) ? arg0.text : null
77
+ if (evName === 'message:rich_message') {
78
+ // arg1 is `ctx => handleRichMessageMessage(ctx, DEPS)`
79
+ const arg1 = node.arguments[1]
80
+ if (arg1 && (ts.isArrowFunction(arg1) || ts.isFunctionExpression(arg1))) {
81
+ const body = arg1.body
82
+ const inner = !ts.isBlock(body) && ts.isCallExpression(body) ? body : undefined
83
+ if (inner && inner.arguments.length >= 2) found = inner.arguments[1]
84
+ }
85
+ }
86
+ }
87
+ ts.forEachChild(node, visit)
88
+ }
89
+ visit(sourceFile)
90
+ return found
91
+ }
92
+
93
+ /** Resolve the deps arg to its source text — inline object literal directly,
94
+ * or the initializer of the `const <name> = { … }` it names. */
95
+ function resolveDepsText(arg: ts.Expression): string | null {
96
+ if (ts.isObjectLiteralExpression(arg)) return arg.getText(sourceFile)
97
+ if (ts.isIdentifier(arg)) {
98
+ let text: string | null = null
99
+ const visit = (node: ts.Node): void => {
100
+ if (text) return
101
+ if (
102
+ ts.isVariableDeclaration(node) &&
103
+ ts.isIdentifier(node.name) &&
104
+ node.name.text === arg.text &&
105
+ node.initializer
106
+ ) {
107
+ text = node.initializer.getText(sourceFile)
108
+ }
109
+ ts.forEachChild(node, visit)
110
+ }
111
+ visit(sourceFile)
112
+ return text
113
+ }
114
+ return null
115
+ }
116
+
117
+ describe('wiring: message:rich_message dispatches through the coalescing path', () => {
118
+ it('the rich_message registration passes deps that bind handleInbound to handleInboundCoalesced', () => {
119
+ const depsArg = findRichMessageDepsArg()
120
+ expect(depsArg, 'could not locate the message:rich_message registration deps arg').not.toBeNull()
121
+
122
+ const depsText = resolveDepsText(depsArg as ts.Expression)
123
+ expect(depsText, 'could not resolve the rich_message deps source text').not.toBeNull()
124
+
125
+ // The coalescing entry point — the same one message:text routes through.
126
+ // Pre-fix the deps was `mediaEnvelopeDeps` (bare `handleInbound,`
127
+ // shorthand), so this assertion is RED on the bug and GREEN with the fix.
128
+ expect(depsText).toContain('handleInbound: handleInboundCoalesced')
129
+ })
130
+ })
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Guard 2 — outcome: real handler + real coalescer
134
+ //
135
+ // NOTE: Guard 2 drives its OWN `richCoalesces` boolean toggle rather than
136
+ // importing the gateway's dispatch wiring, so it validates the coalescer
137
+ // CONTRACT (folding/ordering into one turn vs. the pre-fix split) — it is NOT
138
+ // a wiring regression guard. Guard 1 (the AST/source-parse over gateway.ts) is
139
+ // the SOLE guard that regresses if the rich_message registration stops routing
140
+ // through `handleInboundCoalesced`.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const CHAT_ID = -1004223464247
144
+ const USER_ID = 777
145
+
146
+ function makeBotInfo() {
147
+ return {
148
+ id: 999,
149
+ is_bot: true,
150
+ first_name: 'TestBot',
151
+ username: 'test_bot',
152
+ can_join_groups: true,
153
+ can_read_all_group_messages: false,
154
+ supports_inline_queries: false,
155
+ can_connect_to_business: false,
156
+ has_main_web_app: false,
157
+ }
158
+ }
159
+
160
+ /** Forwarded rich (bot) message, no top-level text/caption — the live shape. */
161
+ function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
162
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
163
+ return {
164
+ update_id,
165
+ message: {
166
+ message_id: update_id,
167
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
168
+ from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
169
+ date: 1784837003,
170
+ forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
171
+ forward_from: originUser,
172
+ forward_date: 1784830000,
173
+ rich_message: { blocks },
174
+ },
175
+ } as unknown as Update
176
+ }
177
+
178
+ function makeTextUpdate(update_id: number, text: string): Update {
179
+ return {
180
+ update_id,
181
+ message: {
182
+ message_id: update_id,
183
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
184
+ from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
185
+ date: 1784837004,
186
+ text,
187
+ },
188
+ } as unknown as Update
189
+ }
190
+
191
+ interface HarnessOptions {
192
+ /** Bind the rich handler's dispatch to the coalescing path (the fix) or to
193
+ * the bare non-coalescing path (the pre-fix bug). */
194
+ richCoalesces: boolean
195
+ }
196
+
197
+ /**
198
+ * Wire a real grammy Bot the way the production gateway does: message:text
199
+ * enqueues into a shared coalescer; message:rich_message goes through the
200
+ * REAL production handler with deps whose `handleInbound` is bound to either
201
+ * the same coalescer (fix) or a bare immediate dispatch (bug).
202
+ */
203
+ function buildHarness(opts: HarnessOptions) {
204
+ const flushed: string[] = []
205
+
206
+ // The exact coalescer module the gateway uses. 1500ms sliding window.
207
+ const coalescer = createInboundCoalescer<{ text: string }>({
208
+ gapMs: 1500,
209
+ merge: entries => ({ text: entries.map(e => e.text).join('\n') }),
210
+ onFlush: (_key, merged) => flushed.push(merged.text),
211
+ })
212
+
213
+ const key = inboundCoalesceKey(String(CHAT_ID), null, String(USER_ID))
214
+
215
+ /** Coalescing dispatch — mirrors handleInboundCoalesced's enqueue step. */
216
+ const coalescedDispatch = async (text: string): Promise<void> => {
217
+ const { bypass } = coalescer.enqueue(key, { text })
218
+ if (bypass) flushed.push(text)
219
+ }
220
+ /** Bare dispatch — the pre-fix bug: every message is its own turn. */
221
+ const bareDispatch = async (text: string): Promise<void> => {
222
+ flushed.push(text)
223
+ }
224
+
225
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
226
+ bot.botInfo = makeBotInfo()
227
+
228
+ bot.on('message:text', async ctx => {
229
+ await coalescedDispatch(ctx.message.text)
230
+ })
231
+
232
+ const richDeps: MediaEnvelopeDeps = {
233
+ handleInbound: async (_ctx, text) => {
234
+ await (opts.richCoalesces ? coalescedDispatch(text) : bareDispatch(text))
235
+ },
236
+ handleAckOnly: async () => {},
237
+ handleRefusal: async () => {},
238
+ log: () => {},
239
+ }
240
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, richDeps))
241
+
242
+ return { bot, flushed }
243
+ }
244
+
245
+ const BLOCKS = [{ type: 'paragraph', text: 'forwarded body from a bot' }]
246
+
247
+ describe('outcome: forwarded rich message coalesces with a sibling inbound', () => {
248
+ beforeEach(() => {
249
+ resetUpdateCounters()
250
+ vi.useFakeTimers()
251
+ })
252
+ afterEach(() => vi.useRealTimers())
253
+
254
+ it('rich + text in the same window flush as ONE ordered turn (the fix)', async () => {
255
+ const { bot, flushed } = buildHarness({ richCoalesces: true })
256
+
257
+ await bot.handleUpdate(makeForwardedRichUpdate(9001, BLOCKS))
258
+ // A second inbound arrives 500ms later — inside the 1500ms window.
259
+ vi.advanceTimersByTime(500)
260
+ await bot.handleUpdate(makeTextUpdate(9002, 'and my own note about it'))
261
+
262
+ // Nothing flushed yet — the window is still open.
263
+ expect(flushed).toEqual([])
264
+
265
+ // Window closes 1500ms after the LAST message (sliding window).
266
+ vi.advanceTimersByTime(1500)
267
+
268
+ // ONE turn, both bodies, rich body first (arrival order preserved).
269
+ expect(flushed).toHaveLength(1)
270
+ expect(flushed[0]).toBe('forwarded body from a bot\nand my own note about it')
271
+ })
272
+
273
+ it('the pre-fix bare wiring would split them into TWO turns (bug characterization)', async () => {
274
+ const { bot, flushed } = buildHarness({ richCoalesces: false })
275
+
276
+ await bot.handleUpdate(makeForwardedRichUpdate(9003, BLOCKS))
277
+ // The rich message bypassed the window: it flushes immediately.
278
+ expect(flushed).toEqual(['forwarded body from a bot'])
279
+
280
+ vi.advanceTimersByTime(500)
281
+ await bot.handleUpdate(makeTextUpdate(9004, 'and my own note about it'))
282
+ vi.advanceTimersByTime(1500)
283
+
284
+ // Two separate turns — the fragmentation the fix eliminates.
285
+ expect(flushed).toEqual([
286
+ 'forwarded body from a bot',
287
+ 'and my own note about it',
288
+ ])
289
+ })
290
+ })
@@ -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',