@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.19.13",
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.13",
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(`[telegram] cannot start listener: ${err.message}`)
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
- chatId,
346
- 'sorry, something went wrong on my side. try again in a moment.',
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
+ })
@@ -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