@s0nderlabs/anima-plugin-telegram 0.25.0 → 0.26.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "test": "bun test"
40
40
  },
41
41
  "dependencies": {
42
- "@s0nderlabs/anima-core": "0.25.0",
42
+ "@s0nderlabs/anima-core": "0.26.0",
43
43
  "grammy": "^1.42.0",
44
44
  "zod": "^3.23.8"
45
45
  }
@@ -0,0 +1,570 @@
1
+ // End-to-end listener test against an in-process fake Telegram Bot API.
2
+ //
3
+ // Committed, CI-safe port of the gitignored test/local/e2e-telegram-mock.ts
4
+ // script, EXTENDED with callback_query (inline-keyboard approval) coverage.
5
+ //
6
+ // What this exercises against the REAL `TelegramListener` (no live token, no
7
+ // 0G, no real network):
8
+ // 1. inbound message -> debounce -> dispatchUserMessage callback -> reply
9
+ // 2. allowlist / pairing default-deny (unknown sender -> pairing code DM)
10
+ // 3. MarkdownV2 translation (**bold** -> *bold*, inline code preserved) +
11
+ // long-message chunking with (i/N) suffixes
12
+ // 4. NEW: callback_query (approval button tap) -> handleCallbackQuery ->
13
+ // approvalResolver resolves the pending approval with the mapped choice,
14
+ // answerCallbackQuery fires, and the message keyboard is cleaned up.
15
+ // Also covers the unauthorized-clicker reject path.
16
+ //
17
+ // HERMETIC: the fake Bot API binds on port 0 (OS-assigned). Each test uses a
18
+ // UNIQUE random bot token (the listener takes a process-wide token lock keyed
19
+ // by token) and a fresh lock dir, and stops the listener + releases the lock
20
+ // in a finally block, so it never flakes under parallel runs.
21
+
22
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
23
+ import { mkdtempSync, rmSync } from 'node:fs'
24
+ import http from 'node:http'
25
+ import { tmpdir } from 'node:os'
26
+ import { join } from 'node:path'
27
+ import { PairingStore } from '@s0nderlabs/anima-core'
28
+ import { buildApprovalKeyboard } from './approval-keyboard'
29
+ import { TelegramListener } from './listener'
30
+ import type { ApprovalChoiceKind, TelegramApprovalBridge, TelegramRuntimeContext } from './types'
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Fake Telegram Bot API server
34
+ // ---------------------------------------------------------------------------
35
+
36
+ interface SentMessage {
37
+ chatId: number
38
+ text: string
39
+ reply_markup?: unknown
40
+ parse_mode?: string
41
+ }
42
+
43
+ interface AnsweredCallback {
44
+ callbackQueryId: string
45
+ text?: string
46
+ }
47
+
48
+ interface EditedText {
49
+ chatId?: number
50
+ messageId?: number
51
+ text: string
52
+ reply_markup?: unknown
53
+ }
54
+
55
+ interface EditedReplyMarkup {
56
+ chatId?: number
57
+ messageId?: number
58
+ reply_markup?: unknown
59
+ }
60
+
61
+ interface MockServer {
62
+ url: string
63
+ sends: SentMessage[]
64
+ answeredCallbacks: AnsweredCallback[]
65
+ editedTexts: EditedText[]
66
+ editedReplyMarkups: EditedReplyMarkup[]
67
+ getDeleteWebhookCount: () => number
68
+ inject: (update: unknown) => void
69
+ stop: () => Promise<void>
70
+ }
71
+
72
+ async function startMockServer(): Promise<MockServer> {
73
+ const updates: unknown[] = []
74
+ let nextMessageId = 1
75
+ const sends: SentMessage[] = []
76
+ const answeredCallbacks: AnsweredCallback[] = []
77
+ const editedTexts: EditedText[] = []
78
+ const editedReplyMarkups: EditedReplyMarkup[] = []
79
+ let deletedWebhookCount = 0
80
+
81
+ const server = http.createServer(async (req, res) => {
82
+ const url = req.url ?? '/'
83
+ let body = ''
84
+ for await (const chunk of req) body += String(chunk)
85
+ const params = (() => {
86
+ try {
87
+ return JSON.parse(body || '{}') as Record<string, unknown>
88
+ } catch {
89
+ return {}
90
+ }
91
+ })()
92
+ const ok = (result: unknown): void => {
93
+ res.writeHead(200, { 'content-type': 'application/json' })
94
+ res.end(JSON.stringify({ ok: true, result }))
95
+ }
96
+
97
+ if (url.endsWith('/getMe')) {
98
+ ok({ id: 12345, is_bot: true, first_name: 'mock', username: 'mockbot' })
99
+ return
100
+ }
101
+ if (url.endsWith('/getUpdates')) {
102
+ ok(updates.splice(0, updates.length))
103
+ return
104
+ }
105
+ if (url.endsWith('/deleteWebhook')) {
106
+ deletedWebhookCount++
107
+ ok(true)
108
+ return
109
+ }
110
+ if (url.endsWith('/sendMessage')) {
111
+ sends.push({
112
+ chatId: Number(params.chat_id ?? 0),
113
+ text: String(params.text ?? ''),
114
+ reply_markup: params.reply_markup,
115
+ parse_mode: params.parse_mode as string | undefined,
116
+ })
117
+ ok({ message_id: nextMessageId++ })
118
+ return
119
+ }
120
+ if (url.endsWith('/answerCallbackQuery')) {
121
+ answeredCallbacks.push({
122
+ callbackQueryId: String(params.callback_query_id ?? ''),
123
+ text: params.text as string | undefined,
124
+ })
125
+ ok(true)
126
+ return
127
+ }
128
+ if (url.endsWith('/editMessageText')) {
129
+ editedTexts.push({
130
+ chatId: params.chat_id != null ? Number(params.chat_id) : undefined,
131
+ messageId: params.message_id != null ? Number(params.message_id) : undefined,
132
+ text: String(params.text ?? ''),
133
+ reply_markup: params.reply_markup,
134
+ })
135
+ // Telegram returns the edited Message object on success.
136
+ ok({ message_id: Number(params.message_id ?? 0), text: String(params.text ?? '') })
137
+ return
138
+ }
139
+ if (url.endsWith('/editMessageReplyMarkup')) {
140
+ editedReplyMarkups.push({
141
+ chatId: params.chat_id != null ? Number(params.chat_id) : undefined,
142
+ messageId: params.message_id != null ? Number(params.message_id) : undefined,
143
+ reply_markup: params.reply_markup,
144
+ })
145
+ ok({ message_id: Number(params.message_id ?? 0) })
146
+ return
147
+ }
148
+ // setMyCommands, setMessageReaction, sendChatAction, etc.
149
+ ok(true)
150
+ })
151
+
152
+ await new Promise<void>(r => server.listen(0, '127.0.0.1', () => r()))
153
+ const addr = server.address()
154
+ const port = typeof addr === 'object' && addr ? addr.port : 0
155
+
156
+ return {
157
+ url: `http://127.0.0.1:${port}`,
158
+ sends,
159
+ answeredCallbacks,
160
+ editedTexts,
161
+ editedReplyMarkups,
162
+ getDeleteWebhookCount: () => deletedWebhookCount,
163
+ inject: u => updates.push(u),
164
+ stop: () => new Promise<void>(r => server.close(() => r())),
165
+ }
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Helpers
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /** Unique token per run avoids the process-wide token-lock contention. */
173
+ function uniqueToken(): string {
174
+ return `${Math.floor(Math.random() * 1e9)}:fake-${Math.random().toString(36).slice(2)}`
175
+ }
176
+
177
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
178
+
179
+ /** Poll until `pred()` is true or `timeoutMs` elapses. Avoids fixed sleeps. */
180
+ async function waitFor(pred: () => boolean, timeoutMs = 4000): Promise<boolean> {
181
+ const start = Date.now()
182
+ while (Date.now() - start < timeoutMs) {
183
+ if (pred()) return true
184
+ await sleep(50)
185
+ }
186
+ return pred()
187
+ }
188
+
189
+ function injectMessage(
190
+ mock: MockServer,
191
+ opts: { updateId: number; fromId: number; chatId: number; messageId: number; text: string },
192
+ ): void {
193
+ mock.inject({
194
+ update_id: opts.updateId,
195
+ message: {
196
+ message_id: opts.messageId,
197
+ from: { id: opts.fromId, is_bot: false, username: 'op', first_name: 'Op' },
198
+ chat: { id: opts.chatId, type: 'private' },
199
+ date: Math.floor(Date.now() / 1000),
200
+ text: opts.text,
201
+ },
202
+ })
203
+ }
204
+
205
+ function injectCallbackQuery(
206
+ mock: MockServer,
207
+ opts: {
208
+ updateId: number
209
+ fromId: number
210
+ chatId: number
211
+ messageId: number
212
+ callbackData: string
213
+ messageText?: string | null
214
+ },
215
+ ): void {
216
+ const message: Record<string, unknown> = {
217
+ message_id: opts.messageId,
218
+ from: { id: 12345, is_bot: true, username: 'mockbot', first_name: 'mock' },
219
+ chat: { id: opts.chatId, type: 'private' },
220
+ date: Math.floor(Date.now() / 1000),
221
+ reply_markup: buildApprovalKeyboard(opts.callbackData.split(':')[2] ?? 'a-1'),
222
+ }
223
+ if (opts.messageText != null) message.text = opts.messageText
224
+ mock.inject({
225
+ update_id: opts.updateId,
226
+ callback_query: {
227
+ id: `cbq-${opts.updateId}`,
228
+ from: { id: opts.fromId, is_bot: false, username: 'op', first_name: 'Op' },
229
+ message,
230
+ chat_instance: 'ci-1',
231
+ data: opts.callbackData,
232
+ },
233
+ })
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Test rig
238
+ // ---------------------------------------------------------------------------
239
+
240
+ let lockDir: string
241
+ let pairingDir: string
242
+ const cleanups: Array<() => Promise<void> | void> = []
243
+
244
+ beforeEach(() => {
245
+ lockDir = mkdtempSync(join(tmpdir(), 'anima-tg-e2e-lock-'))
246
+ pairingDir = mkdtempSync(join(tmpdir(), 'anima-tg-e2e-pairing-'))
247
+ })
248
+
249
+ afterEach(async () => {
250
+ // Tear down listeners + servers FIRST so the token lock is released and the
251
+ // server socket is closed before the next test (hermetic / no flake).
252
+ for (const fn of cleanups.splice(0, cleanups.length)) {
253
+ try {
254
+ await fn()
255
+ } catch {
256
+ /* best-effort teardown */
257
+ }
258
+ }
259
+ rmSync(lockDir, { recursive: true, force: true })
260
+ rmSync(pairingDir, { recursive: true, force: true })
261
+ })
262
+
263
+ interface Harness {
264
+ mock: MockServer
265
+ listener: TelegramListener
266
+ }
267
+
268
+ async function makeHarness(
269
+ overrides: Partial<TelegramRuntimeContext> & { approvalBridge?: TelegramApprovalBridge } = {},
270
+ ): Promise<Harness> {
271
+ const mock = await startMockServer()
272
+ const listener = new TelegramListener({
273
+ botToken: uniqueToken(),
274
+ apiRoot: mock.url,
275
+ allowedUserIds: [42],
276
+ agentName: 'e2e-agent',
277
+ // small debounce so the round-trip finishes fast in CI
278
+ debounceMs: 150,
279
+ dispatchUserMessage: async () => ({ response: 'pong' }),
280
+ ...overrides,
281
+ // lockRootDir is not part of TelegramRuntimeContext; merge after spread
282
+ lockRootDir: lockDir,
283
+ } as TelegramRuntimeContext & { apiRoot: string; lockRootDir: string; debounceMs: number })
284
+ cleanups.push(async () => {
285
+ await listener.stop()
286
+ await mock.stop()
287
+ })
288
+ return { mock, listener }
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // 1. Inbound message -> debounce -> dispatch -> reply round-trip
293
+ // ---------------------------------------------------------------------------
294
+
295
+ describe('TelegramListener e2e: inbound -> dispatch -> reply', () => {
296
+ it('debounces an inbound DM, dispatches the channel-wrapped text, and replies', async () => {
297
+ let dispatchedText = ''
298
+ const { mock, listener } = await makeHarness({
299
+ dispatchUserMessage: async input => {
300
+ dispatchedText = input.text
301
+ return { response: 'pong from brain' }
302
+ },
303
+ })
304
+ await listener.start()
305
+
306
+ expect(await waitFor(() => mock.getDeleteWebhookCount() >= 1)).toBe(true)
307
+
308
+ injectMessage(mock, {
309
+ updateId: 1001,
310
+ fromId: 42,
311
+ chatId: 42,
312
+ messageId: 1,
313
+ text: 'hello there',
314
+ })
315
+
316
+ expect(await waitFor(() => dispatchedText.length > 0)).toBe(true)
317
+ // dispatch receives the <channel>-wrapped untrusted text
318
+ expect(dispatchedText).toContain('hello there')
319
+ expect(dispatchedText).toContain('<channel')
320
+ expect(dispatchedText).toContain('source="telegram"')
321
+
322
+ const reply = await waitFor(() =>
323
+ mock.sends.some(s => s.chatId === 42 && s.text.includes('pong')),
324
+ )
325
+ expect(reply).toBe(true)
326
+ })
327
+ })
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // 2. Allowlist / pairing default-deny
331
+ // ---------------------------------------------------------------------------
332
+
333
+ describe('TelegramListener e2e: allowlist + pairing default-deny', () => {
334
+ it('drops a non-allowlisted sender with no pairing store (no reply at all)', async () => {
335
+ const { mock, listener } = await makeHarness({
336
+ allowedUserIds: [42],
337
+ pairingStore: undefined,
338
+ })
339
+ await listener.start()
340
+ expect(await waitFor(() => mock.getDeleteWebhookCount() >= 1)).toBe(true)
341
+
342
+ // stranger (id 7) is not in the allowlist -> dropped, no pairing store
343
+ injectMessage(mock, { updateId: 2001, fromId: 7, chatId: 7, messageId: 1, text: 'let me in' })
344
+
345
+ // give the listener time to process + (not) reply
346
+ await sleep(700)
347
+ expect(mock.sends.filter(s => s.chatId === 7)).toHaveLength(0)
348
+ })
349
+
350
+ it('sends a pairing code to an unknown sender when a pairing store is configured', async () => {
351
+ const store = new PairingStore({ dir: pairingDir })
352
+ const { mock, listener } = await makeHarness({
353
+ // empty allowlist + pairing store => unknown sender gets a pairing code
354
+ allowedUserIds: [],
355
+ pairingStore: store,
356
+ })
357
+ await listener.start()
358
+ expect(await waitFor(() => mock.getDeleteWebhookCount() >= 1)).toBe(true)
359
+
360
+ injectMessage(mock, { updateId: 2101, fromId: 555, chatId: 555, messageId: 1, text: 'hi' })
361
+
362
+ const got = await waitFor(() => mock.sends.some(s => s.chatId === 555))
363
+ expect(got).toBe(true)
364
+ const pairingMsg = mock.sends.find(s => s.chatId === 555)
365
+ expect(pairingMsg).toBeDefined()
366
+ // pairing message carries an 8-char code; assert a code-shaped token exists
367
+ expect(/[A-Z0-9]{8}/.test(pairingMsg?.text ?? '')).toBe(true)
368
+ })
369
+ })
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // 3. MarkdownV2 translation + chunking
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('TelegramListener e2e: MarkdownV2 translation + chunking', () => {
376
+ it('translates **bold** -> *bold* and preserves inline code on the wire', async () => {
377
+ const { mock, listener } = await makeHarness({
378
+ allowedUserIds: [99],
379
+ dispatchUserMessage: async () => ({
380
+ response: 'Your balance: **0.0819 0G**. Wallet `0xd56b...9683`.',
381
+ }),
382
+ })
383
+ await listener.start()
384
+ expect(await waitFor(() => mock.getDeleteWebhookCount() >= 1)).toBe(true)
385
+
386
+ injectMessage(mock, { updateId: 3001, fromId: 99, chatId: 99, messageId: 1, text: 'balance?' })
387
+
388
+ const got = await waitFor(() =>
389
+ mock.sends.some(s => s.chatId === 99 && s.parse_mode === 'MarkdownV2'),
390
+ )
391
+ expect(got).toBe(true)
392
+ const body = mock.sends.find(s => s.chatId === 99)?.text ?? ''
393
+ expect(body).toContain('*0\\.0819 0G*') // **bold** -> *bold* (dot escaped)
394
+ expect(body).toContain('`0xd56b...9683`') // inline code preserved
395
+ expect(body).not.toContain('**') // no literal markdown bold left
396
+ expect(body).not.toContain('\\`') // inline-code backticks not escaped
397
+ })
398
+
399
+ it('splits a long reply into multiple chunks with (i/N) suffixes', async () => {
400
+ const longReply = 'paragraph '.repeat(900) // well over the 4096 TG cap
401
+ const { mock, listener } = await makeHarness({
402
+ allowedUserIds: [88],
403
+ dispatchUserMessage: async () => ({ response: longReply }),
404
+ })
405
+ await listener.start()
406
+ expect(await waitFor(() => mock.getDeleteWebhookCount() >= 1)).toBe(true)
407
+
408
+ injectMessage(mock, {
409
+ updateId: 3101,
410
+ fromId: 88,
411
+ chatId: 88,
412
+ messageId: 1,
413
+ text: 'tell me a lot',
414
+ })
415
+
416
+ const got = await waitFor(() => mock.sends.filter(s => s.chatId === 88).length >= 2, 6000)
417
+ expect(got).toBe(true)
418
+ const chunks = mock.sends.filter(s => s.chatId === 88)
419
+ expect(chunks.length).toBeGreaterThan(1)
420
+ // first + last chunk carry (i/N) ordinal suffixes. In MarkdownV2 the
421
+ // wrapping parens are backslash-escaped (`\(1/N\)`), so assert on the
422
+ // ordinal core that survives escaping.
423
+ expect(chunks[0]?.text).toContain(`1/${chunks.length}`)
424
+ expect(chunks[chunks.length - 1]?.text).toContain(`${chunks.length}/${chunks.length}`)
425
+ // sanity: the escaped-paren suffix shape is present on the last chunk
426
+ expect(chunks[chunks.length - 1]?.text).toContain(`\\(${chunks.length}/${chunks.length}\\)`)
427
+ })
428
+ })
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // 4. NEW: callback_query (inline approval button) resolution path
432
+ // ---------------------------------------------------------------------------
433
+
434
+ describe('TelegramListener e2e: callback_query approval resolution', () => {
435
+ /**
436
+ * Wire an approval bridge the way the gateway/chat dispatcher does:
437
+ * listener.start() fills `installCallbackHandler.current` with a registrar;
438
+ * we call it to install our resolver, which the pre-installed
439
+ * `callback_query:data` handler invokes on each click.
440
+ */
441
+ function makeBridge(): {
442
+ bridge: TelegramApprovalBridge
443
+ install: () => void
444
+ resolved: Array<{ approvalId: string; choice: ApprovalChoiceKind; fromUserId: number }>
445
+ } {
446
+ const resolved: Array<{ approvalId: string; choice: ApprovalChoiceKind; fromUserId: number }> =
447
+ []
448
+ const bridge: TelegramApprovalBridge = {
449
+ sendApproval: { current: null },
450
+ installCallbackHandler: { current: null },
451
+ }
452
+ const install = (): void => {
453
+ const registrar = bridge.installCallbackHandler.current
454
+ if (!registrar) throw new Error('installCallbackHandler not filled by listener.start()')
455
+ registrar((approvalId, choice, fromUserId) => {
456
+ resolved.push({ approvalId, choice, fromUserId })
457
+ })
458
+ }
459
+ return { bridge, install, resolved }
460
+ }
461
+
462
+ it('resolves a pending approval with the mapped choice and answers + clears the keyboard', async () => {
463
+ const { bridge, install, resolved } = makeBridge()
464
+ const { mock, listener } = await makeHarness({ allowedUserIds: [42], approvalBridge: bridge })
465
+ await listener.start()
466
+ // bridge slots are filled inside start(); install the resolver now
467
+ install()
468
+ expect(bridge.sendApproval.current).not.toBeNull()
469
+
470
+ // Operator taps "Session" on the approval keyboard for approval a-7.
471
+ injectCallbackQuery(mock, {
472
+ updateId: 4001,
473
+ fromId: 42,
474
+ chatId: 42,
475
+ messageId: 50,
476
+ callbackData: 'ea:session:a-7',
477
+ messageText: 'Approve shell.run? rm tmp file',
478
+ })
479
+
480
+ expect(await waitFor(() => resolved.length === 1)).toBe(true)
481
+ expect(resolved[0]).toEqual({ approvalId: 'a-7', choice: 'session', fromUserId: 42 })
482
+
483
+ // The handler answers the callback query (dismisses the phone spinner)
484
+ expect(await waitFor(() => mock.answeredCallbacks.length >= 1)).toBe(true)
485
+ expect(mock.answeredCallbacks.some(a => (a.text ?? '').includes('session'))).toBe(true)
486
+
487
+ // ...and visually resolves the modal: since the message had text, it edits
488
+ // the text and drops the inline keyboard.
489
+ expect(await waitFor(() => mock.editedTexts.length >= 1)).toBe(true)
490
+ const edit = mock.editedTexts.find(e => e.text.includes('Approve shell.run'))
491
+ expect(edit).toBeDefined()
492
+ expect(edit?.text).toContain('Allowed for session')
493
+ expect(edit?.reply_markup).toBeUndefined() // keyboard removed
494
+ })
495
+
496
+ it('maps each choice (once/always/deny) through to the resolver verbatim', async () => {
497
+ const cases: Array<{ data: string; choice: ApprovalChoiceKind; approvalId: string }> = [
498
+ { data: 'ea:once:a-1', choice: 'once', approvalId: 'a-1' },
499
+ { data: 'ea:always:a-2', choice: 'always', approvalId: 'a-2' },
500
+ { data: 'ea:deny:a-3', choice: 'deny', approvalId: 'a-3' },
501
+ ]
502
+ for (const c of cases) {
503
+ const { bridge, install, resolved } = makeBridge()
504
+ const { mock, listener } = await makeHarness({ allowedUserIds: [42], approvalBridge: bridge })
505
+ await listener.start()
506
+ install()
507
+ injectCallbackQuery(mock, {
508
+ updateId: 4100,
509
+ fromId: 42,
510
+ chatId: 42,
511
+ messageId: 60,
512
+ callbackData: c.data,
513
+ messageText: 'Approve a tool call?',
514
+ })
515
+ expect(await waitFor(() => resolved.length === 1)).toBe(true)
516
+ expect(resolved[0]).toEqual({ approvalId: c.approvalId, choice: c.choice, fromUserId: 42 })
517
+ // stop this harness before the next iteration so locks/servers free
518
+ await cleanups.splice(0, cleanups.length).reduce(async (p, fn) => {
519
+ await p
520
+ await fn()
521
+ }, Promise.resolve())
522
+ }
523
+ })
524
+
525
+ it('rejects an unauthorized clicker: answers with a denial, does NOT resolve, no keyboard edit', async () => {
526
+ const { bridge, install, resolved } = makeBridge()
527
+ const { mock, listener } = await makeHarness({ allowedUserIds: [42], approvalBridge: bridge })
528
+ await listener.start()
529
+ install()
530
+
531
+ // user 9999 is NOT in the allowlist; the click must be rejected
532
+ injectCallbackQuery(mock, {
533
+ updateId: 4200,
534
+ fromId: 9999,
535
+ chatId: 9999,
536
+ messageId: 70,
537
+ callbackData: 'ea:always:a-99',
538
+ messageText: 'Approve dangerous thing?',
539
+ })
540
+
541
+ expect(await waitFor(() => mock.answeredCallbacks.length >= 1)).toBe(true)
542
+ expect(mock.answeredCallbacks.some(a => (a.text ?? '').includes('not authorized'))).toBe(true)
543
+ // resolver was never invoked
544
+ expect(resolved).toHaveLength(0)
545
+ // and no message edit happened (modal untouched)
546
+ await sleep(300)
547
+ expect(mock.editedTexts).toHaveLength(0)
548
+ expect(mock.editedReplyMarkups).toHaveLength(0)
549
+ })
550
+
551
+ it('drops a malformed callback payload: answers, never resolves', async () => {
552
+ const { bridge, install, resolved } = makeBridge()
553
+ const { mock, listener } = await makeHarness({ allowedUserIds: [42], approvalBridge: bridge })
554
+ await listener.start()
555
+ install()
556
+
557
+ injectCallbackQuery(mock, {
558
+ updateId: 4300,
559
+ fromId: 42,
560
+ chatId: 42,
561
+ messageId: 80,
562
+ callbackData: 'garbage-not-an-approval',
563
+ messageText: 'whatever',
564
+ })
565
+
566
+ expect(await waitFor(() => mock.answeredCallbacks.length >= 1)).toBe(true)
567
+ expect(mock.answeredCallbacks.some(a => (a.text ?? '').includes('malformed'))).toBe(true)
568
+ expect(resolved).toHaveLength(0)
569
+ })
570
+ })