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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- 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',
|