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("&", "&").replace("<", "<").replace(">", ">")
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("&", "&").replace(chr(34), """)}">{_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"^>\s?(.*)", line) # > is already HTML-escaped at this point
327
+ for line in text.split("\n"):
328
+ m = re.match(r"^>\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 
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