@s0nderlabs/anima-plugin-telegram 0.19.14 → 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.14",
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.14",
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',
package/src/listener.ts CHANGED
@@ -6,6 +6,7 @@ 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,
@@ -19,6 +20,7 @@ import { DELIVERY_FAILURE_NOTICE, sendWithRetry } from './retry'
19
20
  import { sanitizeInbound } from './sanitize'
20
21
  import { buildSessionKey } from './session-key'
21
22
  import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
23
+ import { startTypingLoop } from './typing'
22
24
 
23
25
  /**
24
26
  * Long-poll Telegram bot. Inbound DMs from allowedUserIds are debounced and
@@ -355,6 +357,17 @@ export class TelegramListener {
355
357
  /* never block on hook failures */
356
358
  }
357
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)
358
371
  let ok = true
359
372
  try {
360
373
  const input: TelegramDispatchInput = {
@@ -365,6 +378,9 @@ export class TelegramListener {
365
378
  displayName: batch.displayName,
366
379
  latestMessageId: messageId,
367
380
  sessionKey: buildSessionKey({ agentName: this.opts.agentName, chatId }),
381
+ onToolEvent: ev => {
382
+ void tracker.push(ev)
383
+ },
368
384
  }
369
385
  const channelText = formatTelegramChannel({
370
386
  chatId,
@@ -384,15 +400,26 @@ export class TelegramListener {
384
400
  const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''
385
401
  console.error(`[telegram] dispatch failed: ${msg.slice(0, 500)}${stack}`)
386
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.'
387
410
  try {
388
- await this.bot.api.sendMessage(
389
- chatId,
390
- 'sorry, something went wrong on my side. try again in a moment.',
391
- { reply_parameters: { message_id: messageId, allow_sending_without_reply: true } },
392
- )
411
+ await this.bot.api.sendMessage(chatId, replyText, {
412
+ reply_parameters: { message_id: messageId, allow_sending_without_reply: true },
413
+ })
393
414
  } catch {
394
415
  /* swallow */
395
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()
396
423
  }
397
424
  if (this.opts.onProcessingEnd) {
398
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