nebula-ai-plugin-telegram 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/package.json +46 -0
- package/src/approval-keyboard.ts +108 -0
- package/src/chunking.ts +63 -0
- package/src/commands.ts +28 -0
- package/src/debounce.ts +105 -0
- package/src/format.ts +64 -0
- package/src/guidance.ts +20 -0
- package/src/index.ts +125 -0
- package/src/limits.ts +60 -0
- package/src/listener.ts +612 -0
- package/src/markdown.ts +211 -0
- package/src/pairing-flow.ts +31 -0
- package/src/progress.ts +240 -0
- package/src/reactions.ts +54 -0
- package/src/recovery.ts +131 -0
- package/src/retry.ts +114 -0
- package/src/sanitize.ts +128 -0
- package/src/session-key.ts +37 -0
- package/src/session-state.ts +102 -0
- package/src/types.ts +144 -0
- package/src/typing.ts +31 -0
package/src/listener.ts
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { Bot, type Context, GrammyError, HttpError } from 'grammy'
|
|
2
|
+
import { type ApprovalChoice, parseCallbackData } from './approval-keyboard'
|
|
3
|
+
import { escapeChunkSuffixForMarkdownV2, splitMessage } from './chunking'
|
|
4
|
+
import { buildTelegramCommands } from './commands'
|
|
5
|
+
import { DebounceBuffer, type FlushedBatch } from './debounce'
|
|
6
|
+
import { formatTelegramChannel } from './format'
|
|
7
|
+
import { RateLimiter } from './limits'
|
|
8
|
+
import { formatMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
|
|
9
|
+
import { formatPairingMessage } from './pairing-flow'
|
|
10
|
+
import { ProgressTracker } from './progress'
|
|
11
|
+
import { reactError, reactProcessing, reactSuccess } from './reactions'
|
|
12
|
+
import {
|
|
13
|
+
BotTokenLockedError,
|
|
14
|
+
TELEGRAM_TOKEN_LOCK_SCOPE,
|
|
15
|
+
type TokenLock,
|
|
16
|
+
acquireTelegramTokenLock,
|
|
17
|
+
classifyStartFailure,
|
|
18
|
+
clearWebhookBeforePolling,
|
|
19
|
+
} from './recovery'
|
|
20
|
+
import { DELIVERY_FAILURE_NOTICE, sendWithRetry } from './retry'
|
|
21
|
+
import { sanitizeInbound } from './sanitize'
|
|
22
|
+
import { buildSessionKey } from './session-key'
|
|
23
|
+
import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
|
|
24
|
+
import { startTypingLoop } from './typing'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Long-poll Telegram bot. Inbound DMs from allowedUserIds are debounced and
|
|
28
|
+
* dispatched to the brain via `dispatchUserMessage`. Reactions transition
|
|
29
|
+
* from 👀 (processing) → 👍/👎 (success/error). Reply text is sent back via
|
|
30
|
+
* grammy's `bot.api.sendMessage` with retry-classified backoff.
|
|
31
|
+
*
|
|
32
|
+
* Lifecycle: `start()` acquires a host-wide token lock, clears any stale
|
|
33
|
+
* webhook, then boots grammy in long-poll mode. `stop()` releases the lock
|
|
34
|
+
* and stops the bot. Both are idempotent.
|
|
35
|
+
*/
|
|
36
|
+
/** Retry cadence + cap when the TG bot-token lock is held by a (possibly
|
|
37
|
+
* zombie) prior holder. 12 × 30s = 6 minutes, comfortably past the 5-minute
|
|
38
|
+
* lock TTL so a stale-but-tenable lock auto-evicts. */
|
|
39
|
+
const RETRY_INTERVAL_MS = 30_000
|
|
40
|
+
const MAX_LOCK_RETRY_ATTEMPTS = 12
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Map an `ApprovalChoice` to the human-readable resolution label appended
|
|
44
|
+
* to the approval message after a click. Mirrors the keyboard labels so
|
|
45
|
+
* operators see the same wording in the resolved message that they tapped.
|
|
46
|
+
*/
|
|
47
|
+
export function formatApprovalResolution(choice: ApprovalChoice, byUserId: number): string {
|
|
48
|
+
const label =
|
|
49
|
+
choice === 'once'
|
|
50
|
+
? '✅ Allowed once'
|
|
51
|
+
: choice === 'session'
|
|
52
|
+
? '✅ Allowed for session'
|
|
53
|
+
: choice === 'always'
|
|
54
|
+
? '✅ Always allowed'
|
|
55
|
+
: '❌ Denied'
|
|
56
|
+
return `${label} (by ${byUserId})`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Update kinds we ask Telegram to deliver via long-poll.
|
|
61
|
+
*
|
|
62
|
+
* `'message'` covers inbound DMs we dispatch to the brain. `'callback_query'`
|
|
63
|
+
* covers inline-keyboard taps (the [Allow Once / Session / Always / Deny]
|
|
64
|
+
* buttons rendered for tool approvals). Without `'callback_query'` here,
|
|
65
|
+
* Telegram silently filters the click events out of `getUpdates`, the
|
|
66
|
+
* `bot.on('callback_query:data', ...)` handler never fires, and operator
|
|
67
|
+
* taps register on the device but never reach the harness — the modal
|
|
68
|
+
* appears stuck and the brain's tool call hangs until timeout.
|
|
69
|
+
*
|
|
70
|
+
* Latent bug from v0.18.0 (the introduction of inline-keyboard approvals);
|
|
71
|
+
* v0.19.10 fixed handler registration but not the polling spec, which is
|
|
72
|
+
* why no live drive caught it before v0.19.18.
|
|
73
|
+
*/
|
|
74
|
+
export const TELEGRAM_ALLOWED_UPDATES = ['message', 'callback_query'] as const
|
|
75
|
+
|
|
76
|
+
export interface TelegramListenerOpts extends TelegramRuntimeContext {
|
|
77
|
+
/** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
|
|
78
|
+
apiRoot?: string
|
|
79
|
+
/** Optional per-user rate-limit. Default capacity=30, window=60s. */
|
|
80
|
+
rateLimit?: { capacity: number; windowMs: number }
|
|
81
|
+
/** Optional debounce window. Default 600ms. */
|
|
82
|
+
debounceMs?: number
|
|
83
|
+
/** Optional override of the locks dir (test only). */
|
|
84
|
+
lockRootDir?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class TelegramListener {
|
|
88
|
+
private readonly opts: TelegramListenerOpts
|
|
89
|
+
private readonly bot: Bot
|
|
90
|
+
private readonly limiter: RateLimiter
|
|
91
|
+
private readonly debounce: DebounceBuffer
|
|
92
|
+
private readonly inflight = new Map<number, Promise<void>>()
|
|
93
|
+
private running = false
|
|
94
|
+
private tokenLock: TokenLock | null = null
|
|
95
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
96
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
97
|
+
private retryAttempts = 0
|
|
98
|
+
private stopped = false
|
|
99
|
+
private approvalResolver:
|
|
100
|
+
| ((approvalId: string, choice: ApprovalChoice, fromUserId: number) => void)
|
|
101
|
+
| null = null
|
|
102
|
+
|
|
103
|
+
constructor(opts: TelegramListenerOpts) {
|
|
104
|
+
this.opts = opts
|
|
105
|
+
this.bot = new Bot(opts.botToken, opts.apiRoot ? { client: { apiRoot: opts.apiRoot } } : {})
|
|
106
|
+
this.limiter = new RateLimiter(opts.rateLimit)
|
|
107
|
+
this.debounce = new DebounceBuffer((chatId, batch) => this.handleFlushed(chatId, batch), {
|
|
108
|
+
quietPeriodMs: opts.debounceMs,
|
|
109
|
+
})
|
|
110
|
+
this.bot.on('message', ctx => this.onMessage(ctx))
|
|
111
|
+
// Register callback_query handler at construction time. grammY rejects
|
|
112
|
+
// late `bot.on()` registration once polling starts, so any approval
|
|
113
|
+
// resolver wiring must happen via the `approvalResolver` slot, not by
|
|
114
|
+
// calling `bot.on()` again. See approvalBridge.installCallbackHandler.
|
|
115
|
+
this.bot.on('callback_query:data', ctx => this.handleCallbackQuery(ctx))
|
|
116
|
+
this.bot.catch(err => {
|
|
117
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
118
|
+
this.log(`grammy.catch: ${msg.slice(0, 200)}`)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async handleCallbackQuery(ctx: Context): Promise<void> {
|
|
123
|
+
const q = ctx.callbackQuery
|
|
124
|
+
if (!q) return
|
|
125
|
+
console.log(
|
|
126
|
+
`[telegram] callback_query received from user=${q.from.id} data=${(q.data ?? '').slice(0, 80)}`,
|
|
127
|
+
)
|
|
128
|
+
const parsed = parseCallbackData(q.data)
|
|
129
|
+
if (!parsed) {
|
|
130
|
+
console.log(
|
|
131
|
+
`[telegram] callback_query dropped: malformed data=${(q.data ?? '').slice(0, 80)}`,
|
|
132
|
+
)
|
|
133
|
+
try {
|
|
134
|
+
await ctx.answerCallbackQuery({ text: 'malformed approval callback' })
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore */
|
|
137
|
+
}
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (this.opts.allowedUserIds.length > 0 && !this.opts.allowedUserIds.includes(q.from.id)) {
|
|
141
|
+
console.log(
|
|
142
|
+
`[telegram] callback_query dropped: unauthorized user=${q.from.id} (allowlist=${this.opts.allowedUserIds.join(',')})`,
|
|
143
|
+
)
|
|
144
|
+
try {
|
|
145
|
+
await ctx.answerCallbackQuery({ text: '⛔ You are not authorized to approve commands.' })
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const resolver = this.approvalResolver
|
|
152
|
+
if (!resolver) {
|
|
153
|
+
console.log(
|
|
154
|
+
`[telegram] callback_query dropped: no resolver pending for approval=${parsed.approvalId} choice=${parsed.choice}`,
|
|
155
|
+
)
|
|
156
|
+
try {
|
|
157
|
+
await ctx.answerCallbackQuery({ text: 'no approval pending' })
|
|
158
|
+
} catch {
|
|
159
|
+
/* ignore */
|
|
160
|
+
}
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
console.log(
|
|
164
|
+
`[telegram] callback_query resolved: approval=${parsed.approvalId} choice=${parsed.choice} from=${q.from.id}`,
|
|
165
|
+
)
|
|
166
|
+
resolver(parsed.approvalId, parsed.choice, q.from.id)
|
|
167
|
+
try {
|
|
168
|
+
await ctx.answerCallbackQuery({ text: `✓ ${parsed.choice}` })
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
// Resolve the modal visually: append the choice + drop the inline
|
|
173
|
+
// keyboard. Without this, every clicked approval message stays on
|
|
174
|
+
// screen with all four buttons, leaving operators unsure whether
|
|
175
|
+
// their tap registered. Best-effort — if the edit fails (rate
|
|
176
|
+
// limit, message age, deleted), the underlying approval is still
|
|
177
|
+
// resolved at the runtime level, so we swallow the error.
|
|
178
|
+
const originalText =
|
|
179
|
+
typeof q.message?.text === 'string' && q.message.text.length > 0 ? q.message.text : null
|
|
180
|
+
const suffix = formatApprovalResolution(parsed.choice, q.from.id)
|
|
181
|
+
try {
|
|
182
|
+
if (originalText) {
|
|
183
|
+
await ctx.editMessageText(`${originalText}\n\n${suffix}`, { reply_markup: undefined })
|
|
184
|
+
} else {
|
|
185
|
+
await ctx.editMessageReplyMarkup({ reply_markup: undefined })
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* ignore — modal was clicked, runtime already resolved; keyboard cleanup is cosmetic */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async start(): Promise<void> {
|
|
193
|
+
if (this.running || this.stopped) return
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
this.tokenLock = acquireTelegramTokenLock(this.opts.botToken, {
|
|
197
|
+
agentId: this.opts.agentName,
|
|
198
|
+
rootDir: this.opts.lockRootDir,
|
|
199
|
+
})
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Lock contention is recoverable: the prior holder may be a zombie or
|
|
202
|
+
// a stale lockfile from an ungraceful exit (see
|
|
203
|
+
// feedback-tg-token-lock-zombie-after-upgrade.md). Retry every 30s up
|
|
204
|
+
// to 12 attempts (6 minutes, past the 5-minute lock TTL) so we
|
|
205
|
+
// eventually reclaim once the existing entry expires. Without this,
|
|
206
|
+
// a single failed lock acquisition silenced the bot for the entire
|
|
207
|
+
// harness lifetime.
|
|
208
|
+
if (err instanceof BotTokenLockedError) {
|
|
209
|
+
console.warn(
|
|
210
|
+
`[telegram] cannot start listener: ${err.message}; will retry in ${RETRY_INTERVAL_MS / 1000}s`,
|
|
211
|
+
)
|
|
212
|
+
this.scheduleStartRetry()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
throw err
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.retryAttempts = 0
|
|
219
|
+
this.running = true
|
|
220
|
+
console.log(`[telegram] listener.start() called for @${this.opts.agentName}`)
|
|
221
|
+
|
|
222
|
+
if (this.opts.allowedUserIds.length === 0 && !this.opts.pairingStore) {
|
|
223
|
+
console.warn(
|
|
224
|
+
'[telegram] no allowlist configured AND no pairing store. ' +
|
|
225
|
+
'All inbound messages will be DROPPED. Configure allowedUserIds via ' +
|
|
226
|
+
'`nebula telegram setup` or enable pairing.',
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Wire approval bridge if the dispatcher provided one. The bridge has two
|
|
231
|
+
// slots: sendApproval (we fill with a closure over this.bot) and
|
|
232
|
+
// installCallbackHandler (we fill with a registrar over bot.on('callback_query')).
|
|
233
|
+
if (this.opts.approvalBridge) {
|
|
234
|
+
this.opts.approvalBridge.sendApproval.current = (chatId, text, approvalId) =>
|
|
235
|
+
this.sendApprovalMessage(chatId, text, approvalId)
|
|
236
|
+
this.opts.approvalBridge.installCallbackHandler.current = handler =>
|
|
237
|
+
this.installCallbackHandler(handler)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// v0.24.12: operator-notifier slot. Gateway fills it with brain clarify
|
|
241
|
+
// questions on autonomous market wakes; we broadcast to every allowed
|
|
242
|
+
// chat so the operator sees the question on their phone when no TUI is
|
|
243
|
+
// attached.
|
|
244
|
+
if (this.opts.operatorNotifier) {
|
|
245
|
+
this.opts.operatorNotifier.current = text => this.notifyOperators(text)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await clearWebhookBeforePolling(this.bot)
|
|
249
|
+
|
|
250
|
+
// v0.20.0: register the bot command menu so Telegram clients show
|
|
251
|
+
// the autocomplete list when the operator types `/`. Sourced from the
|
|
252
|
+
// shared registry; safe to call repeatedly (Telegram dedupes).
|
|
253
|
+
try {
|
|
254
|
+
await this.bot.api.setMyCommands(buildTelegramCommands(), {
|
|
255
|
+
scope: { type: 'default' },
|
|
256
|
+
})
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.warn(
|
|
259
|
+
`[telegram] setMyCommands failed (non-fatal): ${(err as Error).message?.slice(0, 200) ?? 'unknown'}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.refreshTimer = setInterval(() => {
|
|
264
|
+
if (this.tokenLock && !this.tokenLock.refresh()) {
|
|
265
|
+
console.warn('[telegram] token lock lost - stopping listener')
|
|
266
|
+
void this.stop()
|
|
267
|
+
}
|
|
268
|
+
}, 60_000)
|
|
269
|
+
|
|
270
|
+
void this.bot
|
|
271
|
+
.start({
|
|
272
|
+
onStart: info => console.log(`[telegram] listener active @${info.username}`),
|
|
273
|
+
drop_pending_updates: true,
|
|
274
|
+
allowed_updates: [...TELEGRAM_ALLOWED_UPDATES],
|
|
275
|
+
})
|
|
276
|
+
.catch(err => {
|
|
277
|
+
const verdict = classifyStartFailure(err)
|
|
278
|
+
console.error(`[telegram] bot.start ${verdict.kind}: ${verdict.detail.slice(0, 400)}`)
|
|
279
|
+
this.running = false
|
|
280
|
+
this.releaseLock()
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async stop(): Promise<void> {
|
|
285
|
+
this.stopped = true
|
|
286
|
+
if (this.retryTimer) {
|
|
287
|
+
clearTimeout(this.retryTimer)
|
|
288
|
+
this.retryTimer = null
|
|
289
|
+
}
|
|
290
|
+
if (this.opts.operatorNotifier) this.opts.operatorNotifier.current = null
|
|
291
|
+
if (!this.running) {
|
|
292
|
+
this.releaseLock()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
this.running = false
|
|
296
|
+
if (this.refreshTimer) {
|
|
297
|
+
clearInterval(this.refreshTimer)
|
|
298
|
+
this.refreshTimer = null
|
|
299
|
+
}
|
|
300
|
+
this.debounce.flushAll()
|
|
301
|
+
try {
|
|
302
|
+
await this.bot.stop()
|
|
303
|
+
} catch {
|
|
304
|
+
// grammy stop can throw if start hasn't completed; ignore.
|
|
305
|
+
}
|
|
306
|
+
await Promise.allSettled([...this.inflight.values()])
|
|
307
|
+
this.releaseLock()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* v0.24.12: broadcast a short text to every allowed operator chat. Used
|
|
312
|
+
* by the gateway to forward unsolicited brain prompts (clarify on
|
|
313
|
+
* autonomous market wakes) when no TUI is connected. Best-effort: per-chat
|
|
314
|
+
* failures are logged but don't stop the broadcast.
|
|
315
|
+
*/
|
|
316
|
+
private async notifyOperators(text: string): Promise<void> {
|
|
317
|
+
if (!this.running) return
|
|
318
|
+
const trimmed = text.trim()
|
|
319
|
+
if (trimmed.length === 0) return
|
|
320
|
+
const body = trimmed.length > 3500 ? `${trimmed.slice(0, 3500)}\n[truncated]` : trimmed
|
|
321
|
+
await Promise.allSettled(
|
|
322
|
+
this.opts.allowedUserIds.map(async chatId => {
|
|
323
|
+
try {
|
|
324
|
+
await this.bot.api.sendMessage(chatId, body)
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.warn(
|
|
327
|
+
`[telegram] notifyOperators chat=${chatId} failed: ${(err as Error).message?.slice(0, 200) ?? 'unknown'}`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private scheduleStartRetry(): void {
|
|
335
|
+
if (this.stopped) return
|
|
336
|
+
if (this.retryAttempts >= MAX_LOCK_RETRY_ATTEMPTS) {
|
|
337
|
+
console.error(
|
|
338
|
+
`[telegram] gave up acquiring bot-token lock after ${this.retryAttempts} attempts; manual intervention required (rm ~/.nebula/locks/${TELEGRAM_TOKEN_LOCK_SCOPE}-*.lock)`,
|
|
339
|
+
)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
if (this.retryTimer) clearTimeout(this.retryTimer)
|
|
343
|
+
this.retryAttempts += 1
|
|
344
|
+
this.retryTimer = setTimeout(() => {
|
|
345
|
+
this.retryTimer = null
|
|
346
|
+
void this.start()
|
|
347
|
+
}, RETRY_INTERVAL_MS)
|
|
348
|
+
this.retryTimer.unref?.()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private releaseLock(): void {
|
|
352
|
+
if (this.tokenLock) {
|
|
353
|
+
try {
|
|
354
|
+
this.tokenLock.release()
|
|
355
|
+
} catch {
|
|
356
|
+
/* best-effort */
|
|
357
|
+
}
|
|
358
|
+
this.tokenLock = null
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Send the inline-keyboard approval message. Used by the approval bridge. */
|
|
363
|
+
private async sendApprovalMessage(
|
|
364
|
+
chatId: number,
|
|
365
|
+
body: string,
|
|
366
|
+
approvalId: string,
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const { buildApprovalKeyboard } = await import('./approval-keyboard')
|
|
369
|
+
await sendWithRetry(() =>
|
|
370
|
+
this.bot.api.sendMessage(chatId, body, {
|
|
371
|
+
reply_markup: buildApprovalKeyboard(approvalId),
|
|
372
|
+
}),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Register the caller's approval resolver. The actual `bot.on('callback_query:data', ...)`
|
|
378
|
+
* middleware is installed once in the constructor (grammY rejects late
|
|
379
|
+
* registration after polling starts, so we cannot wire the handler lazily
|
|
380
|
+
* inside a dispatch turn). This method just swaps the resolver slot the
|
|
381
|
+
* pre-installed handler reads from. Returns a no-op uninstaller for
|
|
382
|
+
* back-compat with the previous API; teardown happens via `bot.stop()`.
|
|
383
|
+
*/
|
|
384
|
+
private installCallbackHandler(
|
|
385
|
+
onResolve: (approvalId: string, choice: ApprovalChoice, fromUserId: number) => void,
|
|
386
|
+
): () => void {
|
|
387
|
+
this.approvalResolver = onResolve
|
|
388
|
+
return () => {
|
|
389
|
+
this.approvalResolver = null
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Handle one inbound TG update. Sanitize → rate-limit → debounce.
|
|
395
|
+
* Errors here are swallowed (logged) so grammy stays alive.
|
|
396
|
+
*/
|
|
397
|
+
private async onMessage(ctx: Context): Promise<void> {
|
|
398
|
+
const msg = ctx.message
|
|
399
|
+
if (!msg) return
|
|
400
|
+
const sanitized = sanitizeInbound(
|
|
401
|
+
{
|
|
402
|
+
chatType: msg.chat.type,
|
|
403
|
+
chatId: msg.chat.id,
|
|
404
|
+
fromId: msg.from?.id ?? null,
|
|
405
|
+
fromIsBot: msg.from?.is_bot ?? false,
|
|
406
|
+
fromUsername: msg.from?.username ?? null,
|
|
407
|
+
fromFirstName: msg.from?.first_name ?? null,
|
|
408
|
+
fromLastName: msg.from?.last_name ?? null,
|
|
409
|
+
text: msg.text ?? msg.caption ?? null,
|
|
410
|
+
messageId: msg.message_id,
|
|
411
|
+
forwardedFrom:
|
|
412
|
+
(msg as { forward_from?: unknown; forward_origin?: unknown }).forward_from ??
|
|
413
|
+
(msg as { forward_origin?: unknown }).forward_origin ??
|
|
414
|
+
null,
|
|
415
|
+
mediaGroupId: msg.media_group_id ?? null,
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
allowedUserIds: this.opts.allowedUserIds,
|
|
419
|
+
pairingStore: this.opts.pairingStore,
|
|
420
|
+
},
|
|
421
|
+
)
|
|
422
|
+
if (!sanitized.ok) {
|
|
423
|
+
if (sanitized.action === 'send-pairing-code' && sanitized.code) {
|
|
424
|
+
const text = formatPairingMessage({
|
|
425
|
+
code: sanitized.code,
|
|
426
|
+
agentName: this.opts.agentName,
|
|
427
|
+
})
|
|
428
|
+
try {
|
|
429
|
+
await this.bot.api.sendMessage(msg.chat.id, text)
|
|
430
|
+
} catch (sendErr) {
|
|
431
|
+
this.log(`pairing-code send failed: ${(sendErr as Error).message?.slice(0, 200) ?? ''}`)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.log(`drop: ${sanitized.reason} from chat=${msg.chat.id}`)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const event = sanitized.event
|
|
438
|
+
if (this.limiter.shouldDrop(event.userId)) {
|
|
439
|
+
this.log(`rate-limit-drop user=${event.userId}`)
|
|
440
|
+
void reactError(this.bot, event.chatId, event.messageId)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
this.debounce.push(event.chatId, {
|
|
444
|
+
text: event.text,
|
|
445
|
+
messageId: event.messageId,
|
|
446
|
+
ts: event.ts,
|
|
447
|
+
userId: event.userId,
|
|
448
|
+
username: event.username,
|
|
449
|
+
displayName: event.displayName,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private handleFlushed(chatId: number, batch: FlushedBatch): void {
|
|
454
|
+
const existing = this.inflight.get(chatId)
|
|
455
|
+
const next = (existing ?? Promise.resolve()).then(() => this.dispatchOne(chatId, batch))
|
|
456
|
+
this.inflight.set(
|
|
457
|
+
chatId,
|
|
458
|
+
next.finally(() => {
|
|
459
|
+
if (this.inflight.get(chatId) === next) this.inflight.delete(chatId)
|
|
460
|
+
}),
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private async dispatchOne(chatId: number, batch: FlushedBatch): Promise<void> {
|
|
465
|
+
const messageId = batch.latestMessageId
|
|
466
|
+
void reactProcessing(this.bot, chatId, messageId)
|
|
467
|
+
if (this.opts.onProcessingStart) {
|
|
468
|
+
try {
|
|
469
|
+
await this.opts.onProcessingStart(chatId, messageId)
|
|
470
|
+
} catch {
|
|
471
|
+
/* never block on hook failures */
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Show "typing..." in the chat header for the duration of the brain turn.
|
|
475
|
+
// TG's chat action expires after ~5s, so the loop refreshes on a 4.5s
|
|
476
|
+
// interval. Cancel via try/finally so it stops in both happy and error
|
|
477
|
+
// paths. See `typing.ts` for the cancel-fn pattern.
|
|
478
|
+
const stopTyping = startTypingLoop(this.bot, chatId)
|
|
479
|
+
// Tool-call progress message: hermes-style scratch message that gets
|
|
480
|
+
// edited as the brain progresses through tools. See `progress.ts`.
|
|
481
|
+
// Always created; the tracker only sends a message when the brain
|
|
482
|
+
// actually fires a tool event, so prompts that go straight to a
|
|
483
|
+
// text answer don't get a noisy progress preamble.
|
|
484
|
+
const tracker = new ProgressTracker(this.bot, chatId)
|
|
485
|
+
let ok = true
|
|
486
|
+
try {
|
|
487
|
+
const input: TelegramDispatchInput = {
|
|
488
|
+
text: batch.text,
|
|
489
|
+
chatId,
|
|
490
|
+
userId: batch.userId,
|
|
491
|
+
username: batch.username,
|
|
492
|
+
displayName: batch.displayName,
|
|
493
|
+
latestMessageId: messageId,
|
|
494
|
+
sessionKey: buildSessionKey({ agentName: this.opts.agentName, chatId }),
|
|
495
|
+
onToolEvent: ev => {
|
|
496
|
+
void tracker.push(ev)
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
const channelText = formatTelegramChannel({
|
|
500
|
+
chatId,
|
|
501
|
+
username: batch.username,
|
|
502
|
+
displayName: batch.displayName,
|
|
503
|
+
text: batch.text,
|
|
504
|
+
})
|
|
505
|
+
const result = await this.opts.dispatchUserMessage({ ...input, text: channelText })
|
|
506
|
+
const reply = result.response.trim()
|
|
507
|
+
if (reply.length > 0) {
|
|
508
|
+
await this.sendChunked(chatId, reply, messageId)
|
|
509
|
+
}
|
|
510
|
+
void reactSuccess(this.bot, chatId, messageId)
|
|
511
|
+
} catch (err) {
|
|
512
|
+
ok = false
|
|
513
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
514
|
+
const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''
|
|
515
|
+
console.error(`[telegram] dispatch failed: ${msg.slice(0, 500)}${stack}`)
|
|
516
|
+
void reactError(this.bot, chatId, messageId)
|
|
517
|
+
// Translate LedgerInsufficientError into an actionable topup hint
|
|
518
|
+
// instead of the generic "something went wrong" reply. Detect by
|
|
519
|
+
// name (avoids requiring the plugin to import core's typed class).
|
|
520
|
+
const isLedger = err instanceof Error && err.name === 'LedgerInsufficientError'
|
|
521
|
+
const replyText = isLedger
|
|
522
|
+
? `⚠️ I need a top-up to keep working.\n\n${msg}`
|
|
523
|
+
: 'sorry, something went wrong on my side. try again in a moment.'
|
|
524
|
+
try {
|
|
525
|
+
await this.bot.api.sendMessage(chatId, replyText, {
|
|
526
|
+
reply_parameters: { message_id: messageId, allow_sending_without_reply: true },
|
|
527
|
+
})
|
|
528
|
+
} catch {
|
|
529
|
+
/* swallow */
|
|
530
|
+
}
|
|
531
|
+
} finally {
|
|
532
|
+
// Flush any pending throttled progress edit before clearing the
|
|
533
|
+
// typing loop. finalize() is idempotent, swallows errors, and is safe
|
|
534
|
+
// even if the tracker never rendered anything.
|
|
535
|
+
await tracker.finalize().catch(() => {})
|
|
536
|
+
stopTyping()
|
|
537
|
+
}
|
|
538
|
+
if (this.opts.onProcessingEnd) {
|
|
539
|
+
try {
|
|
540
|
+
await this.opts.onProcessingEnd(chatId, messageId, ok)
|
|
541
|
+
} catch {
|
|
542
|
+
/* never block */
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Send a (possibly long) reply with MarkdownV2 + chunking. Falls back to
|
|
549
|
+
* plain-text on parse_error. On retry exhaustion, sends the delivery-failure
|
|
550
|
+
* notice. First chunk is reply-linked; subsequent chunks are not.
|
|
551
|
+
*/
|
|
552
|
+
private async sendChunked(chatId: number, body: string, replyToMessageId: number): Promise<void> {
|
|
553
|
+
const chunks = splitMessage(body)
|
|
554
|
+
let firstSend = true
|
|
555
|
+
for (const chunk of chunks) {
|
|
556
|
+
const md = escapeChunkSuffixForMarkdownV2(formatMarkdownV2(chunk))
|
|
557
|
+
try {
|
|
558
|
+
await sendWithRetry(() =>
|
|
559
|
+
this.bot.api.sendMessage(chatId, md, {
|
|
560
|
+
parse_mode: 'MarkdownV2',
|
|
561
|
+
reply_parameters: firstSend
|
|
562
|
+
? { message_id: replyToMessageId, allow_sending_without_reply: true }
|
|
563
|
+
: undefined,
|
|
564
|
+
}),
|
|
565
|
+
)
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (isMarkdownParseError(err)) {
|
|
568
|
+
// Plain-text fallback for this chunk
|
|
569
|
+
try {
|
|
570
|
+
await sendWithRetry(() =>
|
|
571
|
+
this.bot.api.sendMessage(chatId, stripMarkdownV2(chunk), {
|
|
572
|
+
reply_parameters: firstSend
|
|
573
|
+
? { message_id: replyToMessageId, allow_sending_without_reply: true }
|
|
574
|
+
: undefined,
|
|
575
|
+
}),
|
|
576
|
+
)
|
|
577
|
+
} catch (fallbackErr) {
|
|
578
|
+
// Even plain-text failed; surface delivery-failure notice once.
|
|
579
|
+
this.log(`send fallback failed: ${(fallbackErr as Error).message?.slice(0, 200)}`)
|
|
580
|
+
try {
|
|
581
|
+
await this.bot.api.sendMessage(chatId, DELIVERY_FAILURE_NOTICE)
|
|
582
|
+
} catch {
|
|
583
|
+
/* best-effort */
|
|
584
|
+
}
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
} else {
|
|
588
|
+
this.log(`send failed: ${(err as Error).message?.slice(0, 200)}`)
|
|
589
|
+
try {
|
|
590
|
+
await this.bot.api.sendMessage(chatId, DELIVERY_FAILURE_NOTICE)
|
|
591
|
+
} catch {
|
|
592
|
+
/* best-effort */
|
|
593
|
+
}
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
firstSend = false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private log(line: string): void {
|
|
602
|
+
if (this.opts.debug) console.log(`[telegram] ${line}`)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Telegram caps messages at 4096 chars. We cap at 4000 to leave header room. */
|
|
607
|
+
export function capForTelegram(text: string): string {
|
|
608
|
+
if (text.length <= 4000) return text
|
|
609
|
+
return `${text.slice(0, 3970)}\n[reply truncated]`
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export { GrammyError, HttpError }
|