@s0nderlabs/anima-plugin-telegram 0.24.17 → 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 +2 -2
- package/src/listener-e2e.test.ts +570 -0
- package/src/listener.ts +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@s0nderlabs/anima-plugin-telegram",
|
|
3
|
-
"version": "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.
|
|
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
|
+
})
|
package/src/listener.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function formatApprovalResolution(choice: ApprovalChoice, byUserId: numbe
|
|
|
64
64
|
* buttons rendered for tool approvals). Without `'callback_query'` here,
|
|
65
65
|
* Telegram silently filters the click events out of `getUpdates`, the
|
|
66
66
|
* `bot.on('callback_query:data', ...)` handler never fires, and operator
|
|
67
|
-
* taps register on the device but never reach the harness
|
|
67
|
+
* taps register on the device but never reach the harness, the modal
|
|
68
68
|
* appears stuck and the brain's tool call hangs until timeout.
|
|
69
69
|
*
|
|
70
70
|
* Latent bug from v0.18.0 (the introduction of inline-keyboard approvals);
|
|
@@ -172,7 +172,7 @@ export class TelegramListener {
|
|
|
172
172
|
// Resolve the modal visually: append the choice + drop the inline
|
|
173
173
|
// keyboard. Without this, every clicked approval message stays on
|
|
174
174
|
// screen with all four buttons, leaving operators unsure whether
|
|
175
|
-
// their tap registered. Best-effort
|
|
175
|
+
// their tap registered. Best-effort, if the edit fails (rate
|
|
176
176
|
// limit, message age, deleted), the underlying approval is still
|
|
177
177
|
// resolved at the runtime level, so we swallow the error.
|
|
178
178
|
const originalText =
|
|
@@ -185,7 +185,7 @@ export class TelegramListener {
|
|
|
185
185
|
await ctx.editMessageReplyMarkup({ reply_markup: undefined })
|
|
186
186
|
}
|
|
187
187
|
} catch {
|
|
188
|
-
/* ignore
|
|
188
|
+
/* ignore, modal was clicked, runtime already resolved; keyboard cleanup is cosmetic */
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -514,11 +514,15 @@ export class TelegramListener {
|
|
|
514
514
|
const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''
|
|
515
515
|
console.error(`[telegram] dispatch failed: ${msg.slice(0, 500)}${stack}`)
|
|
516
516
|
void reactError(this.bot, chatId, messageId)
|
|
517
|
-
// Translate
|
|
518
|
-
// instead of the generic "something went wrong" reply. Detect by
|
|
519
|
-
// name (avoids
|
|
520
|
-
|
|
521
|
-
|
|
517
|
+
// Translate a credit/ledger-exhaustion error into an actionable topup
|
|
518
|
+
// hint instead of the generic "something went wrong" reply. Detect by
|
|
519
|
+
// name (avoids importing core's typed classes): Direct throws
|
|
520
|
+
// LedgerInsufficientError ("anima topup --compute"), Router throws
|
|
521
|
+
// RouterCreditError ("anima topup --router"), both carry the actionable
|
|
522
|
+
// message, so surface it verbatim.
|
|
523
|
+
const errName = err instanceof Error ? err.name : ''
|
|
524
|
+
const isTopupHint = errName === 'LedgerInsufficientError' || errName === 'RouterCreditError'
|
|
525
|
+
const replyText = isTopupHint
|
|
522
526
|
? `⚠️ I need a top-up to keep working.\n\n${msg}`
|
|
523
527
|
: 'sorry, something went wrong on my side. try again in a moment.'
|
|
524
528
|
try {
|