@@ -47,65 +47,130 @@ def _is_allowed(update: Update) -> bool:
4747# ── Markdown → Telegram HTML ────────────────────────────
4848
4949def 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"\x00 BLOCK { 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'(^> .+(?:\n> .+)*)' ,
94+ lambda m : '<blockquote>' + re .sub (r'^> ' , '' , 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
111176async 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