fix(telegram): group attachment detection, typing lifecycle, per-chat session isolation

- Fix group @mention with attachments being silently dropped: filter now checks caption / caption_entities for media messages - Fix typing indicator cancelled prematurely on inline updates (break_loop=false): typing and reply_to cleanup moved to process_chain_end's finally block - Fix same user's group and private chat interfering with each other: _map_key now includes chat_id for per-chat context isolation - Refactor _make_handler to use direct function references instead of string-based getattr

keyboardstaff committed Mar 23, 2026 at 07:18 UTC 24eb76296317ebdb5cb41fb325046c05907bc222
4 files changed +33 -30
plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py
+7 -9
@@ -64,11 +64,11 @@ class TelegramBotManager(Extension):
64
65 try:
66 # Create handler closures that capture bot_name and config
67 - _on_start = partial(_make_handler("handle_start"), bot_name=name, bot_cfg=bot_cfg)
68 - _on_clear = partial(_make_handler("handle_clear"), bot_name=name, bot_cfg=bot_cfg)
69 - _on_message = partial(_make_handler("handle_message"), bot_name=name, bot_cfg=bot_cfg)
70 - _on_callback = partial(_make_handler("handle_callback_query"), bot_name=name, bot_cfg=bot_cfg)
71 - _on_new_members = partial(_make_handler("handle_new_members"), bot_name=name, bot_cfg=bot_cfg)
67 + _on_start = partial(_make_handler(handle_start), bot_name=name, bot_cfg=bot_cfg)
68 + _on_clear = partial(_make_handler(handle_clear), bot_name=name, bot_cfg=bot_cfg)
69 + _on_message = partial(_make_handler(handle_message), bot_name=name, bot_cfg=bot_cfg)
70 + _on_callback = partial(_make_handler(handle_callback_query), bot_name=name, bot_cfg=bot_cfg)
71 + _on_new_members = partial(_make_handler(handle_new_members), bot_name=name, bot_cfg=bot_cfg)
72
73 instance = create_bot(
74 name=name,
@@ -115,10 +115,8 @@ def _get_current_bot_cfg(bot_name: str) -> dict:
115 return {}
116
117
118 -def _make_handler(handler_func):
118 +def _make_handler(handler_fn):
119 """Create a wrapper that resolves fresh bot config on every call."""
120 async def _wrapped(event, bot_name: str, bot_cfg: dict):
121 - from plugins._telegram_integration.helpers import handler
122 - fn = getattr(handler, handler_func)
123 - await fn(event, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
121 + await handler_fn(event, bot_name, _get_current_bot_cfg(bot_name) or bot_cfg)
122 return _wrapped
plugins/_telegram_integration/extensions/python/process_chain_end/_55_telegram_reply.py
+10 -1
@@ -2,7 +2,10 @@ from helpers.extension import Extension
2 from helpers.print_style import PrintStyle
3 from helpers.errors import format_error
4 from agent import AgentContext, LoopData, UserMessage
5 -from plugins._telegram_integration.helpers.handler import CTX_TG_BOT, CTX_TG_ATTACHMENTS, CTX_TG_KEYBOARD
5 +from plugins._telegram_integration.helpers.handler import (
6 + CTX_TG_BOT, CTX_TG_ATTACHMENTS, CTX_TG_KEYBOARD,
7 + CTX_TG_TYPING_STOP, CTX_TG_REPLY_TO,
8 +)
9
10 MAX_SEND_RETRIES: int = 2
11 CTX_SEND_FAILURES: str = "_telegram_send_failures"
@@ -29,6 +32,12 @@ class TelegramAutoReply(Extension):
32 await self._send_reply(context, response_text, attachments, keyboard)
33 except Exception as e:
34 PrintStyle.error(f"Telegram auto-reply error: {format_error(e)}")
35 + finally:
36 + # Cancel typing and clean up reply_to after final send
37 + typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None)
38 + if typing_stop:
39 + typing_stop.set()
40 + context.data.pop(CTX_TG_REPLY_TO, None)
41
42 async def _send_reply(
43 self,
plugins/_telegram_integration/helpers/bot_manager.py
+11 -9
@@ -115,19 +115,21 @@ def _make_group_mention_filter(handler: Callable, bot: Bot):
115 await handler(message)
116 return
117
118 - # Check for @mention in text
119 - if message.text and f"@{bot_username}" in message.text:
118 + # Check for @mention in text or caption (media messages use caption)
119 + text = message.text or message.caption or ""
120 + entities = message.entities or message.caption_entities or []
121 +
122 + if text and f"@{bot_username}" in text:
123 await handler(message)
124 return
125
126 # Check entities for mention
124 - if message.entities:
125 - for entity in message.entities:
126 - if entity.type == "mention":
127 - mention_text = message.text[entity.offset:entity.offset + entity.length]
128 - if mention_text.lower() == f"@{bot_username.lower()}":
129 - await handler(message)
130 - return
127 + for entity in entities:
128 + if entity.type == "mention":
129 + mention_text = text[entity.offset:entity.offset + entity.length]
130 + if mention_text.lower() == f"@{bot_username.lower()}":
131 + await handler(message)
132 + return
133
134 _group_handler.__name__ = f"_group_handler_{id(handler)}"
135 return _group_handler
plugins/_telegram_integration/helpers/handler.py
+5 -11
@@ -61,8 +61,8 @@ def _save_state(state: dict):
61 files.write_file(path, json.dumps(state))
62
63
64 -def _map_key(bot_name: str, user_id: int) -> str:
65 - return f"{bot_name}:{user_id}"
64 +def _map_key(bot_name: str, user_id: int, chat_id: int) -> str:
65 + return f"{bot_name}:{user_id}:{chat_id}"
66
67
68 def cleanup_old_attachments():
@@ -160,7 +160,7 @@ async def handle_clear(message: TgMessage, bot_name: str, bot_cfg: dict):
160 if not _is_allowed(bot_cfg, user.id, user.username):
161 return
162
163 - key = _map_key(bot_name, user.id)
163 + key = _map_key(bot_name, user.id, message.chat.id)
164
165 with _chat_map_lock:
166 state = _load_state()
@@ -349,7 +349,7 @@ async def _get_or_create_context_from_user(
349 username: str | None,
350 chat_id: int,
351 ) -> AgentContext | None:
352 - key = _map_key(bot_name, user_id)
352 + key = _map_key(bot_name, user_id, chat_id)
353
354 with _chat_map_lock:
355 state = _load_state()
@@ -360,7 +360,6 @@ async def _get_or_create_context_from_user(
360 if ctx_id:
361 ctx = AgentContext.get(ctx_id)
362 if ctx:
363 - ctx.data[CTX_TG_CHAT_ID] = chat_id # always track latest chat
363 return ctx
364 # Context was garbage collected, remove stale mapping
365 chats.pop(key, None)
@@ -490,12 +489,7 @@ async def send_telegram_reply(
489 if not chat_id:
490 return "No chat_id on context"
491
493 - # Cancel persistent typing indicator
494 - typing_stop = context.data.pop(CTX_TG_TYPING_STOP, None)
495 - if typing_stop:
496 - typing_stop.set()
497 -
498 - reply_to = context.data.pop(CTX_TG_REPLY_TO, None)
492 + reply_to = context.data.get(CTX_TG_REPLY_TO)
493
494 try:
495 async with _temp_bot(instance.bot.token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) as reply_bot: