""" Telegram message formatting and UX utilities. Provides smart message splitting, MarkdownV2 escaping, and typing indicator management for the Telegram Claude Code bridge. Based on research in: .planning/phases/02-telegram-integration/02-RESEARCH.md """ import asyncio import logging import re from telegram.constants import ChatAction logger = logging.getLogger(__name__) TELEGRAM_MAX_LENGTH = 4096 SAFE_LENGTH = 4000 # Leave room for MarkdownV2 escape character expansion def split_message_smart(text: str, max_length: int = SAFE_LENGTH) -> list[str]: """ Split long message at smart boundaries, respecting MarkdownV2 code blocks. Never splits inside triple-backtick code blocks. Prefers paragraph breaks (\\n\\n), then line breaks (\\n), then hard character split as last resort. Uses 4000 as default max (not 4096) to leave room for MarkdownV2 escape character expansion. Args: text: Message text to split max_length: Maximum length per chunk (default: 4000) Returns: List of message chunks, each <= max_length Example: >>> split_message_smart("a" * 5000) ['aaa...', 'aaa...'] # Two chunks, each <= 4000 chars """ if len(text) <= max_length: return [text] chunks = [] current_chunk = "" in_code_block = False lines = text.split('\n') for line in lines: # Track code block state if line.strip().startswith('```'): in_code_block = not in_code_block # Check if adding this line exceeds limit potential_chunk = current_chunk + ('\n' if current_chunk else '') + line if len(potential_chunk) > max_length: # Would exceed limit if in_code_block: # Inside code block - must include whole block # (Telegram will handle overflow gracefully or we truncate) current_chunk = potential_chunk else: # Can split here if current_chunk: chunks.append(current_chunk) current_chunk = line else: current_chunk = potential_chunk if current_chunk: chunks.append(current_chunk) return chunks def escape_markdown_v2(text: str) -> str: """ Escape MarkdownV2 special characters outside of code blocks. Escapes 17 special characters: _ * [ ] ( ) ~ ` > # + - = | { } . ! BUT does NOT escape content inside code blocks (triple backticks or single backticks). Strategy: Split text by code regions, escape only non-code regions, rejoin. Args: text: Text to escape Returns: Text with MarkdownV2 special characters escaped outside code blocks Example: >>> escape_markdown_v2("hello_world") 'hello\\_world' >>> escape_markdown_v2("`hello_world`") '`hello_world`' # Inside backticks, not escaped """ # Characters that need escaping in MarkdownV2 escape_chars = r'_*[]()~`>#+-=|{}.!' # Pattern to match code blocks (triple backticks) and inline code (single backticks) # Match triple backticks first (```...```), then single backticks (`...`) code_pattern = re.compile(r'(```[\s\S]*?```|`[^`]*?`)', re.MULTILINE) # Split text into code and non-code segments parts = [] last_end = 0 for match in code_pattern.finditer(text): # Add non-code segment (escaped) non_code = text[last_end:match.start()] if non_code: # Escape special characters in non-code text escaped = re.sub(f'([{re.escape(escape_chars)}])', r'\\\1', non_code) parts.append(escaped) # Add code segment (not escaped) parts.append(match.group(0)) last_end = match.end() # Add remaining non-code segment if last_end < len(text): non_code = text[last_end:] escaped = re.sub(f'([{re.escape(escape_chars)}])', r'\\\1', non_code) parts.append(escaped) return ''.join(parts) async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event): """ Maintain typing indicator until stop_event is set. Sends ChatAction.TYPING every 4 seconds to keep indicator alive for operations longer than 5 seconds (Telegram expires typing after 5s). Uses asyncio.wait_for pattern with timeout to re-send every 4 seconds until stop_event is set. Args: bot: Telegram bot instance chat_id: Chat ID to send typing indicator to stop_event: asyncio.Event to signal when to stop Example: >>> stop_typing = asyncio.Event() >>> task = asyncio.create_task(typing_indicator_loop(bot, chat_id, stop_typing)) >>> # ... long operation ... >>> stop_typing.set() >>> await task """ while not stop_event.is_set(): try: result = await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) logger.info(f"Typing indicator sent to chat_id={chat_id}, result={result}") except Exception as e: logger.warning(f"Failed to send typing indicator: {e}") # Wait 4s or until stop_event (whichever comes first) try: await asyncio.wait_for(stop_event.wait(), timeout=4.0) break # stop_event was set except asyncio.TimeoutError: continue # Timeout, re-send typing indicator