- Fix typing indicator not showing: clean up stale typing tasks between messages and use dynamic event lookup in callbacks instead of capturing a specific event at creation time - Fix stream-json input format: use nested message object for stdin NDJSON - Switch --system-prompt to --append-system-prompt so Claude Code's default system prompt (with model identity) is preserved - Add --dangerously-skip-permissions for full tool access in subprocess - Use full model ID (claude-sonnet-4-5-20250929) in default persona Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
166 lines
5.3 KiB
Python
166 lines
5.3 KiB
Python
"""
|
|
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
|