"""Converts GitHub-flavored Markdown to Telegram-compatible HTML. Telegram supports a limited subset of HTML tags: , , , , ,
, , 
, . This module strips everything else and maps common Markdown constructs to the supported tags. """ import re # HTML tags that Telegram Bot API supports (case-insensitive tag names) _ALLOWED_TAGS: frozenset[str] = frozenset( { 'b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre', 'a', 'blockquote', 'tg-spoiler', 'tg-emoji', } ) # Regex to match any HTML tag (opening, closing, or self-closing) _HTML_TAG_RE: re.Pattern[str] = re.compile(r'<(/?)(\w[\w-]*)((?:\s+[^>]*)?)(/?)>', re.IGNORECASE) def _strip_unsupported_html(text: str) -> str: """Remove HTML tags that Telegram does not support, keeping only allowed ones.""" def _replace_tag(match: re.Match[str]) -> str: tag_name = match.group(2).lower() if tag_name in _ALLOWED_TAGS: return match.group(0) return '' return _HTML_TAG_RE.sub(_replace_tag, text) def _escape_html(text: str) -> str: """Escape characters that conflict with Telegram HTML parsing. Only escapes `&`, `<`, `>` that are NOT already part of allowed HTML tags. We run this BEFORE markdown conversion so markdown symbols are still intact. """ # Escape ampersands that are not already HTML entities text = re.sub(r'&(?!amp;|lt;|gt;|quot;|#\d+;)', '&', text) return text def github_markdown_to_telegram_html(text: str) -> str: """Convert GitHub-flavored Markdown to Telegram HTML. Handles: - ``## Header`` -> ``Header`` - ``**bold**`` / ``__bold__`` -> ``bold`` - ``*italic*`` / ``_italic_`` -> ``italic`` - `` `code` `` -> ``code`` - ``- item`` / ``* item`` -> ``bullet item`` - ``[text](url)`` -> ``text`` - Strips unsupported HTML tags """ if not text: return '' # Escape HTML-sensitive chars first (but preserve existing tags for later stripping) # We do a targeted escape: only bare < > that are NOT part of tags result = text # --- Code blocks (``` ... ```) -- protect from further processing --- code_blocks: list[str] = [] def _save_code_block(match: re.Match[str]) -> str: lang = match.group(1) or '' code = match.group(2) # Escape HTML inside code code = code.replace('&', '&').replace('<', '<').replace('>', '>') placeholder = f'\x00CODEBLOCK{len(code_blocks)}\x00' if lang: code_blocks.append(f'
{code}
') else: code_blocks.append(f'
{code}
') return placeholder result = re.sub(r'```(\w+)?\n(.*?)```', _save_code_block, result, flags=re.DOTALL) # --- Inline code (`...`) -- protect from further processing --- inline_codes: list[str] = [] def _save_inline_code(match: re.Match[str]) -> str: code = match.group(1) code = code.replace('&', '&').replace('<', '<').replace('>', '>') placeholder = f'\x00INLINECODE{len(inline_codes)}\x00' inline_codes.append(f'{code}') return placeholder result = re.sub(r'`([^`]+)`', _save_inline_code, result) # --- Escape remaining bare HTML entities --- result = _escape_html(result) # --- Headers: ## Header -> Header --- result = re.sub(r'^#{1,6}\s+(.+)$', r'\1', result, flags=re.MULTILINE) # --- Bold: **text** or __text__ -> text --- result = re.sub(r'\*\*(.+?)\*\*', r'\1', result) result = re.sub(r'__(.+?)__', r'\1', result) # --- Italic: *text* or _text_ -> text --- # Negative lookbehind/lookahead to avoid matching inside words with underscores result = re.sub(r'(?\1
', result) result = re.sub(r'(?\1', result) # --- Strikethrough: ~~text~~ -> text --- result = re.sub(r'~~(.+?)~~', r'\1', result) # --- Links: [text](url) -> text --- result = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', result) # --- Unordered lists: - item or * item -> bullet --- _BULLET = '\u2022' result = re.sub(r'^[\s]*[-*]\s+', f' {_BULLET} ', result, flags=re.MULTILINE) # --- Horizontal rules: --- or *** or ___ --- result = re.sub(r'^[-*_]{3,}\s*$', '', result, flags=re.MULTILINE) # --- Images: ![alt](url) -> just alt text --- result = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', result) # --- Strip unsupported HTML tags --- result = _strip_unsupported_html(result) # --- Restore code blocks --- for i, block in enumerate(code_blocks): result = result.replace(f'\x00CODEBLOCK{i}\x00', block) for i, code in enumerate(inline_codes): result = result.replace(f'\x00INLINECODE{i}\x00', code) # --- Clean up excessive blank lines (max 2 consecutive) --- result = re.sub(r'\n{3,}', '\n\n', result) return result.strip() def _close_open_tags(html: str) -> str: """Find unclosed HTML tags and append closing tags in reverse order.""" open_tags: list[str] = [] for match in _HTML_TAG_RE.finditer(html): is_closing = match.group(1) == '/' is_self_closing = match.group(4) == '/' tag_name = match.group(2).lower() if is_self_closing: continue if is_closing: if open_tags and open_tags[-1] == tag_name: open_tags.pop() else: open_tags.append(tag_name) # Close remaining open tags in reverse order for tag in reversed(open_tags): html += f'' return html def truncate_for_blockquote( description_html: str, *, message_prefix: str, message_suffix: str, max_message_length: int = 4096, ellipsis: str = '...', ) -> str: """Truncate description HTML to fit within Telegram message limit inside a blockquote. Calculates available space by subtracting prefix/suffix lengths and blockquote tag overhead from the total message limit. Args: description_html: The already-converted HTML description. message_prefix: Everything before the blockquote in the message. message_suffix: Everything after the blockquote in the message. max_message_length: Telegram message character limit (default 4096). ellipsis: String to append when truncating. Returns: The (possibly truncated) description HTML ready to be placed inside ``
...
``. """ blockquote_open = '
' blockquote_close = '
' overhead = len(blockquote_open) + len(blockquote_close) available = max_message_length - len(message_prefix) - len(message_suffix) - overhead # Leave a small safety margin for any off-by-one with Telegram entity counting available -= 20 if available <= 0: return ellipsis if len(description_html) <= available: return description_html # Reserve space for ellipsis, then iteratively truncate until # the result (with closing tags) fits within the budget. budget = available - len(ellipsis) truncated = description_html[:budget] # If we broke an HTML tag, backtrack to before it last_open = truncated.rfind('<') last_close = truncated.rfind('>') if last_open > last_close: truncated = truncated[:last_open] # Close any unclosed HTML tags to avoid Telegram parse errors closed = _close_open_tags(truncated) # If closing tags pushed us over budget, trim more text while len(closed) + len(ellipsis) > available and len(truncated) > 0: truncated = truncated[:-20] if len(truncated) > 20 else '' last_open = truncated.rfind('<') last_close = truncated.rfind('>') if last_open > last_close: truncated = truncated[:last_open] closed = _close_open_tags(truncated) return closed.rstrip() + ellipsis