@s0nderlabs/anima-plugin-telegram 0.19.14 → 0.19.16
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.ts +32 -5
- 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.16",
|
|
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.16",
|
|
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
|
-
|
|
390
|
-
|
|
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
|
+
})
|
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
|