diff --git a/api/bot/message_handler.py b/api/bot/message_handler.py index 60ee31d..56de33f 100644 --- a/api/bot/message_handler.py +++ b/api/bot/message_handler.py @@ -124,7 +124,7 @@ class MessageIODeps: send_animation: Callable[..., Optional[int]] send_photo: Callable[..., Optional[int]] send_video: Callable[..., Optional[int]] - delete_msg: Callable[[str, str], None] + delete_msg: Callable[[str, str], bool] edit_message: Callable[[str, str, str], None] admin_report: Callable[[str, Optional[Exception], Optional[Dict[str, Any]]], None] @@ -205,7 +205,7 @@ class MessageHandlerDeps: send_animation: Callable[..., Optional[int]] send_photo: Callable[..., Optional[int]] send_video: Callable[..., Optional[int]] - delete_msg: Callable[[str, str], None] + delete_msg: Callable[[str, str], bool] edit_message: Callable[[str, str, str], None] admin_report: Callable[[str, Optional[Exception], Optional[Dict[str, Any]]], None] get_bot_message_metadata: Callable[[Any, str, Any], Optional[Dict[str, Any]]] diff --git a/api/bot/message_links.py b/api/bot/message_links.py index 82de962..0cbe3d7 100644 --- a/api/bot/message_links.py +++ b/api/bot/message_links.py @@ -11,7 +11,7 @@ class LinkReplacementDeps(Protocol): @property def link_service(self) -> LinkServiceProtocol: ... - def delete_msg(self, chat_id: str, message_id: str) -> None: ... + def delete_msg(self, chat_id: str, message_id: str) -> bool: ... def send_msg(self, *args: Any, **kwargs: Any) -> Optional[int]: ... @@ -83,8 +83,8 @@ def send_replacement(target_reply_id: Optional[str]) -> Optional[int]: return deps.send_msg(chat_id, fixed_text, buttons=original_links) if link_mode == "delete": - deps.delete_msg(chat_id, message_id) sent_message_id = send_replacement(reply_id) + deps.delete_msg(chat_id, message_id) if sent_message_id is not None: deps.save_message_to_redis( chat_id, diff --git a/api/bot/telegram.py b/api/bot/telegram.py index 2e3ab65..11148f2 100644 --- a/api/bot/telegram.py +++ b/api/bot/telegram.py @@ -4,6 +4,7 @@ import json import re +import time from dataclasses import dataclass from os import environ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple @@ -226,11 +227,13 @@ def __init__( self, telegram_request: Callable[..., Tuple[Optional[Dict[str, Any]], Optional[str]]], message_has_domain_link: Optional[Callable[[str, str], bool]] = None, + sleep: Callable[[float], None] = time.sleep, ) -> None: self._telegram_request = telegram_request self._message_has_domain_link = ( message_has_domain_link or _message_has_domain_link ) + self._sleep = sleep def request(self, endpoint: str, **kwargs: Any) -> Any: return self._telegram_request(endpoint, **kwargs) @@ -386,14 +389,38 @@ def edit_photo( return True return error is None and bool(payload_response) - def delete_message(self, chat_id: str, msg_id: str) -> None: - self._telegram_request( - "deleteMessage", - method="GET", - params={"chat_id": chat_id, "message_id": msg_id}, - log_errors=False, - expect_json=False, - ) + def delete_message(self, chat_id: str, msg_id: str) -> bool: + for attempt in range(3): + payload, error = self._telegram_request( + "deleteMessage", + method="GET", + params={"chat_id": chat_id, "message_id": msg_id}, + log_errors=False, + ) + if error is None and payload and payload.get("result") is True: + return True + + description = error or "unexpected Telegram response" + if "message to delete not found" in description.lower(): + return True + + error_code = payload.get("error_code") if payload else None + transient = payload is None or error_code == 429 or ( + isinstance(error_code, int) and error_code >= 500 + ) + if transient and attempt < 2: + retry_after = (payload or {}).get("parameters", {}).get("retry_after") + delay = min(float(retry_after), 2.0) if retry_after else 0.25 * 2**attempt + self._sleep(delay) + continue + + print( + f"Telegram deleteMessage failed for chat={chat_id} " + f"message={msg_id}: {description}" + ) + return False + + return False def send_animation( self, diff --git a/tests/test_message_ai_media.py b/tests/test_message_ai_media.py index bc77703..ce54477 100644 --- a/tests/test_message_ai_media.py +++ b/tests/test_message_ai_media.py @@ -312,6 +312,8 @@ def test_message_links_handle_link_replacement_delete_mode_stores_fixed_context( ) assert handled is True + method_names = [call_args[0] for call_args in deps.method_calls] + assert method_names.index("send_msg") < method_names.index("delete_msg") deps.delete_msg.assert_called_once_with("555", "100") deps.send_msg.assert_called_once_with( "555", diff --git a/tests/test_telegram_gateway.py b/tests/test_telegram_gateway.py index 43f27db..9e4763f 100644 --- a/tests/test_telegram_gateway.py +++ b/tests/test_telegram_gateway.py @@ -1,5 +1,7 @@ -from api.bot.telegram import TelegramGateway import json +from unittest.mock import MagicMock + +from api.bot.telegram import TelegramGateway class FakeTelegramRequest: @@ -54,16 +56,49 @@ def test_send_message_enables_html_for_linked_polymarket_title(): def test_delete_message_delegates_to_telegram_request(): - request = FakeTelegramRequest(({"ok": True}, None)) + request = FakeTelegramRequest(({"ok": True, "result": True}, None)) gateway = TelegramGateway(telegram_request=request) - gateway.delete_message("123", "10") + result = gateway.delete_message("123", "10") + assert result is True assert request.calls[0][0] == "deleteMessage" assert request.calls[0][1]["method"] == "GET" assert request.calls[0][1]["params"] == {"chat_id": "123", "message_id": "10"} +def test_delete_message_retries_transient_failure(): + request = MagicMock( + side_effect=[ + (None, "timed out"), + ({"ok": False, "error_code": 503}, "temporarily unavailable"), + ({"ok": True, "result": True}, None), + ] + ) + sleep = MagicMock() + gateway = TelegramGateway(telegram_request=request, sleep=sleep) + + assert gateway.delete_message("123", "10") is True + assert request.call_count == 3 + assert [call.args[0] for call in sleep.call_args_list] == [0.25, 0.5] + + +def test_delete_message_does_not_retry_permanent_failure(capsys): + request = MagicMock( + return_value=( + {"ok": False, "error_code": 400}, + "Bad Request: message can't be deleted", + ) + ) + sleep = MagicMock() + gateway = TelegramGateway(telegram_request=request, sleep=sleep) + + assert gateway.delete_message("123", "10") is False + assert request.call_count == 1 + sleep.assert_not_called() + assert "message can't be deleted" in capsys.readouterr().out + + def test_send_photo_delegates_to_telegram_request(): request = FakeTelegramRequest(({"ok": True, "result": {"message_id": 77}}, None)) gateway = TelegramGateway(telegram_request=request)