@s0nderlabs/anima-plugin-telegram 0.19.13 → 0.19.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/listener-retry.test.ts +97 -0
- package/src/listener.ts +78 -7
- package/src/progress.test.ts +132 -0
- package/src/progress.ts +220 -0
- package/src/types.ts +17 -0
- package/src/typing.test.ts +69 -0
- package/src/typing.ts +31 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@s0nderlabs/anima-plugin-telegram",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"test": "bun test"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@s0nderlabs/anima-core": "0.19.
|
|
31
|
+
"@s0nderlabs/anima-core": "0.19.15",
|
|
32
32
|
"grammy": "^1.42.0",
|
|
33
33
|
"zod": "^3.23.8"
|
|
34
34
|
}
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,9 @@ export type {
|
|
|
24
24
|
TelegramDispatchInput,
|
|
25
25
|
TelegramDispatchResult,
|
|
26
26
|
TelegramInboundEvent,
|
|
27
|
+
TelegramToolEvent,
|
|
27
28
|
} from './types'
|
|
29
|
+
export { ProgressTracker, PROGRESS_EDIT_INTERVAL } from './progress'
|
|
28
30
|
export { TelegramListener, capForTelegram } from './listener'
|
|
29
31
|
export { buildSessionKey, sanitizeAgentName } from './session-key'
|
|
30
32
|
export { formatTelegramChannel, formatInboundPreview } from './format'
|
|
@@ -88,6 +90,7 @@ export {
|
|
|
88
90
|
REACTION_ERR,
|
|
89
91
|
} from './reactions'
|
|
90
92
|
export { TELEGRAM_GUIDANCE } from './guidance'
|
|
93
|
+
export { startTypingLoop, TYPING_REFRESH_INTERVAL_MS } from './typing'
|
|
91
94
|
|
|
92
95
|
const plugin: NativePlugin = {
|
|
93
96
|
name: 'telegram',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { TelegramListener } from './listener'
|
|
6
|
+
import { acquireTelegramTokenLock } from './recovery'
|
|
7
|
+
import type { TelegramRuntimeContext } from './types'
|
|
8
|
+
|
|
9
|
+
let lockDir: string
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
lockDir = mkdtempSync(join(tmpdir(), 'anima-listener-retry-'))
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
rmSync(lockDir, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const FAKE_TOKEN = '999:does-not-call-network'
|
|
20
|
+
|
|
21
|
+
function makeOpts(): TelegramRuntimeContext & { lockRootDir: string; apiRoot: string } {
|
|
22
|
+
return {
|
|
23
|
+
botToken: FAKE_TOKEN,
|
|
24
|
+
allowedUserIds: [42],
|
|
25
|
+
agentName: 'retry-canary',
|
|
26
|
+
pairingStore: undefined,
|
|
27
|
+
dispatchUserMessage: async () => ({ response: 'ok' }),
|
|
28
|
+
onProcessingStart: async () => {},
|
|
29
|
+
onProcessingEnd: async () => {},
|
|
30
|
+
approvalBridge: undefined,
|
|
31
|
+
lockRootDir: lockDir,
|
|
32
|
+
// Point grammY at an unreachable host so any accidental network call
|
|
33
|
+
// would fail fast. We never reach bot.start() in these tests because
|
|
34
|
+
// the lock path returns first.
|
|
35
|
+
apiRoot: 'http://127.0.0.1:1',
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('TelegramListener lock-retry', () => {
|
|
40
|
+
it('does NOT throw when the bot-token lock is held; retains running=false until the lock frees', async () => {
|
|
41
|
+
// Pre-occupy the lock. From the listener's perspective this is a
|
|
42
|
+
// zombie/leftover holder it must wait out.
|
|
43
|
+
const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
44
|
+
agentId: 'retry-canary',
|
|
45
|
+
rootDir: lockDir,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const listener = new TelegramListener(makeOpts())
|
|
49
|
+
// Pre-fix this would throw BotTokenLockedError synchronously after the
|
|
50
|
+
// build-runtime catch and never re-attempt. Now it must swallow,
|
|
51
|
+
// schedule a retry timer, and remain stoppable.
|
|
52
|
+
await expect(listener.start()).resolves.toBeUndefined()
|
|
53
|
+
|
|
54
|
+
// stop() should release whatever we held + cancel the retry timer.
|
|
55
|
+
await listener.stop()
|
|
56
|
+
blocker.release()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('stop() cancels a pending retry without leaking timers', async () => {
|
|
60
|
+
const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
61
|
+
agentId: 'retry-canary',
|
|
62
|
+
rootDir: lockDir,
|
|
63
|
+
})
|
|
64
|
+
const listener = new TelegramListener(makeOpts())
|
|
65
|
+
await listener.start() // schedules retry because blocker holds the lock
|
|
66
|
+
// Immediately stop. If the retry timer wasn't unref'd / cleared the
|
|
67
|
+
// bun:test process would hang waiting for it (visible as a >30s test
|
|
68
|
+
// timeout; this assertion fails fast otherwise).
|
|
69
|
+
await listener.stop()
|
|
70
|
+
blocker.release()
|
|
71
|
+
// After stop+release, fresh acquisition by an outside caller works
|
|
72
|
+
// (no orphaned listener still holding the lock).
|
|
73
|
+
const now = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
74
|
+
agentId: 'retry-canary',
|
|
75
|
+
rootDir: lockDir,
|
|
76
|
+
})
|
|
77
|
+
expect(now).toBeDefined()
|
|
78
|
+
now.release()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('lock-clear path: when the prior holder releases, the next start succeeds', async () => {
|
|
82
|
+
const prior = acquireTelegramTokenLock(FAKE_TOKEN, {
|
|
83
|
+
agentId: 'retry-canary',
|
|
84
|
+
rootDir: lockDir,
|
|
85
|
+
})
|
|
86
|
+
const listener = new TelegramListener(makeOpts())
|
|
87
|
+
await listener.start() // pending retry; lock not yet acquired
|
|
88
|
+
prior.release()
|
|
89
|
+
// Retry runs every 30s in production; we verify the lockfile state
|
|
90
|
+
// rather than waiting on real timers. Pending retry won't fire in this
|
|
91
|
+
// synchronous window, but the listener.stop() path must still succeed.
|
|
92
|
+
await listener.stop()
|
|
93
|
+
// After listener.stop() with retry pending and prior released, the
|
|
94
|
+
// lockfile dir is empty (no orphan).
|
|
95
|
+
expect(existsSync(join(lockDir, 'telegram-bot-token-cbae9eeaf0ee85c6.lock'))).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/listener.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { formatTelegramChannel } from './format'
|
|
|
6
6
|
import { RateLimiter } from './limits'
|
|
7
7
|
import { formatMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
|
|
8
8
|
import { formatPairingMessage } from './pairing-flow'
|
|
9
|
+
import { ProgressTracker } from './progress'
|
|
9
10
|
import { reactError, reactProcessing, reactSuccess } from './reactions'
|
|
10
11
|
import {
|
|
11
12
|
BotTokenLockedError,
|
|
13
|
+
TELEGRAM_TOKEN_LOCK_SCOPE,
|
|
12
14
|
type TokenLock,
|
|
13
15
|
acquireTelegramTokenLock,
|
|
14
16
|
classifyStartFailure,
|
|
@@ -18,6 +20,7 @@ import { DELIVERY_FAILURE_NOTICE, sendWithRetry } from './retry'
|
|
|
18
20
|
import { sanitizeInbound } from './sanitize'
|
|
19
21
|
import { buildSessionKey } from './session-key'
|
|
20
22
|
import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
|
|
23
|
+
import { startTypingLoop } from './typing'
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* Long-poll Telegram bot. Inbound DMs from allowedUserIds are debounced and
|
|
@@ -29,6 +32,12 @@ import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
|
|
|
29
32
|
* webhook, then boots grammy in long-poll mode. `stop()` releases the lock
|
|
30
33
|
* and stops the bot. Both are idempotent.
|
|
31
34
|
*/
|
|
35
|
+
/** Retry cadence + cap when the TG bot-token lock is held by a (possibly
|
|
36
|
+
* zombie) prior holder. 12 × 30s = 6 minutes, comfortably past the 5-minute
|
|
37
|
+
* lock TTL so a stale-but-tenable lock auto-evicts. */
|
|
38
|
+
const RETRY_INTERVAL_MS = 30_000
|
|
39
|
+
const MAX_LOCK_RETRY_ATTEMPTS = 12
|
|
40
|
+
|
|
32
41
|
export interface TelegramListenerOpts extends TelegramRuntimeContext {
|
|
33
42
|
/** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
|
|
34
43
|
apiRoot?: string
|
|
@@ -49,6 +58,9 @@ export class TelegramListener {
|
|
|
49
58
|
private running = false
|
|
50
59
|
private tokenLock: TokenLock | null = null
|
|
51
60
|
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
61
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
62
|
+
private retryAttempts = 0
|
|
63
|
+
private stopped = false
|
|
52
64
|
private approvalResolver:
|
|
53
65
|
| ((approvalId: string, choice: ApprovalChoice, fromUserId: number) => void)
|
|
54
66
|
| null = null
|
|
@@ -110,7 +122,7 @@ export class TelegramListener {
|
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
async start(): Promise<void> {
|
|
113
|
-
if (this.running) return
|
|
125
|
+
if (this.running || this.stopped) return
|
|
114
126
|
|
|
115
127
|
try {
|
|
116
128
|
this.tokenLock = acquireTelegramTokenLock(this.opts.botToken, {
|
|
@@ -118,12 +130,24 @@ export class TelegramListener {
|
|
|
118
130
|
rootDir: this.opts.lockRootDir,
|
|
119
131
|
})
|
|
120
132
|
} catch (err) {
|
|
133
|
+
// Lock contention is recoverable: the prior holder may be a zombie or
|
|
134
|
+
// a stale lockfile from an ungraceful exit (see
|
|
135
|
+
// feedback-tg-token-lock-zombie-after-upgrade.md). Retry every 30s up
|
|
136
|
+
// to 12 attempts (6 minutes, past the 5-minute lock TTL) so we
|
|
137
|
+
// eventually reclaim once the existing entry expires. Without this,
|
|
138
|
+
// a single failed lock acquisition silenced the bot for the entire
|
|
139
|
+
// harness lifetime.
|
|
121
140
|
if (err instanceof BotTokenLockedError) {
|
|
122
|
-
console.warn(
|
|
141
|
+
console.warn(
|
|
142
|
+
`[telegram] cannot start listener: ${err.message}; will retry in ${RETRY_INTERVAL_MS / 1000}s`,
|
|
143
|
+
)
|
|
144
|
+
this.scheduleStartRetry()
|
|
145
|
+
return
|
|
123
146
|
}
|
|
124
147
|
throw err
|
|
125
148
|
}
|
|
126
149
|
|
|
150
|
+
this.retryAttempts = 0
|
|
127
151
|
this.running = true
|
|
128
152
|
console.log(`[telegram] listener.start() called for @${this.opts.agentName}`)
|
|
129
153
|
|
|
@@ -169,6 +193,11 @@ export class TelegramListener {
|
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
async stop(): Promise<void> {
|
|
196
|
+
this.stopped = true
|
|
197
|
+
if (this.retryTimer) {
|
|
198
|
+
clearTimeout(this.retryTimer)
|
|
199
|
+
this.retryTimer = null
|
|
200
|
+
}
|
|
172
201
|
if (!this.running) {
|
|
173
202
|
this.releaseLock()
|
|
174
203
|
return
|
|
@@ -188,6 +217,23 @@ export class TelegramListener {
|
|
|
188
217
|
this.releaseLock()
|
|
189
218
|
}
|
|
190
219
|
|
|
220
|
+
private scheduleStartRetry(): void {
|
|
221
|
+
if (this.stopped) return
|
|
222
|
+
if (this.retryAttempts >= MAX_LOCK_RETRY_ATTEMPTS) {
|
|
223
|
+
console.error(
|
|
224
|
+
`[telegram] gave up acquiring bot-token lock after ${this.retryAttempts} attempts; manual intervention required (rm ~/.anima/locks/${TELEGRAM_TOKEN_LOCK_SCOPE}-*.lock)`,
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (this.retryTimer) clearTimeout(this.retryTimer)
|
|
229
|
+
this.retryAttempts += 1
|
|
230
|
+
this.retryTimer = setTimeout(() => {
|
|
231
|
+
this.retryTimer = null
|
|
232
|
+
void this.start()
|
|
233
|
+
}, RETRY_INTERVAL_MS)
|
|
234
|
+
this.retryTimer.unref?.()
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
private releaseLock(): void {
|
|
192
238
|
if (this.tokenLock) {
|
|
193
239
|
try {
|
|
@@ -311,6 +357,17 @@ export class TelegramListener {
|
|
|
311
357
|
/* never block on hook failures */
|
|
312
358
|
}
|
|
313
359
|
}
|
|
360
|
+
// Show "typing..." in the chat header for the duration of the brain turn.
|
|
361
|
+
// TG's chat action expires after ~5s, so the loop refreshes on a 4.5s
|
|
362
|
+
// interval. Cancel via try/finally so it stops in both happy and error
|
|
363
|
+
// paths. See `typing.ts` for the cancel-fn pattern.
|
|
364
|
+
const stopTyping = startTypingLoop(this.bot, chatId)
|
|
365
|
+
// Tool-call progress message: hermes-style scratch message that gets
|
|
366
|
+
// edited as the brain progresses through tools. See `progress.ts`.
|
|
367
|
+
// Always created; the tracker only sends a message when the brain
|
|
368
|
+
// actually fires a tool event, so prompts that go straight to a
|
|
369
|
+
// text answer don't get a noisy progress preamble.
|
|
370
|
+
const tracker = new ProgressTracker(this.bot, chatId)
|
|
314
371
|
let ok = true
|
|
315
372
|
try {
|
|
316
373
|
const input: TelegramDispatchInput = {
|
|
@@ -321,6 +378,9 @@ export class TelegramListener {
|
|
|
321
378
|
displayName: batch.displayName,
|
|
322
379
|
latestMessageId: messageId,
|
|
323
380
|
sessionKey: buildSessionKey({ agentName: this.opts.agentName, chatId }),
|
|
381
|
+
onToolEvent: ev => {
|
|
382
|
+
void tracker.push(ev)
|
|
383
|
+
},
|
|
324
384
|
}
|
|
325
385
|
const channelText = formatTelegramChannel({
|
|
326
386
|
chatId,
|
|
@@ -340,15 +400,26 @@ export class TelegramListener {
|
|
|
340
400
|
const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''
|
|
341
401
|
console.error(`[telegram] dispatch failed: ${msg.slice(0, 500)}${stack}`)
|
|
342
402
|
void reactError(this.bot, chatId, messageId)
|
|
403
|
+
// Translate LedgerInsufficientError into an actionable topup hint
|
|
404
|
+
// instead of the generic "something went wrong" reply. Detect by
|
|
405
|
+
// name (avoids requiring the plugin to import core's typed class).
|
|
406
|
+
const isLedger = err instanceof Error && err.name === 'LedgerInsufficientError'
|
|
407
|
+
const replyText = isLedger
|
|
408
|
+
? `⚠️ I need a top-up to keep working.\n\n${msg}`
|
|
409
|
+
: 'sorry, something went wrong on my side. try again in a moment.'
|
|
343
410
|
try {
|
|
344
|
-
await this.bot.api.sendMessage(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
{ reply_parameters: { message_id: messageId, allow_sending_without_reply: true } },
|
|
348
|
-
)
|
|
411
|
+
await this.bot.api.sendMessage(chatId, replyText, {
|
|
412
|
+
reply_parameters: { message_id: messageId, allow_sending_without_reply: true },
|
|
413
|
+
})
|
|
349
414
|
} catch {
|
|
350
415
|
/* swallow */
|
|
351
416
|
}
|
|
417
|
+
} finally {
|
|
418
|
+
// Flush any pending throttled progress edit before clearing the
|
|
419
|
+
// typing loop. finalize() is idempotent, swallows errors, and is safe
|
|
420
|
+
// even if the tracker never rendered anything.
|
|
421
|
+
await tracker.finalize().catch(() => {})
|
|
422
|
+
stopTyping()
|
|
352
423
|
}
|
|
353
424
|
if (this.opts.onProcessingEnd) {
|
|
354
425
|
try {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { Bot } from 'grammy'
|
|
3
|
+
import { PROGRESS_EDIT_INTERVAL, ProgressTracker } from './progress'
|
|
4
|
+
|
|
5
|
+
interface CallLog {
|
|
6
|
+
sendMessage: { text: string; messageId: number }[]
|
|
7
|
+
editMessageText: { messageId: number; text: string }[]
|
|
8
|
+
/** Reject the editMessageText this many times before succeeding. Use to drive the flood-mode fallback. */
|
|
9
|
+
rejectEditNTimes?: number
|
|
10
|
+
/** Reject editMessageText with this exact error each time it's called. */
|
|
11
|
+
editError?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeStubBot(log: CallLog): Bot {
|
|
15
|
+
let nextMessageId = 1000
|
|
16
|
+
return {
|
|
17
|
+
api: {
|
|
18
|
+
sendMessage: async (_chatId: number, text: string) => {
|
|
19
|
+
const id = nextMessageId++
|
|
20
|
+
log.sendMessage.push({ text, messageId: id })
|
|
21
|
+
return { message_id: id, chat: { id: _chatId } } as unknown as Awaited<
|
|
22
|
+
ReturnType<Bot['api']['sendMessage']>
|
|
23
|
+
>
|
|
24
|
+
},
|
|
25
|
+
editMessageText: async (_chatId: number, messageId: number, text: string) => {
|
|
26
|
+
if (log.rejectEditNTimes && log.rejectEditNTimes > 0) {
|
|
27
|
+
log.rejectEditNTimes -= 1
|
|
28
|
+
throw new Error(log.editError ?? 'Bad Request: 429 Too Many Requests')
|
|
29
|
+
}
|
|
30
|
+
log.editMessageText.push({ messageId, text })
|
|
31
|
+
return true as const
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
} as unknown as Bot
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('ProgressTracker', () => {
|
|
38
|
+
it('first push sends a new message and records messageId', async () => {
|
|
39
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
40
|
+
const bot = makeStubBot(log)
|
|
41
|
+
const t = new ProgressTracker(bot, 999)
|
|
42
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1', argsPreview: 'date' })
|
|
43
|
+
expect(log.sendMessage.length).toBe(1)
|
|
44
|
+
expect(log.editMessageText.length).toBe(0)
|
|
45
|
+
expect(t.hasRendered()).toBe(true)
|
|
46
|
+
expect(log.sendMessage[0]?.text).toContain('shell\\.run')
|
|
47
|
+
expect(log.sendMessage[0]?.text).toContain('date')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('subsequent push within throttle does NOT immediately edit', async () => {
|
|
51
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
52
|
+
const bot = makeStubBot(log)
|
|
53
|
+
const t = new ProgressTracker(bot, 999)
|
|
54
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1' })
|
|
55
|
+
await t.push({ kind: 'start', tool: 'web.fetch', callId: 'c2' })
|
|
56
|
+
expect(log.sendMessage.length).toBe(1)
|
|
57
|
+
expect(log.editMessageText.length).toBe(0)
|
|
58
|
+
await t.finalize()
|
|
59
|
+
// finalize forces a flush of the pending edit.
|
|
60
|
+
expect(log.editMessageText.length).toBe(1)
|
|
61
|
+
expect(log.editMessageText[0]?.text).toContain('shell\\.run')
|
|
62
|
+
expect(log.editMessageText[0]?.text).toContain('web\\.fetch')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('end event marks the line with a check', async () => {
|
|
66
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
67
|
+
const bot = makeStubBot(log)
|
|
68
|
+
const t = new ProgressTracker(bot, 999)
|
|
69
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1' })
|
|
70
|
+
await t.push({ kind: 'end', tool: 'shell.run', callId: 'c1', ok: true })
|
|
71
|
+
await t.finalize()
|
|
72
|
+
expect(log.editMessageText.length).toBe(1)
|
|
73
|
+
expect(log.editMessageText[0]?.text).toContain('✓')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('end event with ok=false marks the line with an X', async () => {
|
|
77
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
78
|
+
const bot = makeStubBot(log)
|
|
79
|
+
const t = new ProgressTracker(bot, 999)
|
|
80
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1' })
|
|
81
|
+
await t.push({ kind: 'end', tool: 'shell.run', callId: 'c1', ok: false })
|
|
82
|
+
await t.finalize()
|
|
83
|
+
expect(log.editMessageText.length).toBe(1)
|
|
84
|
+
expect(log.editMessageText[0]?.text).toContain('✗')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('finalize is idempotent', async () => {
|
|
88
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
89
|
+
const bot = makeStubBot(log)
|
|
90
|
+
const t = new ProgressTracker(bot, 999)
|
|
91
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1' })
|
|
92
|
+
await t.finalize()
|
|
93
|
+
await t.finalize()
|
|
94
|
+
await t.finalize()
|
|
95
|
+
expect(log.sendMessage.length).toBe(1)
|
|
96
|
+
// No additional edits triggered by repeated finalize.
|
|
97
|
+
expect(log.editMessageText.length).toBeLessThanOrEqual(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('flood error flips canEdit off and falls back to sendMessage', async () => {
|
|
101
|
+
const log: CallLog = {
|
|
102
|
+
sendMessage: [],
|
|
103
|
+
editMessageText: [],
|
|
104
|
+
rejectEditNTimes: 99,
|
|
105
|
+
editError: 'Bad Request: 429 Too Many Requests',
|
|
106
|
+
}
|
|
107
|
+
const bot = makeStubBot(log)
|
|
108
|
+
const t = new ProgressTracker(bot, 999)
|
|
109
|
+
// 1st push: sendMessage (new message)
|
|
110
|
+
await t.push({ kind: 'start', tool: 'shell.run', callId: 'c1' })
|
|
111
|
+
expect(log.sendMessage.length).toBe(1)
|
|
112
|
+
// Wait past throttle to force an edit attempt on next push.
|
|
113
|
+
await new Promise(r => setTimeout(r, PROGRESS_EDIT_INTERVAL + 50))
|
|
114
|
+
// 2nd push triggers editMessageText, which rejects with 429 → canEdit=false
|
|
115
|
+
await t.push({ kind: 'start', tool: 'web.fetch', callId: 'c2' })
|
|
116
|
+
// Wait past throttle again so 3rd push goes through.
|
|
117
|
+
await new Promise(r => setTimeout(r, PROGRESS_EDIT_INTERVAL + 50))
|
|
118
|
+
// 3rd push: now canEdit=false, falls back to sendMessage of the latest line.
|
|
119
|
+
await t.push({ kind: 'start', tool: 'fs.read', callId: 'c3' })
|
|
120
|
+
expect(log.sendMessage.length).toBe(2)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('end event before start event is silently ignored', async () => {
|
|
124
|
+
const log: CallLog = { sendMessage: [], editMessageText: [] }
|
|
125
|
+
const bot = makeStubBot(log)
|
|
126
|
+
const t = new ProgressTracker(bot, 999)
|
|
127
|
+
// Receiving an 'end' for a callId we never saw 'start' for.
|
|
128
|
+
await t.push({ kind: 'end', tool: 'shell.run', callId: 'unknown', ok: true })
|
|
129
|
+
expect(log.sendMessage.length).toBe(0)
|
|
130
|
+
expect(t.hasRendered()).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
})
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call progress tracker for live TG dispatch surfacing.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors hermes' `send_progress_messages` (run.py:7070-7272): the agent's
|
|
5
|
+
* tool calls accumulate into a single "scratch" TG message that gets edited
|
|
6
|
+
* in place as the brain progresses through the turn. Final answer arrives
|
|
7
|
+
* later as a separate message.
|
|
8
|
+
*
|
|
9
|
+
* Behavior:
|
|
10
|
+
* - First `push` sends a new message and saves messageId.
|
|
11
|
+
* - Subsequent pushes within the throttle window are coalesced — a single
|
|
12
|
+
* trailing edit fires after the throttle elapses.
|
|
13
|
+
* - On a TG flood error (HTTP 429), `canEdit` flips off and remaining
|
|
14
|
+
* pushes go as separate messages instead of edits.
|
|
15
|
+
* - All errors swallowed: progress is best-effort, never blocks dispatch.
|
|
16
|
+
* - `finalize()` is idempotent and forces any pending edit to flush.
|
|
17
|
+
*
|
|
18
|
+
* Tool emoji mapping is a small allowlist; everything else gets the wrench.
|
|
19
|
+
* Args preview is provided by the brain via `BrainToolEvent.argsPreview`
|
|
20
|
+
* (see `previewToolArgs` in og-compute.ts).
|
|
21
|
+
*/
|
|
22
|
+
import type { Bot } from 'grammy'
|
|
23
|
+
import { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
|
|
24
|
+
|
|
25
|
+
const PROGRESS_EDIT_INTERVAL_MS = 1_500
|
|
26
|
+
/** TG hard cap is 4096; keep margin for `(N/N)` suffix and edit growth. */
|
|
27
|
+
const PROGRESS_TEXT_CAP = 3_800
|
|
28
|
+
|
|
29
|
+
const TOOL_EMOJI: Record<string, string> = {
|
|
30
|
+
'shell.run': '💻',
|
|
31
|
+
'shell.cd': '📁',
|
|
32
|
+
'shell.process_start': '🚀',
|
|
33
|
+
'shell.process_output': '📥',
|
|
34
|
+
'shell.process_list': '📋',
|
|
35
|
+
'shell.process_kill': '🛑',
|
|
36
|
+
'fs.read': '📄',
|
|
37
|
+
'fs.write': '✏️',
|
|
38
|
+
'fs.patch': '🩹',
|
|
39
|
+
'fs.search': '🔍',
|
|
40
|
+
'web.fetch': '🌐',
|
|
41
|
+
'browser.navigate': '🌐',
|
|
42
|
+
'browser.snapshot': '📸',
|
|
43
|
+
'browser.click': '🖱️',
|
|
44
|
+
'browser.type': '⌨️',
|
|
45
|
+
'browser.scroll': '🖱️',
|
|
46
|
+
'browser.back': '⬅️',
|
|
47
|
+
'browser.press': '⌨️',
|
|
48
|
+
'browser.get_images': '🖼️',
|
|
49
|
+
'browser.console': '🛠',
|
|
50
|
+
'browser.vision': '👁',
|
|
51
|
+
'memory.read': '🧠',
|
|
52
|
+
'memory.save': '💾',
|
|
53
|
+
todo: '📝',
|
|
54
|
+
clarify: '❓',
|
|
55
|
+
'skills.list': '📚',
|
|
56
|
+
'skills.view': '📖',
|
|
57
|
+
'skills.manage': '🛠',
|
|
58
|
+
'session.search': '🔎',
|
|
59
|
+
'code.execute': '🐍',
|
|
60
|
+
'vision.analyze': '👁',
|
|
61
|
+
'delegate.task': '🤝',
|
|
62
|
+
'tool.search': '🔧',
|
|
63
|
+
'chain.gas': '⛽',
|
|
64
|
+
'chain.balance': '💰',
|
|
65
|
+
'chain.contract': '📜',
|
|
66
|
+
'chain.tx': '📝',
|
|
67
|
+
'wallet.transfer': '💸',
|
|
68
|
+
'swap.quote': '🔁',
|
|
69
|
+
'swap.execute': '🔄',
|
|
70
|
+
'stake.delegate': '🥩',
|
|
71
|
+
'comms.send': '📨',
|
|
72
|
+
'comms.list': '📬',
|
|
73
|
+
'market.list': '🛒',
|
|
74
|
+
'market.bid': '🪙',
|
|
75
|
+
'account.info': 'ℹ️',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ProgressEvent {
|
|
79
|
+
kind: 'start' | 'end'
|
|
80
|
+
tool: string
|
|
81
|
+
callId: string
|
|
82
|
+
argsPreview?: string
|
|
83
|
+
ok?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class ProgressTracker {
|
|
87
|
+
private messageId: number | null = null
|
|
88
|
+
/** Map of callId → line index in `lines` so 'end' events can mark ✓/✗. */
|
|
89
|
+
private callIndex = new Map<string, number>()
|
|
90
|
+
private lines: string[] = []
|
|
91
|
+
private lastEditTs = 0
|
|
92
|
+
/** Last text we successfully sent or edited; used to skip no-op flushes. */
|
|
93
|
+
private lastFlushedText = ''
|
|
94
|
+
private canEdit = true
|
|
95
|
+
private pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
96
|
+
private finalized = false
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly bot: Bot,
|
|
100
|
+
private readonly chatId: number,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Add an event to the progress timeline. Drives a sendMessage on first
|
|
105
|
+
* call, editMessageText on subsequent calls (throttled at 1.5s).
|
|
106
|
+
*
|
|
107
|
+
* Returns the in-flight flush promise so dispatch can `await tracker.push`
|
|
108
|
+
* if it wants strict ordering, but normal use is fire-and-forget.
|
|
109
|
+
*/
|
|
110
|
+
async push(ev: ProgressEvent): Promise<void> {
|
|
111
|
+
if (this.finalized) return
|
|
112
|
+
if (ev.kind === 'start') {
|
|
113
|
+
const line = formatStartLine(ev)
|
|
114
|
+
this.callIndex.set(ev.callId, this.lines.length)
|
|
115
|
+
this.lines.push(line)
|
|
116
|
+
} else {
|
|
117
|
+
const idx = this.callIndex.get(ev.callId)
|
|
118
|
+
if (idx == null || this.lines[idx] == null) return
|
|
119
|
+
this.lines[idx] = `${this.lines[idx]} ${ev.ok === false ? '✗' : '✓'}`
|
|
120
|
+
}
|
|
121
|
+
await this.flush()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Force any pending throttled edit to fire NOW, then mark the tracker
|
|
126
|
+
* closed. Future pushes are no-ops.
|
|
127
|
+
*/
|
|
128
|
+
async finalize(): Promise<void> {
|
|
129
|
+
if (this.finalized) return
|
|
130
|
+
if (this.pendingTimer) {
|
|
131
|
+
clearTimeout(this.pendingTimer)
|
|
132
|
+
this.pendingTimer = null
|
|
133
|
+
}
|
|
134
|
+
await this.flush(true)
|
|
135
|
+
this.finalized = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the tracker has rendered anything yet. Used by the listener to
|
|
140
|
+
* decide whether to skip the final reply ("..." sandwich UX).
|
|
141
|
+
*/
|
|
142
|
+
hasRendered(): boolean {
|
|
143
|
+
return this.messageId !== null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async flush(force = false): Promise<void> {
|
|
147
|
+
if (this.lines.length === 0) return
|
|
148
|
+
const text = capProgressText(this.lines.join('\n'))
|
|
149
|
+
// Skip no-op flushes: nothing changed since the last send/edit.
|
|
150
|
+
if (text === this.lastFlushedText) return
|
|
151
|
+
const remaining = PROGRESS_EDIT_INTERVAL_MS - (Date.now() - this.lastEditTs)
|
|
152
|
+
if (!force && remaining > 0 && this.messageId !== null) {
|
|
153
|
+
// Throttle: schedule one trailing edit if not already pending.
|
|
154
|
+
if (!this.pendingTimer) {
|
|
155
|
+
this.pendingTimer = setTimeout(() => {
|
|
156
|
+
this.pendingTimer = null
|
|
157
|
+
void this.flush()
|
|
158
|
+
}, remaining)
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
this.pendingTimer = null
|
|
163
|
+
const md = escapeMarkdownV2(text)
|
|
164
|
+
try {
|
|
165
|
+
if (this.messageId === null) {
|
|
166
|
+
const sent = await this.bot.api.sendMessage(this.chatId, md, {
|
|
167
|
+
parse_mode: 'MarkdownV2',
|
|
168
|
+
})
|
|
169
|
+
this.messageId = sent.message_id
|
|
170
|
+
} else if (this.canEdit) {
|
|
171
|
+
await this.bot.api.editMessageText(this.chatId, this.messageId, md, {
|
|
172
|
+
parse_mode: 'MarkdownV2',
|
|
173
|
+
})
|
|
174
|
+
} else {
|
|
175
|
+
// Flood-mode fallback: append the latest line as a new message.
|
|
176
|
+
const lastLine = this.lines[this.lines.length - 1] ?? ''
|
|
177
|
+
await this.bot.api.sendMessage(this.chatId, escapeMarkdownV2(lastLine), {
|
|
178
|
+
parse_mode: 'MarkdownV2',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
this.lastEditTs = Date.now()
|
|
182
|
+
this.lastFlushedText = text
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const msg = String((err as Error).message ?? '').toLowerCase()
|
|
185
|
+
if (msg.includes('flood') || msg.includes('too many requests') || msg.includes('429')) {
|
|
186
|
+
this.canEdit = false
|
|
187
|
+
} else if (isMarkdownParseError(err)) {
|
|
188
|
+
// MarkdownV2 escape miss; retry as plain text once.
|
|
189
|
+
try {
|
|
190
|
+
const plain = stripMarkdownV2(text)
|
|
191
|
+
if (this.messageId === null) {
|
|
192
|
+
const sent = await this.bot.api.sendMessage(this.chatId, plain)
|
|
193
|
+
this.messageId = sent.message_id
|
|
194
|
+
} else {
|
|
195
|
+
await this.bot.api.editMessageText(this.chatId, this.messageId, plain)
|
|
196
|
+
}
|
|
197
|
+
this.lastEditTs = Date.now()
|
|
198
|
+
} catch {
|
|
199
|
+
/* swallow — never block dispatch */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// All other errors swallowed.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatStartLine(ev: ProgressEvent): string {
|
|
208
|
+
const emoji = TOOL_EMOJI[ev.tool] ?? '🔧'
|
|
209
|
+
if (ev.argsPreview && ev.argsPreview.length > 0) {
|
|
210
|
+
return `${emoji} ${ev.tool}: ${ev.argsPreview}`
|
|
211
|
+
}
|
|
212
|
+
return `${emoji} ${ev.tool}`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function capProgressText(text: string): string {
|
|
216
|
+
if (text.length <= PROGRESS_TEXT_CAP) return text
|
|
217
|
+
return `${text.slice(0, PROGRESS_TEXT_CAP - 1)}…`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const PROGRESS_EDIT_INTERVAL = PROGRESS_EDIT_INTERVAL_MS
|
package/src/types.ts
CHANGED
|
@@ -79,6 +79,15 @@ export interface TelegramApprovalBridge {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Tool-call lifecycle event observed by the TG dispatcher for live UI rendering. */
|
|
83
|
+
export interface TelegramToolEvent {
|
|
84
|
+
kind: 'start' | 'end'
|
|
85
|
+
tool: string
|
|
86
|
+
callId: string
|
|
87
|
+
argsPreview?: string
|
|
88
|
+
ok?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
export interface TelegramDispatchInput {
|
|
83
92
|
/** Composed text after debounce flush; safe to feed into brain prompt. */
|
|
84
93
|
text: string
|
|
@@ -94,6 +103,14 @@ export interface TelegramDispatchInput {
|
|
|
94
103
|
latestMessageId: number
|
|
95
104
|
/** Stable session key for this chat: `agent:<name>:telegram:dm:<chatId>`. */
|
|
96
105
|
sessionKey: string
|
|
106
|
+
/**
|
|
107
|
+
* Per-turn observer of tool-call lifecycle. Listener supplies this so it
|
|
108
|
+
* can stream progress to a TG message as the brain works through the turn.
|
|
109
|
+
* Dispatch implementation (chat-telegram.ts in local mode, build-runtime.ts
|
|
110
|
+
* in sandbox mode) must forward this to `brain.infer({ onToolEvent: ... })`.
|
|
111
|
+
* Errors swallowed; observer must NEVER block dispatch.
|
|
112
|
+
*/
|
|
113
|
+
onToolEvent?: (ev: TelegramToolEvent) => void
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
export interface TelegramDispatchResult {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { Bot } from 'grammy'
|
|
3
|
+
import { TYPING_REFRESH_INTERVAL_MS, startTypingLoop } from './typing'
|
|
4
|
+
|
|
5
|
+
function makeStubBot(callLog: { count: number; rejectOnce?: boolean }): Bot {
|
|
6
|
+
return {
|
|
7
|
+
api: {
|
|
8
|
+
sendChatAction: async (_chatId: number, action: string): Promise<true> => {
|
|
9
|
+
if (action !== 'typing') throw new Error(`unexpected action: ${action}`)
|
|
10
|
+
callLog.count += 1
|
|
11
|
+
if (callLog.rejectOnce) {
|
|
12
|
+
callLog.rejectOnce = false
|
|
13
|
+
throw new Error('429 too many requests')
|
|
14
|
+
}
|
|
15
|
+
return true as const
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
} as unknown as Bot
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('startTypingLoop', () => {
|
|
22
|
+
it('fires sendChatAction immediately on start', () => {
|
|
23
|
+
const log = { count: 0 }
|
|
24
|
+
const bot = makeStubBot(log)
|
|
25
|
+
const stop = startTypingLoop(bot, 1234)
|
|
26
|
+
stop()
|
|
27
|
+
expect(log.count).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('refreshes on the configured interval', async () => {
|
|
31
|
+
const log = { count: 0 }
|
|
32
|
+
const bot = makeStubBot(log)
|
|
33
|
+
const stop = startTypingLoop(bot, 1234)
|
|
34
|
+
// Wait 4.6s real time to see the immediate fire + first refresh.
|
|
35
|
+
await new Promise(r => setTimeout(r, TYPING_REFRESH_INTERVAL_MS + 100))
|
|
36
|
+
stop()
|
|
37
|
+
expect(log.count).toBeGreaterThanOrEqual(2)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('cancel fn stops further refreshes', async () => {
|
|
41
|
+
const log = { count: 0 }
|
|
42
|
+
const bot = makeStubBot(log)
|
|
43
|
+
const stop = startTypingLoop(bot, 1234)
|
|
44
|
+
stop()
|
|
45
|
+
await new Promise(r => setTimeout(r, TYPING_REFRESH_INTERVAL_MS + 100))
|
|
46
|
+
expect(log.count).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('survives sendChatAction failures', async () => {
|
|
50
|
+
const log = { count: 0, rejectOnce: true }
|
|
51
|
+
const bot = makeStubBot(log)
|
|
52
|
+
const stop = startTypingLoop(bot, 1234)
|
|
53
|
+
// First fire rejects (caught silently); subsequent refresh still happens.
|
|
54
|
+
await new Promise(r => setTimeout(r, TYPING_REFRESH_INTERVAL_MS + 100))
|
|
55
|
+
stop()
|
|
56
|
+
// Total fires: 1 (rejected) + 1 (refresh) = 2
|
|
57
|
+
expect(log.count).toBe(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('cancel fn is idempotent', () => {
|
|
61
|
+
const log = { count: 0 }
|
|
62
|
+
const bot = makeStubBot(log)
|
|
63
|
+
const stop = startTypingLoop(bot, 1234)
|
|
64
|
+
stop()
|
|
65
|
+
stop() // second call should not throw
|
|
66
|
+
stop() // third call should not throw
|
|
67
|
+
expect(log.count).toBe(1)
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/typing.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing-indicator loop for the active brain turn. TG's `chat_action="typing"`
|
|
3
|
+
* auto-expires after ~5 seconds, so we refresh on a 4.5s interval. Fires once
|
|
4
|
+
* immediately so the user sees `typing...` within the first message-handler tick.
|
|
5
|
+
*
|
|
6
|
+
* Errors are swallowed: if `sendChatAction` rate-limits or fails the network
|
|
7
|
+
* call, the loop keeps running and the brain dispatch must NEVER block on a
|
|
8
|
+
* cosmetic indicator.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors hermes' `_keep_typing` (gateway/platforms/base.py), but uses
|
|
11
|
+
* `setInterval` instead of an asyncio task. `clearInterval(timer)` from the
|
|
12
|
+
* returned cancel fn is idempotent.
|
|
13
|
+
*/
|
|
14
|
+
import type { Bot } from 'grammy'
|
|
15
|
+
|
|
16
|
+
const TYPING_REFRESH_MS = 4_500
|
|
17
|
+
|
|
18
|
+
export function startTypingLoop(bot: Bot, chatId: number): () => void {
|
|
19
|
+
const fire = (): void => {
|
|
20
|
+
void bot.api.sendChatAction(chatId, 'typing').catch(() => {
|
|
21
|
+
/* cosmetic; never block dispatch */
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
fire()
|
|
25
|
+
const timer = setInterval(fire, TYPING_REFRESH_MS)
|
|
26
|
+
return () => {
|
|
27
|
+
clearInterval(timer)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const TYPING_REFRESH_INTERVAL_MS = TYPING_REFRESH_MS
|