From 3f543229f28c6a0fb588a73f093b85a183d86595 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Sat, 20 Jun 2026 22:01:46 +0200 Subject: [PATCH] fix(telegram): notify user when clarify button tap arrives after expiry --- plugins/platforms/telegram/adapter.py | 58 +++++++++++++---- .../gateway/test_telegram_clarify_buttons.py | 65 +++++++++++++++++++ 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 09a5777a4ce..37f99b75adb 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -4292,6 +4292,31 @@ class TelegramAdapter(BasePlatformAdapter): # Catch-all (e.g. page counter button "mx:noop") await query.answer() + async def _notify_clarify_expired(self, query, user_display: str) -> None: + """Tell the user a clarify tap arrived too late to be delivered. + + Fires when the clarify entry was evicted by ``clarify_timeout`` or the + gateway restarted between asking and the tap. In both cases the agent + thread is no longer waiting, so the tap would otherwise leave a + misleading ✓ (or an "awaiting typed response" prompt) on a button the + agent never receives. + """ + try: + await query.answer(text="⚠️ This prompt expired — please /retry.") + except Exception: + pass + try: + await query.edit_message_text( + text=( + f"❓ {_html.escape(query.message.text or '')}\n\n" + "⚠️ This question expired or the session reset — please /retry." + ), + parse_mode=ParseMode.HTML, + reply_markup=None, + ) + except Exception: + pass + async def _handle_callback_query( self, update: "Update", context: "ContextTypes.DEFAULT_TYPE" ) -> None: @@ -4529,12 +4554,20 @@ class TelegramAdapter(BasePlatformAdapter): # clarify. Do NOT pop _clarify_state yet — we still # need it if the user is slow to respond and the entry # is cleared by something else. + flipped = False try: from tools.clarify_gateway import mark_awaiting_text - mark_awaiting_text(clarify_id) + flipped = mark_awaiting_text(clarify_id) except Exception as exc: logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc) + if not flipped: + # Entry evicted (clarify_timeout) or gateway restarted + # between ask and tap — a typed answer would go nowhere. + self._clarify_state.pop(clarify_id, None) + await self._notify_clarify_expired(query, user_display) + return + await query.answer(text="✏️ Type your answer in the chat.") try: await query.edit_message_text( @@ -4580,22 +4613,25 @@ class TelegramAdapter(BasePlatformAdapter): logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc) resolved = False - await query.answer(text=f"✓ {resolved_text[:60]}") - try: - await query.edit_message_text( - text=f"❓ {_html.escape(query.message.text or '')}\n\n{_html.escape(user_display)}: {_html.escape(resolved_text)}", - parse_mode=ParseMode.HTML, - reply_markup=None, - ) - except Exception: - pass - if resolved: + await query.answer(text=f"✓ {resolved_text[:60]}") + try: + await query.edit_message_text( + text=f"❓ {_html.escape(query.message.text or '')}\n\n{_html.escape(user_display)}: {_html.escape(resolved_text)}", + parse_mode=ParseMode.HTML, + reply_markup=None, + ) + except Exception: + pass logger.info( "Telegram clarify button resolved (id=%s, choice=%r, user=%s)", clarify_id, resolved_text, user_display, ) else: + # Entry evicted (clarify_timeout) or gateway restarted + # between ask and tap — surface this instead of leaving a + # misleading ✓ on a button the agent will never receive. + await self._notify_clarify_expired(query, user_display) logger.warning( "Telegram clarify button: resolve_gateway_clarify returned False (id=%s)", clarify_id, diff --git a/tests/gateway/test_telegram_clarify_buttons.py b/tests/gateway/test_telegram_clarify_buttons.py index 70a06087a44..420e7046859 100644 --- a/tests/gateway/test_telegram_clarify_buttons.py +++ b/tests/gateway/test_telegram_clarify_buttons.py @@ -351,6 +351,71 @@ class TestTelegramClarifyCallback: # State preserved assert adapter._clarify_state["cidC"] == "sk-auth" + @pytest.mark.asyncio + async def test_numeric_choice_expired_notifies_user(self): + """Late tap after the entry was evicted (timeout) or the gateway + restarted must surface an expiry notice, not a misleading ✓.""" + adapter = _make_adapter() + # _clarify_state still maps the id (timeout eviction does not pop it), + # but the clarify primitive entry is gone → resolve returns False. + adapter._clarify_state["cidExpired"] = "sk-expired" + + query = AsyncMock() + query.data = "cl:cidExpired:0" + query.message = MagicMock() + query.message.chat_id = 12345 + query.message.text = "Pick" + query.from_user = MagicMock() + query.from_user.id = "777" + query.from_user.first_name = "Tester" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + await adapter._handle_callback_query(update, context) + + # User is told the prompt expired — not a misleading checkmark. + answer_text = query.answer.call_args[1]["text"].lower() + assert "expired" in answer_text + edit_text = query.edit_message_text.call_args[1]["text"].lower() + assert "expired" in edit_text or "session reset" in edit_text + assert "/retry" in edit_text + + @pytest.mark.asyncio + async def test_other_button_expired_notifies_user(self): + """Tapping 'Other' after the entry was evicted must tell the user the + prompt expired instead of silently entering text-capture mode.""" + adapter = _make_adapter() + # No clarify primitive entry → mark_awaiting_text returns False. + adapter._clarify_state["cidOtherExpired"] = "sk-other-expired" + + query = AsyncMock() + query.data = "cl:cidOtherExpired:other" + query.message = MagicMock() + query.message.chat_id = 12345 + query.message.text = "Pick" + query.from_user = MagicMock() + query.from_user.id = "777" + query.from_user.first_name = "Tester" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + + update = MagicMock() + update.callback_query = query + context = MagicMock() + + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + await adapter._handle_callback_query(update, context) + + answer_text = query.answer.call_args[1]["text"].lower() + assert "expired" in answer_text + # State popped so a subsequent typed message is not mis-captured. + assert "cidOtherExpired" not in adapter._clarify_state + @pytest.mark.asyncio async def test_invalid_choice_token(self): from tools import clarify_gateway as cm