@s0nderlabs/anima-plugin-telegram 0.19.11 → 0.19.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.19.11",
3
+ "version": "0.19.13",
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.11",
31
+ "@s0nderlabs/anima-core": "0.19.13",
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 { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
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 { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
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(escapeMarkdownV2(chunk))
371
+ const md = escapeChunkSuffixForMarkdownV2(formatMarkdownV2(chunk))
372
372
  try {
373
373
  await sendWithRetry(() =>
374
374
  this.bot.api.sendMessage(chatId, md, {
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { escapeMarkdownV2, isMarkdownParseError, stripMarkdownV2 } from './markdown'
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
- // Pattern from hermes telegram.py:84. The Bot API requires every reserved
4
- // character in MarkdownV2 entity ranges to be escaped with backslash, even
5
- // inside code blocks for some characters. This module exposes a single
6
- // `escapeMarkdownV2` function for the safe path and `stripMarkdownV2` for
7
- // the plain-text fallback when parse_error fires on send.
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
+ }