From 259f9d2e24218f1610e7434b50f16977449fc573 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 5 Feb 2026 13:39:09 +0900 Subject: [PATCH] feat: add generic send_message method to TelegramClient (issue #59) Add send_message(text, parse_mode) method that can be used for both notifications and command responses. Refactor _send_notification to use the new method. Changes: - Add send_message() method with return value for success/failure - Refactor _send_notification() to call send_message() - Add comprehensive tests for send_message() - Coverage: 93% for telegram_client.py Co-Authored-By: Claude Sonnet 4.5 --- src/notifications/telegram_client.py | 42 +++++++++++------ tests/test_telegram.py | 70 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index 910e853..80d2878 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -117,26 +117,28 @@ class TelegramClient: if self._session is not None and not self._session.closed: await self._session.close() - async def _send_notification(self, msg: NotificationMessage) -> None: + async def send_message(self, text: str, parse_mode: str = "HTML") -> bool: """ - Send notification to Telegram with graceful degradation. + Send a generic text message to Telegram. Args: - msg: Notification message to send + text: Message text to send + parse_mode: Parse mode for formatting (HTML or Markdown) + + Returns: + True if message was sent successfully, False otherwise """ if not self._enabled: - return + return False try: await self._rate_limiter.acquire() - formatted_message = f"{msg.priority.emoji} {msg.message}" url = f"{self.API_BASE.format(token=self._bot_token)}/sendMessage" - payload = { "chat_id": self._chat_id, - "text": formatted_message, - "parse_mode": "HTML", + "text": text, + "parse_mode": parse_mode, } session = self._get_session() @@ -146,15 +148,29 @@ class TelegramClient: logger.error( "Telegram API error (status=%d): %s", resp.status, error_text ) - else: - logger.debug("Telegram notification sent: %s", msg.message[:50]) + return False + logger.debug("Telegram message sent: %s", text[:50]) + return True except asyncio.TimeoutError: - logger.error("Telegram notification timeout") + logger.error("Telegram message timeout") + return False except aiohttp.ClientError as exc: - logger.error("Telegram notification failed: %s", exc) + logger.error("Telegram message failed: %s", exc) + return False except Exception as exc: - logger.error("Unexpected error sending notification: %s", exc) + logger.error("Unexpected error sending message: %s", exc) + return False + + async def _send_notification(self, msg: NotificationMessage) -> None: + """ + Send notification to Telegram with graceful degradation. + + Args: + msg: Notification message to send + """ + formatted_message = f"{msg.priority.emoji} {msg.message}" + await self.send_message(formatted_message) async def notify_trade_execution( self, diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 7c1ef33..b0872fe 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -39,6 +39,76 @@ class TestTelegramClientInit: class TestNotificationSending: """Test notification sending behavior.""" + @pytest.mark.asyncio + async def test_send_message_success(self) -> None: + """send_message returns True on successful send.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + result = await client.send_message("Test message") + + assert result is True + assert mock_post.call_count == 1 + + payload = mock_post.call_args.kwargs["json"] + assert payload["chat_id"] == "456" + assert payload["text"] == "Test message" + assert payload["parse_mode"] == "HTML" + + @pytest.mark.asyncio + async def test_send_message_disabled_client(self) -> None: + """send_message returns False when client disabled.""" + client = TelegramClient(enabled=False) + + with patch("aiohttp.ClientSession.post") as mock_post: + result = await client.send_message("Test message") + + assert result is False + mock_post.assert_not_called() + + @pytest.mark.asyncio + async def test_send_message_api_error(self) -> None: + """send_message returns False on API error.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 400 + mock_resp.text = AsyncMock(return_value="Bad Request") + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp): + result = await client.send_message("Test message") + assert result is False + + @pytest.mark.asyncio + async def test_send_message_with_markdown(self) -> None: + """send_message supports different parse modes.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + result = await client.send_message("*bold*", parse_mode="Markdown") + + assert result is True + payload = mock_post.call_args.kwargs["json"] + assert payload["parse_mode"] == "Markdown" + @pytest.mark.asyncio async def test_no_send_when_disabled(self) -> None: """Notifications not sent when client disabled.""" -- 2.49.1