feat(telegram): group reply-to + formatting pipeline cleanup

- Group reply matching: Bot quotes user's message when user replies to bot in groups; normal send for @mentions or private chats - System prompt formatting guidance: Added formatting rules block guiding the agent to use Telegram-compatible Markdown subset (no tables/HR/deep nesting) - md_to_telegram_html refactor: Simplified table handling (strip pipes vs complex bullet-list conversion), added ~~~ tilde fence support, ***bold italic*** support, strip trailing newlines from code blocks

keyboardstaff committed Mar 22, 2026 at 20:39 UTC 40a72c0c517f47db85f18990ef5059e28eebccba
3 files changed +73 -87
plugins/_telegram_integration/helpers/handler.py
+16 -4
@@ -34,6 +34,7 @@ CTX_TG_CHAT_ID = "telegram_chat_id"
34 CTX_TG_USER_ID = "telegram_user_id"
35 CTX_TG_USERNAME = "telegram_username"
36 CTX_TG_TYPING_STOP = "_telegram_typing_stop"
37 +CTX_TG_REPLY_TO = "_telegram_reply_to_message_id"
38
39 # Transient
40 CTX_TG_ATTACHMENTS = "_telegram_response_attachments"
@@ -221,6 +222,15 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
222 # Store stop event so send_telegram_reply can cancel typing
223 context.data[CTX_TG_TYPING_STOP] = typing_stop
224
225 + # In group chats, if user replied to the bot's message, reply to the user's message
226 + reply_to_id = None
227 + if message.chat.type != "private" and instance.bot_info:
228 + if (message.reply_to_message
229 + and message.reply_to_message.from_user
230 + and message.reply_to_message.from_user.id == instance.bot_info.id):
231 + reply_to_id = message.message_id
232 + context.data[CTX_TG_REPLY_TO] = reply_to_id
233 +
234 # Build user message text
235 text = _extract_message_content(message)
236
@@ -485,22 +495,24 @@ async def send_telegram_reply(
495 if typing_stop:
496 typing_stop.set()
497
498 + reply_to = context.data.pop(CTX_TG_REPLY_TO, None)
499 +
500 try:
501 async with _temp_bot(instance.bot.token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) as reply_bot:
502 if attachments:
503 for path in attachments:
504 local_path = files.fix_dev_path(path)
505 if tc.is_image_file(local_path):
494 - await tc.send_photo(reply_bot, chat_id, local_path)
506 + await tc.send_photo(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
507 else:
496 - await tc.send_file(reply_bot, chat_id, local_path)
508 + await tc.send_file(reply_bot, chat_id, local_path, reply_to_message_id=reply_to)
509
510 if response_text:
511 html_text = tc.md_to_telegram_html(response_text)
512 if keyboard:
501 - await tc.send_text_with_keyboard(reply_bot, chat_id, html_text, keyboard)
513 + await tc.send_text_with_keyboard(reply_bot, chat_id, html_text, keyboard, reply_to_message_id=reply_to)
514 else:
503 - await tc.send_text(reply_bot, chat_id, html_text)
515 + await tc.send_text(reply_bot, chat_id, html_text, reply_to_message_id=reply_to)
516
517 return None
518
plugins/_telegram_integration/helpers/telegram_client.py
+48 -83
@@ -218,11 +218,7 @@ def is_image_file(path: str) -> bool:
218
219
220 def md_to_telegram_html(text: str) -> str:
221 - """Convert standard Markdown to Telegram-compatible HTML.
222 -
223 - Handles: fenced code blocks, inline code, bold, italic, strikethrough,
224 - links, headings, blockquotes, tables, and nested lists.
225 - """
221 + """Convert Markdown to Telegram-compatible HTML."""
222 stash: list[str] = []
223
224 def _put(html: str) -> str:
@@ -232,109 +228,79 @@ def md_to_telegram_html(text: str) -> str:
228 def _esc(t: str) -> str:
229 return t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
230
235 - # Stash fenced code blocks
236 - def _code_block(m: re.Match) -> str:
237 - lang, code = m.group(1) or "", _esc(m.group(2))
238 - tag = f'<pre><code class="language-{lang}">{code}</code></pre>' if lang else f"<pre>{code}</pre>"
239 - return _put(tag)
231 + # Stash code blocks & inline code
232 + def _code_block(m):
233 + lang, body = m.group(1), m.group(2).rstrip("\n")
234 + if lang:
235 + return _put(f'<pre><code class="language-{lang}">{_esc(body)}</code></pre>')
236 + return _put(f"<pre>{_esc(body)}</pre>")
237
241 - text = re.sub(r"```(\w*)\n(.*?)```", _code_block, text, flags=re.DOTALL)
242 -
243 - # Stash inline code
238 + text = re.sub(r"(?:```|~~~)(\w*)\n?(.*?)(?:```|~~~)", _code_block, text, flags=re.DOTALL)
239 text = re.sub(r"`([^`]+)`", lambda m: _put(f"<code>{_esc(m.group(1))}</code>"), text)
240
246 - # Stash Markdown tables as list-style text
247 - text = _stash_tables(text, _put, _esc)
248 -
249 - # Escape remaining HTML chars
241 + # Strip unsupported syntax
242 + text = _strip_tables(text)
243 + text = re.sub(r"^[ \t]*[-*_=]{3,}[ \t]*$", "", text, flags=re.MULTILINE)
244 + text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"[\1](\2)", text)
245 +
246 + # Stash links
247 + text = re.sub(
248 + r"\[([^\]]+)\]\(([^)]+)\)",
249 + lambda m: _put(
250 + f'<a href="{m.group(2).replace("&", "&amp;").replace(chr(34), "&quot;")}">{_esc(m.group(1))}</a>'
251 + ),
252 + text,
253 + )
254 +
255 + # Escape HTML & apply inline formatting
256 text = _esc(text)
251 -
252 - # Inline formatting
257 + text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<b><i>\1</i></b>", text)
258 text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
259 text = re.sub(r"__(.+?)__", r"<b>\1</b>", text)
260 text = re.sub(r"(?<!\w)\*([^*]+?)\*(?!\w)", r"<i>\1</i>", text)
261 text = re.sub(r"(?<!\w)_([^_]+?)_(?!\w)", r"<i>\1</i>", text)
262 text = re.sub(r"~~(.+?)~~", r"<s>\1</s>", text)
258 - text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
263 text = re.sub(r"^#{1,6}\s+(.+)$", r"<b>\1</b>", text, flags=re.MULTILINE)
264
261 - # Blockquotes: > text → <blockquote>
265 + # Block-level formatting
266 text = _convert_blockquotes(text)
263 -
264 - # Lists (nested + ordered)
267 text = _convert_lists(text)
268
267 - # Restore all stashed blocks
269 + # Restore stash
270 for i, block in enumerate(stash):
271 text = text.replace(f"\x00B{i}\x00", block)
272 return text
273
274
273 -# ── Helpers for md_to_telegram_html ──
275
275 -_TABLE_ROW = re.compile(r"^\|(.+)\|$")
276 +_TABLE_RE = re.compile(r"^\|(.+)\|$")
277 _TABLE_SEP = re.compile(r"^[\s|:-]+$")
278
279
279 -def _stash_tables(text: str, put_fn, esc_fn) -> str:
280 - """Convert Markdown tables to key-value list format and stash them.
281 -
282 - | Name | Age |
283 - |-------|-----|
284 - | Alice | 30 | → • Name: Alice, Age: 30
285 - | Bob | 25 | • Name: Bob, Age: 25
286 - """
287 - lines = text.split("\n")
280 +def _strip_tables(text: str) -> str:
281 + """Strip Markdown table pipe syntax, keeping cell content as plain text."""
282 out: list[str] = []
289 - headers: list[str] = []
290 - data_rows: list[list[str]] = []
291 -
292 - def _flush():
293 - if not headers and not data_rows:
294 - return
295 - if headers and data_rows:
296 - items: list[str] = []
297 - for row in data_rows:
298 - pairs = ", ".join(
299 - f"{headers[i]}: {row[i]}" if i < len(row) else headers[i]
300 - for i in range(len(headers))
301 - )
302 - items.append(f"\u2022 {pairs}")
303 - out.append(put_fn(esc_fn("\n".join(items))))
304 - elif data_rows:
305 - # No header row — just bullet each row
306 - for row in data_rows:
307 - out.append(put_fn(esc_fn(f"\u2022 {', '.join(row)}")))
308 - headers.clear()
309 - data_rows.clear()
310 -
311 - for line in lines:
312 - m = _TABLE_ROW.match(line.strip())
283 + for line in text.split("\n"):
284 + stripped = line.strip()
285 + if _TABLE_SEP.match(stripped):
286 + continue
287 + m = _TABLE_RE.match(stripped)
288 if m:
314 - if _TABLE_SEP.match(line.strip()):
315 - continue
316 - cells = [c.strip() for c in m.group(1).split("|")]
317 - if not headers:
318 - headers.extend(cells)
319 - else:
320 - data_rows.append(cells)
289 + out.append(" ".join(c.strip() for c in m.group(1).split("|")))
290 else:
322 - _flush()
291 out.append(line)
324 - _flush()
292 return "\n".join(out)
293
294
328 -_LIST_ITEM = re.compile(r"^( *)([-*+]|\d+\.)\s+(.*)$")
329 -_BULLETS = ["\u2022", "\u25e6", "\u25aa"] # • ◦ ▪
295 +_LIST_RE = re.compile(r"^( *)([-*+]|\d+\.)\s+(.*)$")
296 +_BULLETS = ("\u2022", "\u25e6", "\u25aa")
297
298
299 def _convert_lists(text: str) -> str:
333 - """Convert Markdown list items to indented bullet / numbered lines."""
334 - lines = text.split("\n")
300 + """Convert Markdown list markers to Unicode bullets."""
301 out: list[str] = []
336 - for line in lines:
337 - m = _LIST_ITEM.match(line)
302 + for line in text.split("\n"):
303 + m = _LIST_RE.match(line)
304 if m:
305 depth = len(m.group(1)) // 2
306 marker, content = m.group(2), m.group(3)
@@ -349,20 +315,19 @@ def _convert_lists(text: str) -> str:
315
316
317 def _convert_blockquotes(text: str) -> str:
352 - """Convert Markdown blockquotes (> text) to <blockquote> tags."""
353 - lines = text.split("\n")
318 + """Convert Markdown blockquotes to Telegram <blockquote> tags."""
319 out: list[str] = []
355 - quote_buf: list[str] = []
320 + buf: list[str] = []
321
322 def _flush():
358 - if quote_buf:
359 - out.append("<blockquote>" + "\n".join(quote_buf) + "</blockquote>")
360 - quote_buf.clear()
323 + if buf:
324 + out.append("<blockquote>" + "\n".join(buf) + "</blockquote>")
325 + buf.clear()
326
362 - for line in lines:
363 - m = re.match(r"^&gt;\s?(.*)", line) # > is already HTML-escaped at this point
327 + for line in text.split("\n"):
328 + m = re.match(r"^&gt;\s?(.*)", line)
329 if m:
365 - quote_buf.append(m.group(1))
330 + buf.append(m.group(1))
331 else:
332 _flush()
333 out.append(line)
plugins/_telegram_integration/prompts/fw.telegram.system_context_reply.md
+9
@@ -8,6 +8,15 @@ include file paths in attachments array to send files/images
8 for multiple files zip first then attach single archive
9 optionally set keyboard array for inline buttons
10
11 +# formatting rules
12 +use Telegram-friendly markdown only:
13 + allowed: **bold**, *italic*, ~~strikethrough~~, `inline code`, ```code blocks```, [links](url), > blockquotes, bullet lists (- item), numbered lists (1. item)
14 + headings rendered as bold — keep them short
15 + avoid: tables (use "• key: value" bullet list instead), deeply nested lists (max 2 levels), horizontal rules (---), image syntax ![](url)
16 + do not mix formatting inside code blocks — code blocks are monospace only
17 + send images/files via attachments array, not inline markdown
18 + keep messages concise — users read on mobile
19 +
20 usage:
21
22 ~~~json