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.
@@ -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 }