Skip to content

Commit 6efd0d1

Browse files
juancarlos.caveroclaude
andcommitted
Fix Telegram message formatting: tag-aware HTML chunking
Rewrote md_to_tg() following OpenClaw's approach: - Tag-aware HTML chunk splitting (fixes broken formatting on long messages) - Blockquote support (> text → <blockquote>) - File reference wrapping to prevent Telegram auto-linking (.md, .py, etc.) - More robust code block regex (tolerant of spacing around language) - Null byte block markers to avoid content collisions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4e72c25 commit 6efd0d1

1 file changed

Lines changed: 99 additions & 34 deletions

File tree

telegram-bot/bot.py

Lines changed: 99 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -47,65 +47,130 @@ def _is_allowed(update: Update) -> bool:
4747
# ── Markdown → Telegram HTML ────────────────────────────
4848

4949
def md_to_tg(text: str) -> str:
50-
"""Convert LLM markdown to Telegram HTML. Same approach as OpenClaw:
51-
markdown → HTML, tables → <pre>, retry as plain text if Telegram rejects."""
50+
"""Convert LLM markdown to Telegram HTML.
51+
Pipeline: extract protected blocks → escape HTML → apply formatting → restore blocks.
52+
Follows OpenClaw's approach: always HTML parse_mode, tag-aware chunking, plain text fallback."""
5253

53-
# First: extract code blocks and tables before escaping HTML
54-
blocks = {}
54+
blocks: dict[str, str] = {}
5555
counter = [0]
5656

57-
def save_block(content, tag="pre"):
58-
key = f"__BLOCK{counter[0]}__"
57+
def save_block(content: str, tag: str = "pre") -> str:
58+
key = f"\x00BLOCK{counter[0]}\x00"
5959
counter[0] += 1
6060
blocks[key] = f"<{tag}>{html.escape(content)}</{tag}>"
6161
return key
6262

63-
# Extract fenced code blocks
64-
def replace_code_block(m):
65-
lang = m.group(1)
66-
code = m.group(2)
67-
if lang:
68-
return save_block(code, "pre")
69-
return save_block(code, "pre")
70-
text = re.sub(r'```\w*\n(.*?)```', lambda m: save_block(m.group(1)), text, flags=re.DOTALL)
71-
72-
# Extract markdown tables → pre-formatted (Telegram doesn't support tables)
73-
def replace_table(m):
74-
return save_block(m.group(0))
75-
text = re.sub(r'(?:^\|.+\|$\n?)+', replace_table, text, flags=re.MULTILINE)
76-
77-
# Now escape HTML in the remaining text
63+
# 1. Extract fenced code blocks (``` with optional language, tolerant of spacing)
64+
text = re.sub(
65+
r'```[ \t]*\w*[ \t]*\n(.*?)```',
66+
lambda m: save_block(m.group(1)),
67+
text, flags=re.DOTALL,
68+
)
69+
70+
# 2. Extract markdown tables → pre-formatted
71+
text = re.sub(r'(?:^\|.+\|$\n?)+', lambda m: save_block(m.group(0)), text, flags=re.MULTILINE)
72+
73+
# 3. Escape HTML in remaining text
7874
text = html.escape(text)
7975

80-
# Inline code
76+
# 4. Inline code (before bold/italic to avoid conflicts)
8177
text = re.sub(r'`([^`\n]+)`', r'<code>\1</code>', text)
8278

83-
# Bold: **text**
84-
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
79+
# 5. Bold: **text**
80+
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text, flags=re.DOTALL)
8581

86-
# Italic: *text*
82+
# 6. Italic: *text* (not preceded/followed by *)
8783
text = re.sub(r'(?<!\*)\*([^*\n]+)\*(?!\*)', r'<i>\1</i>', text)
8884

89-
# Strikethrough: ~~text~~
85+
# 7. Strikethrough: ~~text~~
9086
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
9187

92-
# Headers → bold
88+
# 8. Headers → bold
9389
text = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', text, flags=re.MULTILINE)
9490

95-
# Bullet lists
91+
# 9. Blockquotes: > text → <blockquote>
92+
text = re.sub(
93+
r'(^&gt; .+(?:\n&gt; .+)*)',
94+
lambda m: '<blockquote>' + re.sub(r'^&gt; ', '', m.group(0), flags=re.MULTILINE) + '</blockquote>',
95+
text, flags=re.MULTILINE,
96+
)
97+
98+
# 10. Bullet lists
9699
text = re.sub(r'^[-•]\s+', '• ', text, flags=re.MULTILINE)
97100

98-
# Links: [text](url) → <a href="url">text</a>
101+
# 11. Numbered lists: clean up "1. " → "1. " (preserve but normalize)
102+
text = re.sub(r'^(\d+)\.\s+', r'\1. ', text, flags=re.MULTILINE)
103+
104+
# 12. Links: [text](url) → <a href="url">text</a>
99105
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
100106

101-
# Restore saved blocks
102-
for key, block in blocks.items():
103-
text = text.replace(html.escape(key), block)
104-
text = text.replace(key, block)
107+
# 13. Wrap bare file references in <code> to prevent Telegram auto-linking
108+
# (e.g. README.md → <code>README.md</code>, but skip if already inside a tag)
109+
text = re.sub(
110+
r'(?<![<\w/])(\b[\w./-]+\.(?:md|ts|tsx|js|jsx|py|rs|go|yaml|yml|toml|json|sh|css|html|sql|env|lock|cfg|txt|csv|xml))\b(?![^<]*>)',
111+
r'<code>\1</code>',
112+
text,
113+
)
114+
115+
# 14. Restore saved blocks
116+
for key, block_html in blocks.items():
117+
text = text.replace(html.escape(key), block_html)
118+
text = text.replace(key, block_html)
105119

106120
return text
107121

108122

123+
def _split_html_chunks(text: str, max_len: int = 4000) -> list[str]:
124+
"""Split Telegram HTML into chunks, re-opening/closing tags at boundaries.
125+
Ensures each chunk is valid HTML that Telegram can parse."""
126+
if len(text) <= max_len:
127+
return [text]
128+
129+
# Tags that Telegram supports
130+
TAG_RE = re.compile(r'<(/?)(\w[\w-]*)(?:\s[^>]*)?>|([^<]+|<)', re.DOTALL)
131+
VOID_TAGS = {'br', 'hr', 'img'}
132+
133+
chunks: list[str] = []
134+
open_tags: list[str] = [] # stack of currently open tag names
135+
current = ''
136+
137+
for m in TAG_RE.finditer(text):
138+
token = m.group(0)
139+
is_close = m.group(1) == '/'
140+
tag_name = m.group(2)
141+
142+
# Check if adding this token would exceed the limit
143+
# Account for closing tags we'd need to add
144+
close_overhead = sum(len(f'</{t}>') for t in reversed(open_tags))
145+
if len(current) + len(token) + close_overhead > max_len and current.strip():
146+
# Close open tags in this chunk
147+
for t in reversed(open_tags):
148+
current += f'</{t}>'
149+
chunks.append(current)
150+
# Re-open tags in the next chunk
151+
current = ''
152+
for t in open_tags:
153+
current += f'<{t}>'
154+
155+
current += token
156+
157+
# Track open/close tags
158+
if tag_name and tag_name not in VOID_TAGS:
159+
if is_close:
160+
if open_tags and open_tags[-1] == tag_name:
161+
open_tags.pop()
162+
else:
163+
open_tags.append(tag_name)
164+
165+
# Flush remaining
166+
if current.strip():
167+
for t in reversed(open_tags):
168+
current += f'</{t}>'
169+
chunks.append(current)
170+
171+
return chunks or [text]
172+
173+
109174
# ── Commands ────────────────────────────────────────────
110175

111176
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
@@ -290,7 +355,7 @@ def _send_text(chat_id: str, text: str):
290355
return
291356

292357
formatted = md_to_tg(text)
293-
chunks = [formatted[i:i + 4000] for i in range(0, len(formatted), 4000)] if len(formatted) > 4000 else [formatted]
358+
chunks = _split_html_chunks(formatted)
294359

295360
for chunk in chunks:
296361
async def _do_send(c=chunk):

0 commit comments

Comments
 (0)