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/markdown.ts
ADDED
|
@@ -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
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -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
|
package/src/reactions.ts
ADDED
|
@@ -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
|
+
}
|
package/src/recovery.ts
ADDED
|
@@ -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
|
+
}
|