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,211 @@
1
+ // MarkdownV2 escape + plain-text fallback + standard-markdown translator.
2
+ //
3
+ // The brain emits standard CommonMark (`**bold**`, `*italic*`, `` `code` ``,
4
+ // `# heading`, `[text](url)`, lists, blockquotes). Telegram MarkdownV2 has
5
+ // different syntax AND requires every reserved character outside formatting
6
+ // markers to be backslash-escaped. Sending the brain's text directly with
7
+ // `parse_mode: 'MarkdownV2'` either parse-errors or renders escape characters
8
+ // literally.
9
+ //
10
+ // `formatMarkdownV2(text)` is the canonical translator: protect code blocks
11
+ // and links behind placeholders, convert markdown structures to MarkdownV2
12
+ // equivalents, escape remaining reserved chars, restore placeholders. Ported
13
+ // from hermes telegram.py:format_message.
14
+ //
15
+ // `escapeMarkdownV2(text)` and `stripMarkdownV2(text)` remain available for
16
+ // callers that need raw escaping or a plain-text fallback when send fails.
17
+
18
+ const MARKDOWN_V2_ESCAPE_REGEX = /([_*[\]()~`>#+\-=|{}.!\\])/g
19
+
20
+ export function escapeMarkdownV2(text: string): string {
21
+ return text.replace(MARKDOWN_V2_ESCAPE_REGEX, '\\$1')
22
+ }
23
+
24
+ /**
25
+ * Strip MarkdownV2 markers so a parse_error fallback can send the same content
26
+ * as plain text. Handles the four common formatting markers (`*bold*`,
27
+ * `_italic_`, `~strike~`, `||spoiler||`) plus drops escape backslashes.
28
+ *
29
+ * Code-block markers stay (TG renders them as plain text without parse_mode).
30
+ */
31
+ export function stripMarkdownV2(text: string): string {
32
+ let out = text
33
+ out = out.replace(/\\([_*[\]()~`>#+\-=|{}.!\\])/g, '$1')
34
+ out = out.replace(/\|\|([^|]+)\|\|/g, '$1')
35
+ out = out.replace(/\*([^*]+)\*/g, '$1')
36
+ out = out.replace(/(?:^|[\s])_([^_]+)_(?=[\s]|$)/g, ' $1')
37
+ out = out.replace(/~([^~]+)~/g, '$1')
38
+ return out
39
+ }
40
+
41
+ /**
42
+ * Detect if a grammy / Bot API error is a MarkdownV2 parse error so callers
43
+ * can fall back to plain-text. Hermes pattern: the error message contains
44
+ * "can't parse entities" with the MarkdownV2 mention.
45
+ */
46
+ export function isMarkdownParseError(err: unknown): boolean {
47
+ const msg = err instanceof Error ? err.message : String(err)
48
+ const lower = msg.toLowerCase()
49
+ return (
50
+ lower.includes("can't parse entities") ||
51
+ lower.includes('cannot parse entities') ||
52
+ (lower.includes('bad request') && (lower.includes('parse') || lower.includes('entities')))
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Translate standard CommonMark (what the brain emits) into Telegram MarkdownV2.
58
+ * Ports hermes `format_message` (telegram.py:1838-1993). Strategy: stash code
59
+ * spans and links behind NUL-bracketed placeholders, rewrite formatting
60
+ * markers, then escape every reserved char in the remaining plain text and
61
+ * restore placeholders. The trailing safety pass catches stray `( ) { }` that
62
+ * survived the dance, while leaving link parens intact.
63
+ */
64
+ export function formatMarkdownV2(content: string): string {
65
+ if (!content) return content
66
+
67
+ // GFM tables don't render in MarkdownV2 — pipes show literally and columns
68
+ // misalign. Wrap detected table blocks in ``` fences so TG renders them
69
+ // monospace + preserves column alignment. Detection: a line starting with
70
+ // `|` followed by a separator row (`|---|---|`) makes the start of a table;
71
+ // contiguous `|...|` lines are pulled in until the first non-table line.
72
+ const wrapped = wrapGfmTablesInCodeBlocks(content)
73
+
74
+ const placeholders: string[] = []
75
+ const ph = (value: string): string => {
76
+ const key = `\x00PH${placeholders.length}\x00`
77
+ placeholders.push(value)
78
+ return key
79
+ }
80
+
81
+ let text = wrapped
82
+
83
+ text = text.replace(/```(?:[^\n]*\n)?[\s\S]*?```/g, raw => {
84
+ const newlineIdx = raw.indexOf('\n', 3)
85
+ const headerEnd = newlineIdx === -1 ? 3 : newlineIdx + 1
86
+ const header = raw.slice(0, headerEnd)
87
+ const body = raw.slice(headerEnd, raw.length - 3)
88
+ const escaped = body.replace(/\\/g, '\\\\').replace(/`/g, '\\`')
89
+ return ph(`${header}${escaped}\`\`\``)
90
+ })
91
+
92
+ text = text.replace(/`[^`\n]+`/g, raw => ph(raw.replace(/\\/g, '\\\\')))
93
+
94
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, display: string, url: string) => {
95
+ const safeDisplay = escapeMarkdownV2(display)
96
+ const safeUrl = url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
97
+ return ph(`[${safeDisplay}](${safeUrl})`)
98
+ })
99
+
100
+ text = text.replace(/^(#{1,6})\s+(.+)$/gm, (_match, _hashes, inner: string) => {
101
+ const cleaned = inner.trim().replace(/\*\*(.+?)\*\*/g, '$1')
102
+ return ph(`*${escapeMarkdownV2(cleaned)}*`)
103
+ })
104
+
105
+ text = text.replace(/\*\*(.+?)\*\*/g, (_match, inner: string) =>
106
+ ph(`*${escapeMarkdownV2(inner)}*`),
107
+ )
108
+
109
+ text = text.replace(/\*([^*\n]+)\*/g, (_match, inner: string) =>
110
+ ph(`_${escapeMarkdownV2(inner)}_`),
111
+ )
112
+
113
+ text = text.replace(/~~(.+?)~~/g, (_match, inner: string) => ph(`~${escapeMarkdownV2(inner)}~`))
114
+
115
+ text = text.replace(/\|\|(.+?)\|\|/g, (_match, inner: string) =>
116
+ ph(`||${escapeMarkdownV2(inner)}||`),
117
+ )
118
+
119
+ text = text.replace(/^(>{1,3}) (.+)$/gm, (_match, marker: string, body: string) =>
120
+ ph(`${marker} ${escapeMarkdownV2(body)}`),
121
+ )
122
+
123
+ text = escapeMarkdownV2(text)
124
+
125
+ for (let i = placeholders.length - 1; i >= 0; i--) {
126
+ const value = placeholders[i] ?? ''
127
+ text = text.replace(`\x00PH${i}\x00`, value)
128
+ }
129
+
130
+ text = escapeStrayParens(text)
131
+
132
+ return text
133
+ }
134
+
135
+ /**
136
+ * Last-ditch pass over `( ) { }` that survived the placeholder dance. Runs
137
+ * outside code spans only — anything inside `` `…` `` or ``` ```…``` ``` is
138
+ * preserved verbatim. Mirrors hermes safety net at telegram.py:1957-1991.
139
+ */
140
+ function escapeStrayParens(text: string): string {
141
+ const segments = text.split(/(```[\s\S]*?```|`[^`]+`)/g)
142
+ return segments
143
+ .map((seg, idx) => {
144
+ if (idx % 2 === 1) return seg
145
+ return seg.replace(/[(){}]/g, (ch, offset: number) => {
146
+ if (offset > 0 && seg[offset - 1] === '\\') return ch
147
+ if (ch === '(' && offset > 0 && seg[offset - 1] === ']') return ch
148
+ if (ch === ')' && isInsideLinkUrl(seg, offset)) return ch
149
+ return `\\${ch}`
150
+ })
151
+ })
152
+ .join('')
153
+ }
154
+
155
+ /**
156
+ * Detect GFM table blocks in the brain's reply and wrap them in ``` fences.
157
+ * TG MarkdownV2 doesn't render tables; without the fence, pipes show literally
158
+ * and columns drift. With the fence, TG renders monospace + preserves the
159
+ * brain's space padding so columns line up.
160
+ *
161
+ * Table boundary: a `|...|` row immediately followed by a separator row
162
+ * `|---|---|` (or `:---:`, `---|---`, etc.) is the start. Contiguous `|...|`
163
+ * data rows are pulled into the same block. First non-pipe line ends it.
164
+ */
165
+ const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
166
+ const TABLE_ROW_RE = /^\s*\|.+\|?\s*$/
167
+
168
+ export function wrapGfmTablesInCodeBlocks(text: string): string {
169
+ const lines = text.split('\n')
170
+ const out: string[] = []
171
+ let i = 0
172
+ while (i < lines.length) {
173
+ const line = lines[i] ?? ''
174
+ if (
175
+ TABLE_ROW_RE.test(line) &&
176
+ i + 1 < lines.length &&
177
+ TABLE_SEPARATOR_RE.test(lines[i + 1] ?? '')
178
+ ) {
179
+ const block: string[] = [line, lines[i + 1] ?? '']
180
+ let j = i + 2
181
+ while (j < lines.length && TABLE_ROW_RE.test(lines[j] ?? '')) {
182
+ block.push(lines[j] ?? '')
183
+ j += 1
184
+ }
185
+ out.push('```')
186
+ out.push(...block)
187
+ out.push('```')
188
+ i = j
189
+ continue
190
+ }
191
+ out.push(line)
192
+ i += 1
193
+ }
194
+ return out.join('\n')
195
+ }
196
+
197
+ function isInsideLinkUrl(seg: string, closeIdx: number): boolean {
198
+ let depth = 0
199
+ for (let j = closeIdx - 1; j >= Math.max(closeIdx - 2000, 0); j--) {
200
+ const ch = seg[j]
201
+ if (ch === ')') {
202
+ depth += 1
203
+ continue
204
+ }
205
+ if (ch !== '(') continue
206
+ depth -= 1
207
+ if (depth >= 0) continue
208
+ return j > 0 && seg[j - 1] === ']'
209
+ }
210
+ return false
211
+ }
@@ -0,0 +1,31 @@
1
+ // Pairing-flow message formatter.
2
+ //
3
+ // When an unknown user DMs the bot, the listener replies with a pairing code
4
+ // they can give to the operator. The operator approves out-of-band via
5
+ // `nebula pairing approve telegram <code>`, which writes the user-id to
6
+ // `~/.nebula/agents/<id>/pairing/telegram-approved.json`. The next message
7
+ // from that user passes sanitize and reaches the brain.
8
+
9
+ export interface PairingMessageOpts {
10
+ code: string
11
+ agentName?: string
12
+ /** Optional override of the approval CLI hint. */
13
+ approveCommand?: string
14
+ }
15
+
16
+ export function formatPairingMessage(opts: PairingMessageOpts): string {
17
+ const cmd = opts.approveCommand ?? `nebula pairing approve telegram ${opts.code}`
18
+ const greeting = opts.agentName
19
+ ? `🔐 Hi! I'm ${opts.agentName} and I don't recognize you yet.`
20
+ : "🔐 Hi! I don't recognize you yet."
21
+ return [
22
+ greeting,
23
+ '',
24
+ `Your pairing code: ${opts.code}`,
25
+ '',
26
+ 'Send this code to the bot owner and ask them to approve you. They will run:',
27
+ ` ${cmd}`,
28
+ '',
29
+ "Codes expire in 1 hour. Once approved, send your next message and I'll respond.",
30
+ ].join('\n')
31
+ }
@@ -0,0 +1,240 @@
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
+ * Serialize all flush operations so the start-event's sendMessage finishes
99
+ * (assigning messageId) before any end-event's flush runs. Without this
100
+ * lock, fast tools that fire start+end within ~5ms (e.g. strict-deny path)
101
+ * would race two parallel sendMessage calls, producing a duplicate "tool
102
+ * starting" message followed by a separate "tool ended ✗" message instead
103
+ * of one in-place edit. v0.22.1 fix.
104
+ */
105
+ private flushLock: Promise<void> = Promise.resolve()
106
+
107
+ constructor(
108
+ private readonly bot: Bot,
109
+ private readonly chatId: number,
110
+ ) {}
111
+
112
+ /**
113
+ * Add an event to the progress timeline. Drives a sendMessage on first
114
+ * call, editMessageText on subsequent calls (throttled at 1.5s).
115
+ *
116
+ * Returns the in-flight flush promise so dispatch can `await tracker.push`
117
+ * if it wants strict ordering, but normal use is fire-and-forget.
118
+ */
119
+ async push(ev: ProgressEvent): Promise<void> {
120
+ if (this.finalized) return
121
+ if (ev.kind === 'start') {
122
+ const line = formatStartLine(ev)
123
+ this.callIndex.set(ev.callId, this.lines.length)
124
+ this.lines.push(line)
125
+ } else {
126
+ const idx = this.callIndex.get(ev.callId)
127
+ if (idx == null || this.lines[idx] == null) return
128
+ this.lines[idx] = `${this.lines[idx]} ${ev.ok === false ? '✗' : '✓'}`
129
+ }
130
+ // Serialize: subsequent flushes wait for any in-flight sendMessage to
131
+ // resolve so the second flush sees the assigned messageId and routes to
132
+ // editMessageText, not a second sendMessage. v0.22.1 fix for fast-tool
133
+ // double-message regression.
134
+ const previous = this.flushLock
135
+ this.flushLock = previous.then(() => this.flush()).catch(() => {})
136
+ await this.flushLock
137
+ }
138
+
139
+ /**
140
+ * Force any pending throttled edit to fire NOW, then mark the tracker
141
+ * closed. Future pushes are no-ops.
142
+ */
143
+ async finalize(): Promise<void> {
144
+ if (this.finalized) return
145
+ if (this.pendingTimer) {
146
+ clearTimeout(this.pendingTimer)
147
+ this.pendingTimer = null
148
+ }
149
+ // Serialize through the lock so finalize doesn't race a still-flying
150
+ // push from the brain. Important for fast tools where end-event flush
151
+ // and finalize() race the same scratch message edit.
152
+ const previous = this.flushLock
153
+ this.flushLock = previous.then(() => this.flush(true)).catch(() => {})
154
+ await this.flushLock
155
+ this.finalized = true
156
+ }
157
+
158
+ /**
159
+ * Whether the tracker has rendered anything yet. Used by the listener to
160
+ * decide whether to skip the final reply ("..." sandwich UX).
161
+ */
162
+ hasRendered(): boolean {
163
+ return this.messageId !== null
164
+ }
165
+
166
+ private async flush(force = false): Promise<void> {
167
+ if (this.lines.length === 0) return
168
+ const text = capProgressText(this.lines.join('\n'))
169
+ // Skip no-op flushes: nothing changed since the last send/edit.
170
+ if (text === this.lastFlushedText) return
171
+ const remaining = PROGRESS_EDIT_INTERVAL_MS - (Date.now() - this.lastEditTs)
172
+ if (!force && remaining > 0 && this.messageId !== null) {
173
+ // Throttle: schedule one trailing edit if not already pending.
174
+ if (!this.pendingTimer) {
175
+ this.pendingTimer = setTimeout(() => {
176
+ this.pendingTimer = null
177
+ void this.flush()
178
+ }, remaining)
179
+ }
180
+ return
181
+ }
182
+ this.pendingTimer = null
183
+ const md = escapeMarkdownV2(text)
184
+ try {
185
+ if (this.messageId === null) {
186
+ const sent = await this.bot.api.sendMessage(this.chatId, md, {
187
+ parse_mode: 'MarkdownV2',
188
+ })
189
+ this.messageId = sent.message_id
190
+ } else if (this.canEdit) {
191
+ await this.bot.api.editMessageText(this.chatId, this.messageId, md, {
192
+ parse_mode: 'MarkdownV2',
193
+ })
194
+ } else {
195
+ // Flood-mode fallback: append the latest line as a new message.
196
+ const lastLine = this.lines[this.lines.length - 1] ?? ''
197
+ await this.bot.api.sendMessage(this.chatId, escapeMarkdownV2(lastLine), {
198
+ parse_mode: 'MarkdownV2',
199
+ })
200
+ }
201
+ this.lastEditTs = Date.now()
202
+ this.lastFlushedText = text
203
+ } catch (err) {
204
+ const msg = String((err as Error).message ?? '').toLowerCase()
205
+ if (msg.includes('flood') || msg.includes('too many requests') || msg.includes('429')) {
206
+ this.canEdit = false
207
+ } else if (isMarkdownParseError(err)) {
208
+ // MarkdownV2 escape miss; retry as plain text once.
209
+ try {
210
+ const plain = stripMarkdownV2(text)
211
+ if (this.messageId === null) {
212
+ const sent = await this.bot.api.sendMessage(this.chatId, plain)
213
+ this.messageId = sent.message_id
214
+ } else {
215
+ await this.bot.api.editMessageText(this.chatId, this.messageId, plain)
216
+ }
217
+ this.lastEditTs = Date.now()
218
+ } catch {
219
+ /* swallow — never block dispatch */
220
+ }
221
+ }
222
+ // All other errors swallowed.
223
+ }
224
+ }
225
+ }
226
+
227
+ function formatStartLine(ev: ProgressEvent): string {
228
+ const emoji = TOOL_EMOJI[ev.tool] ?? '🔧'
229
+ if (ev.argsPreview && ev.argsPreview.length > 0) {
230
+ return `${emoji} ${ev.tool}: ${ev.argsPreview}`
231
+ }
232
+ return `${emoji} ${ev.tool}`
233
+ }
234
+
235
+ function capProgressText(text: string): string {
236
+ if (text.length <= PROGRESS_TEXT_CAP) return text
237
+ return `${text.slice(0, PROGRESS_TEXT_CAP - 1)}…`
238
+ }
239
+
240
+ export const PROGRESS_EDIT_INTERVAL = PROGRESS_EDIT_INTERVAL_MS
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Atomic reaction state machine: 👀 (processing) → 👍 (success) | 👎 (error).
3
+ * Telegram's setMessageReaction REPLACES all bot reactions on the message in
4
+ * one call, so transitions are atomic. No remove step needed.
5
+ *
6
+ * If the bot doesn't have permission to react in the chat (rare for DMs but
7
+ * possible if the user blocks), the call fails silently and message handling
8
+ * continues. We never let a reaction failure abort a brain turn.
9
+ */
10
+ import type { Bot } from 'grammy'
11
+ import type { ReactionTypeEmoji } from 'grammy/types'
12
+
13
+ type Emoji = ReactionTypeEmoji['emoji']
14
+
15
+ export const REACTION_PROCESSING: Emoji = '\u{1F440}' // 👀
16
+ export const REACTION_OK: Emoji = '\u{1F44D}' // 👍
17
+ export const REACTION_ERR: Emoji = '\u{1F44E}' // 👎
18
+
19
+ export async function reactProcessing(bot: Bot, chatId: number, messageId: number): Promise<void> {
20
+ await safeSetReaction(bot, chatId, messageId, REACTION_PROCESSING)
21
+ }
22
+
23
+ export async function reactSuccess(bot: Bot, chatId: number, messageId: number): Promise<void> {
24
+ await safeSetReaction(bot, chatId, messageId, REACTION_OK)
25
+ }
26
+
27
+ export async function reactError(bot: Bot, chatId: number, messageId: number): Promise<void> {
28
+ await safeSetReaction(bot, chatId, messageId, REACTION_ERR)
29
+ }
30
+
31
+ export async function clearReaction(bot: Bot, chatId: number, messageId: number): Promise<void> {
32
+ await safeSetReactionEmpty(bot, chatId, messageId)
33
+ }
34
+
35
+ async function safeSetReaction(
36
+ bot: Bot,
37
+ chatId: number,
38
+ messageId: number,
39
+ emoji: Emoji,
40
+ ): Promise<void> {
41
+ try {
42
+ await bot.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
43
+ } catch {
44
+ // Reaction failures are cosmetic; never block the turn on them.
45
+ }
46
+ }
47
+
48
+ async function safeSetReactionEmpty(bot: Bot, chatId: number, messageId: number): Promise<void> {
49
+ try {
50
+ await bot.api.setMessageReaction(chatId, messageId, [])
51
+ } catch {
52
+ // Same: silent.
53
+ }
54
+ }
@@ -0,0 +1,131 @@
1
+ // Listener recovery primitives.
2
+ //
3
+ // Token lock: only one nebula process per machine can poll a given bot token.
4
+ // Cross-machine collisions (laptop + sandbox both polling same bot) still
5
+ // produce 409 Conflict on bot.start. We classify those failures so callers
6
+ // can decide retry/abort. The 3-retry-409 + 10-retry-network state machines
7
+ // hermes runs internally are deferred to v0.19 when we adopt @grammyjs/runner
8
+ // for finer-grained polling control. For v0.18.x the lock plus explicit
9
+ // classification is sufficient.
10
+
11
+ import type { Bot } from 'grammy'
12
+ import {
13
+ type ClearStaleScopedLockResult,
14
+ DEFAULT_LOCK_TTL_SECONDS,
15
+ type ScopedLockHandle,
16
+ acquireScopedLock,
17
+ clearStaleScopedLock,
18
+ } from 'nebula-ai-core'
19
+
20
+ export const TELEGRAM_TOKEN_LOCK_SCOPE = 'telegram-bot-token'
21
+
22
+ export class BotTokenLockedError extends Error {
23
+ readonly heldByPid: number
24
+ readonly heldSinceSec: number
25
+ constructor(pid: number, sinceSec: number) {
26
+ super(`telegram bot token already in use by pid ${pid} (started ${sinceSec})`)
27
+ this.name = 'BotTokenLockedError'
28
+ this.heldByPid = pid
29
+ this.heldSinceSec = sinceSec
30
+ }
31
+ }
32
+
33
+ export interface AcquireTokenLockOpts {
34
+ agentId?: string
35
+ ttl?: number
36
+ rootDir?: string
37
+ }
38
+
39
+ export interface TokenLock {
40
+ release: () => void
41
+ refresh: () => boolean
42
+ }
43
+
44
+ export function acquireTelegramTokenLock(
45
+ botToken: string,
46
+ opts: AcquireTokenLockOpts = {},
47
+ ): TokenLock {
48
+ const identity = `${opts.agentId ?? 'default'}:${botToken}`
49
+ const result = acquireScopedLock({
50
+ scope: TELEGRAM_TOKEN_LOCK_SCOPE,
51
+ identity,
52
+ ttl: opts.ttl ?? DEFAULT_LOCK_TTL_SECONDS,
53
+ rootDir: opts.rootDir,
54
+ })
55
+ if (!result.acquired || !result.handle) {
56
+ const ex = result.existing ?? { pid: -1, startedAt: 0, updatedAt: 0 }
57
+ throw new BotTokenLockedError(ex.pid, ex.startedAt)
58
+ }
59
+ return wrapLockHandle(result.handle)
60
+ }
61
+
62
+ function wrapLockHandle(handle: ScopedLockHandle): TokenLock {
63
+ return { release: handle.releaseFn, refresh: handle.refreshFn }
64
+ }
65
+
66
+ /**
67
+ * v0.21.5: gateway boot must proactively reap a zombie/crashed listener's
68
+ * bot-token lock before TelegramListener.start() runs. Without this, the
69
+ * acquire path calls scheduleStartRetry which waits 30s × 12 attempts (6 min)
70
+ * for the TTL to expire — operators see "TG silent" the whole time.
71
+ *
72
+ * Returns whether a stale lock was cleared. Never deletes a lock held by a
73
+ * live foreign PID (that's the legitimate "another nebula is polling this bot"
74
+ * case, where the listener should fail loud).
75
+ *
76
+ * Identity hash matches `acquireTelegramTokenLock`: `${agentId ?? 'default'}:${botToken}`.
77
+ */
78
+ export function clearStaleTelegramTokenLock(
79
+ botToken: string,
80
+ opts: AcquireTokenLockOpts = {},
81
+ ): ClearStaleScopedLockResult {
82
+ const identity = `${opts.agentId ?? 'default'}:${botToken}`
83
+ return clearStaleScopedLock({
84
+ scope: TELEGRAM_TOKEN_LOCK_SCOPE,
85
+ identity,
86
+ rootDir: opts.rootDir,
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Pre-polling webhook clear. grammy does this internally on bot.start, but
92
+ * making it explicit lets us surface failures (rare but possible if someone
93
+ * sets a webhook between init and start_polling).
94
+ */
95
+ export async function clearWebhookBeforePolling(bot: Bot): Promise<void> {
96
+ try {
97
+ await bot.api.deleteWebhook({ drop_pending_updates: false })
98
+ } catch {
99
+ // Best-effort; grammy retries on bot.start. Caller can opt in to logging.
100
+ }
101
+ }
102
+
103
+ export type StartFailureKind = 'conflict' | 'network' | 'auth' | 'fatal' | 'cancelled'
104
+
105
+ export interface StartFailure {
106
+ kind: StartFailureKind
107
+ detail: string
108
+ retryable: boolean
109
+ }
110
+
111
+ export function classifyStartFailure(err: unknown): StartFailure {
112
+ const msg = err instanceof Error ? err.message : String(err)
113
+ const lower = msg.toLowerCase()
114
+ const code = (err as { error_code?: number }).error_code
115
+ if (code === 409) return { kind: 'conflict', detail: msg, retryable: true }
116
+ if (code === 401) return { kind: 'auth', detail: msg, retryable: false }
117
+ if (
118
+ lower.includes('econnreset') ||
119
+ lower.includes('econnrefused') ||
120
+ lower.includes('enetunreach') ||
121
+ lower.includes('socket hang up') ||
122
+ lower.includes('eai_again') ||
123
+ lower.includes('network')
124
+ ) {
125
+ return { kind: 'network', detail: msg, retryable: true }
126
+ }
127
+ if (lower.includes('aborted') || lower.includes('cancelled')) {
128
+ return { kind: 'cancelled', detail: msg, retryable: false }
129
+ }
130
+ return { kind: 'fatal', detail: msg, retryable: false }
131
+ }