fix(telegram): notify user when clarify button tap arrives after expiry

This commit is contained in:
konsisumer 2026-06-20 22:01:46 +02:00 committed by Teknium
parent 90d25adc9e
commit 3f543229f2
2 changed files with 112 additions and 11 deletions

View file

@ -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"
"<i>⚠️ This question expired or the session reset — please /retry.</i>"
),
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<b>{_html.escape(user_display)}:</b> {_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<b>{_html.escape(user_display)}:</b> {_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,

View file

@ -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