@s0nderlabs/anima-plugin-telegram 0.19.10 → 0.19.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +6 -1
- package/src/listener.ts +2 -2
- package/src/markdown.test.ts +98 -1
- package/src/markdown.ts +123 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@s0nderlabs/anima-plugin-telegram",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"test": "bun test"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@s0nderlabs/anima-core": "0.19.
|
|
31
|
+
"@s0nderlabs/anima-core": "0.19.12",
|
|
32
32
|
"grammy": "^1.42.0",
|
|
33
33
|
"zod": "^3.23.8"
|
|
34
34
|
}
|
package/src/index.ts
CHANGED
|
@@ -48,7 +48,12 @@ export {
|
|
|
48
48
|
type ParsedCallback,
|
|
49
49
|
type ResolveOutcome,
|
|
50
50
|
} from './approval-keyboard'
|
|
51
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
escapeMarkdownV2,
|
|
53
|
+
formatMarkdownV2,
|
|
54
|
+
isMarkdownParseError,
|
|
55
|
+
stripMarkdownV2,
|
|
56
|
+
} from './markdown'
|
|
52
57
|
export { escapeChunkSuffixForMarkdownV2, splitMessage, type SplitOpts } from './chunking'
|
|
53
58
|
export type { TelegramApprovalBridge, ApprovalChoiceKind } from './types'
|
|
54
59
|
export { DebounceBuffer } from './debounce'
|
package/src/listener.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { escapeChunkSuffixForMarkdownV2, splitMessage } from './chunking'
|
|
|
4
4
|
import { DebounceBuffer, type FlushedBatch } from './debounce'
|
|
5
5
|
import { formatTelegramChannel } from './format'
|
|
6
6
|
import { RateLimiter } from './limits'
|
|
7
|
-
import {
|
|
7
|
+
import { formatMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
|
|
8
8
|
import { formatPairingMessage } from './pairing-flow'
|
|
9
9
|
import { reactError, reactProcessing, reactSuccess } from './reactions'
|
|
10
10
|
import {
|
|
@@ -368,7 +368,7 @@ export class TelegramListener {
|
|
|
368
368
|
const chunks = splitMessage(body)
|
|
369
369
|
let firstSend = true
|
|
370
370
|
for (const chunk of chunks) {
|
|
371
|
-
const md = escapeChunkSuffixForMarkdownV2(
|
|
371
|
+
const md = escapeChunkSuffixForMarkdownV2(formatMarkdownV2(chunk))
|
|
372
372
|
try {
|
|
373
373
|
await sendWithRetry(() =>
|
|
374
374
|
this.bot.api.sendMessage(chatId, md, {
|
package/src/markdown.test.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
escapeMarkdownV2,
|
|
4
|
+
formatMarkdownV2,
|
|
5
|
+
isMarkdownParseError,
|
|
6
|
+
stripMarkdownV2,
|
|
7
|
+
} from './markdown'
|
|
3
8
|
|
|
4
9
|
describe('escapeMarkdownV2', () => {
|
|
5
10
|
it('escapes all reserved characters', () => {
|
|
@@ -48,6 +53,98 @@ describe('stripMarkdownV2', () => {
|
|
|
48
53
|
})
|
|
49
54
|
})
|
|
50
55
|
|
|
56
|
+
describe('formatMarkdownV2', () => {
|
|
57
|
+
it('passes through plain text but escapes reserved chars', () => {
|
|
58
|
+
expect(formatMarkdownV2('your balance is 0.0819 0G.')).toBe('your balance is 0\\.0819 0G\\.')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('translates **bold** into MarkdownV2 *bold*', () => {
|
|
62
|
+
expect(formatMarkdownV2('**balance**: 0.08 0G')).toBe('*balance*: 0\\.08 0G')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('translates *italic* into MarkdownV2 _italic_', () => {
|
|
66
|
+
expect(formatMarkdownV2('see *details* below.')).toBe('see _details_ below\\.')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps ** bold-with-inner-text translated', () => {
|
|
70
|
+
expect(formatMarkdownV2('**Your balance**: 0.0819 0G')).toBe('*Your balance*: 0\\.0819 0G')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('translates headers into bold', () => {
|
|
74
|
+
expect(formatMarkdownV2('# Title\nbody.')).toBe('*Title*\nbody\\.')
|
|
75
|
+
expect(formatMarkdownV2('## Sub Title\nmore.')).toBe('*Sub Title*\nmore\\.')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('strips redundant bold markers inside headers', () => {
|
|
79
|
+
expect(formatMarkdownV2('# **Hello**')).toBe('*Hello*')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('preserves inline code, escaping only backslashes inside', () => {
|
|
83
|
+
expect(formatMarkdownV2('use `0.5 0G` as the threshold')).toBe('use `0.5 0G` as the threshold')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('preserves fenced code blocks, escaping backticks and backslashes inside', () => {
|
|
87
|
+
expect(formatMarkdownV2('```\nfoo()\nbar\n```')).toBe('```\nfoo()\nbar\n```')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('preserves fenced code blocks with language hint', () => {
|
|
91
|
+
expect(formatMarkdownV2('```js\nconst x = 1;\n```')).toBe('```js\nconst x = 1;\n```')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('escapes backslashes inside fenced code', () => {
|
|
95
|
+
expect(formatMarkdownV2('```\npath\\to\nfile\n```')).toBe('```\npath\\\\to\nfile\n```')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('translates links with escaped display + URL', () => {
|
|
99
|
+
expect(formatMarkdownV2('[example](https://example.com/path)')).toBe(
|
|
100
|
+
'[example](https://example.com/path)',
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('escapes display text but not URL parens-friendly chars', () => {
|
|
105
|
+
expect(formatMarkdownV2('see [the docs](https://example.com)')).toBe(
|
|
106
|
+
'see [the docs](https://example.com)',
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('translates ~~strike~~ into MarkdownV2 ~strike~', () => {
|
|
111
|
+
expect(formatMarkdownV2('~~done~~')).toBe('~done~')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('preserves ||spoiler||', () => {
|
|
115
|
+
expect(formatMarkdownV2('||hidden||')).toBe('||hidden||')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('preserves blockquote markers', () => {
|
|
119
|
+
expect(formatMarkdownV2('> a quote here.')).toBe('> a quote here\\.')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('escapes stray parens and braces in plain text', () => {
|
|
123
|
+
expect(formatMarkdownV2('foo (bar) baz')).toBe('foo \\(bar\\) baz')
|
|
124
|
+
expect(formatMarkdownV2('use {x} for substitution')).toBe('use \\{x\\} for substitution')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does not escape parens that belong to a translated link', () => {
|
|
128
|
+
expect(formatMarkdownV2('see [docs](https://example.com/foo)')).toBe(
|
|
129
|
+
'see [docs](https://example.com/foo)',
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('handles real brain reply with mixed formatting', () => {
|
|
134
|
+
const input = 'Your balance: **0.0819 0G**. Wallet `0xd56b...9683`.'
|
|
135
|
+
const expected = 'Your balance: *0\\.0819 0G*\\. Wallet `0xd56b...9683`\\.'
|
|
136
|
+
expect(formatMarkdownV2(input)).toBe(expected)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('handles empty input', () => {
|
|
140
|
+
expect(formatMarkdownV2('')).toBe('')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('escapes backslashes inside inline code', () => {
|
|
144
|
+
expect(formatMarkdownV2('see `a\\b` for the regex')).toBe('see `a\\\\b` for the regex')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
51
148
|
describe('isMarkdownParseError', () => {
|
|
52
149
|
it('matches the canonical TG parse error string', () => {
|
|
53
150
|
expect(isMarkdownParseError(new Error("Bad Request: can't parse entities: ..."))).toBe(true)
|
package/src/markdown.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
// MarkdownV2 escape + plain-text fallback.
|
|
1
|
+
// MarkdownV2 escape + plain-text fallback + standard-markdown translator.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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.
|
|
8
17
|
|
|
9
18
|
const MARKDOWN_V2_ESCAPE_REGEX = /([_*[\]()~`>#+\-=|{}.!\\])/g
|
|
10
19
|
|
|
@@ -21,15 +30,10 @@ export function escapeMarkdownV2(text: string): string {
|
|
|
21
30
|
*/
|
|
22
31
|
export function stripMarkdownV2(text: string): string {
|
|
23
32
|
let out = text
|
|
24
|
-
// Drop escape backslashes that were applied by escapeMarkdownV2
|
|
25
33
|
out = out.replace(/\\([_*[\]()~`>#+\-=|{}.!\\])/g, '$1')
|
|
26
|
-
// Strip ||spoiler|| (must run before * and _ since `||` shares chars)
|
|
27
34
|
out = out.replace(/\|\|([^|]+)\|\|/g, '$1')
|
|
28
|
-
// Strip *bold* (greedy-safe since markdown only allows single-line *bold*)
|
|
29
35
|
out = out.replace(/\*([^*]+)\*/g, '$1')
|
|
30
|
-
// Strip _italic_
|
|
31
36
|
out = out.replace(/(?:^|[\s])_([^_]+)_(?=[\s]|$)/g, ' $1')
|
|
32
|
-
// Strip ~strike~
|
|
33
37
|
out = out.replace(/~([^~]+)~/g, '$1')
|
|
34
38
|
return out
|
|
35
39
|
}
|
|
@@ -48,3 +52,111 @@ export function isMarkdownParseError(err: unknown): boolean {
|
|
|
48
52
|
(lower.includes('bad request') && (lower.includes('parse') || lower.includes('entities')))
|
|
49
53
|
)
|
|
50
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
|
+
const placeholders: string[] = []
|
|
68
|
+
const ph = (value: string): string => {
|
|
69
|
+
const key = `\x00PH${placeholders.length}\x00`
|
|
70
|
+
placeholders.push(value)
|
|
71
|
+
return key
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let text = content
|
|
75
|
+
|
|
76
|
+
text = text.replace(/```(?:[^\n]*\n)?[\s\S]*?```/g, raw => {
|
|
77
|
+
const newlineIdx = raw.indexOf('\n', 3)
|
|
78
|
+
const headerEnd = newlineIdx === -1 ? 3 : newlineIdx + 1
|
|
79
|
+
const header = raw.slice(0, headerEnd)
|
|
80
|
+
const body = raw.slice(headerEnd, raw.length - 3)
|
|
81
|
+
const escaped = body.replace(/\\/g, '\\\\').replace(/`/g, '\\`')
|
|
82
|
+
return ph(`${header}${escaped}\`\`\``)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
text = text.replace(/`[^`\n]+`/g, raw => ph(raw.replace(/\\/g, '\\\\')))
|
|
86
|
+
|
|
87
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, display: string, url: string) => {
|
|
88
|
+
const safeDisplay = escapeMarkdownV2(display)
|
|
89
|
+
const safeUrl = url.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
|
|
90
|
+
return ph(`[${safeDisplay}](${safeUrl})`)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
text = text.replace(/^(#{1,6})\s+(.+)$/gm, (_match, _hashes, inner: string) => {
|
|
94
|
+
const cleaned = inner.trim().replace(/\*\*(.+?)\*\*/g, '$1')
|
|
95
|
+
return ph(`*${escapeMarkdownV2(cleaned)}*`)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
text = text.replace(/\*\*(.+?)\*\*/g, (_match, inner: string) =>
|
|
99
|
+
ph(`*${escapeMarkdownV2(inner)}*`),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
text = text.replace(/\*([^*\n]+)\*/g, (_match, inner: string) =>
|
|
103
|
+
ph(`_${escapeMarkdownV2(inner)}_`),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
text = text.replace(/~~(.+?)~~/g, (_match, inner: string) => ph(`~${escapeMarkdownV2(inner)}~`))
|
|
107
|
+
|
|
108
|
+
text = text.replace(/\|\|(.+?)\|\|/g, (_match, inner: string) =>
|
|
109
|
+
ph(`||${escapeMarkdownV2(inner)}||`),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
text = text.replace(/^(>{1,3}) (.+)$/gm, (_match, marker: string, body: string) =>
|
|
113
|
+
ph(`${marker} ${escapeMarkdownV2(body)}`),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
text = escapeMarkdownV2(text)
|
|
117
|
+
|
|
118
|
+
for (let i = placeholders.length - 1; i >= 0; i--) {
|
|
119
|
+
const value = placeholders[i] ?? ''
|
|
120
|
+
text = text.replace(`\x00PH${i}\x00`, value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
text = escapeStrayParens(text)
|
|
124
|
+
|
|
125
|
+
return text
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Last-ditch pass over `( ) { }` that survived the placeholder dance. Runs
|
|
130
|
+
* outside code spans only — anything inside `` `…` `` or ``` ```…``` ``` is
|
|
131
|
+
* preserved verbatim. Mirrors hermes safety net at telegram.py:1957-1991.
|
|
132
|
+
*/
|
|
133
|
+
function escapeStrayParens(text: string): string {
|
|
134
|
+
const segments = text.split(/(```[\s\S]*?```|`[^`]+`)/g)
|
|
135
|
+
return segments
|
|
136
|
+
.map((seg, idx) => {
|
|
137
|
+
if (idx % 2 === 1) return seg
|
|
138
|
+
return seg.replace(/[(){}]/g, (ch, offset: number) => {
|
|
139
|
+
if (offset > 0 && seg[offset - 1] === '\\') return ch
|
|
140
|
+
if (ch === '(' && offset > 0 && seg[offset - 1] === ']') return ch
|
|
141
|
+
if (ch === ')' && isInsideLinkUrl(seg, offset)) return ch
|
|
142
|
+
return `\\${ch}`
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
.join('')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isInsideLinkUrl(seg: string, closeIdx: number): boolean {
|
|
149
|
+
let depth = 0
|
|
150
|
+
for (let j = closeIdx - 1; j >= Math.max(closeIdx - 2000, 0); j--) {
|
|
151
|
+
const ch = seg[j]
|
|
152
|
+
if (ch === ')') {
|
|
153
|
+
depth += 1
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (ch !== '(') continue
|
|
157
|
+
depth -= 1
|
|
158
|
+
if (depth >= 0) continue
|
|
159
|
+
return j > 0 && seg[j - 1] === ']'
|
|
160
|
+
}
|
|
161
|
+
return false
|
|
162
|
+
}
|