From febfe1c268a585ac571879b468504f3b9fc4e164 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sat, 21 Mar 2026 16:13:13 -0700 Subject: [PATCH] fix(telegram): escape bare parentheses/braces in MarkdownV2 output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MarkdownV2 format_message conversion left unescaped ( ) { } in edge cases where placeholder processing didn't cover them (e.g. partial link matches, URLs with parens). This caused Telegram to reject the message with 'character ( is reserved and must be escaped' and fall back to plain text — losing all formatting. Added a safety-net pass (step 12) after placeholder restoration that escapes any remaining bare ( ) { } outside code blocks and valid MarkdownV2 link syntax. --- gateway/platforms/telegram.py | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 1303fcdde..c0d8c13c3 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -935,6 +935,45 @@ class TelegramAdapter(BasePlatformAdapter): for key in reversed(list(placeholders.keys())): text = text.replace(key, placeholders[key]) + # 12) Safety net: escape unescaped ( ) { } that slipped through + # placeholder processing. Split the text into code/non-code + # segments so we never touch content inside ``` or ` spans. + _code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text) + _safe_parts = [] + for _idx, _seg in enumerate(_code_split): + if _idx % 2 == 1: + # Inside code span/block — leave untouched + _safe_parts.append(_seg) + else: + # Outside code — escape bare ( ) { } + def _esc_bare(m, _seg=_seg): + s = m.start() + ch = m.group(0) + # Already escaped + if s > 0 and _seg[s - 1] == '\\': + return ch + # ( that opens a MarkdownV2 link [text](url) + if ch == '(' and s > 0 and _seg[s - 1] == ']': + return ch + # ) that closes a link URL + if ch == ')': + before = _seg[:s] + if '](http' in before or '](' in before: + # Check depth + depth = 0 + for j in range(s - 1, max(s - 2000, -1), -1): + if _seg[j] == '(': + depth -= 1 + if depth < 0: + if j > 0 and _seg[j - 1] == ']': + return ch + break + elif _seg[j] == ')': + depth += 1 + return '\\' + ch + _safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg)) + text = ''.join(_safe_parts) + return text async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: