diff --git a/tests/test_utils.py b/tests/test_utils.py index 3569020e..e6dd35d4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,7 +11,13 @@ from utils.alert import Alert, AlertSeverity, register_alert_hook, send_alert from utils.config import Config, ProtocolConfig -from utils.telegram import TelegramError, send_error_message, send_telegram_message +from utils.telegram import ( + TelegramError, + format_rich_table, + send_error_message, + send_telegram_message, + send_telegram_rich_message, +) from utils.web3_wrapper import ( MAX_BACKOFF_SECONDS, ProviderConnectionError, @@ -290,6 +296,126 @@ def test_send_telegram_message_test_override(self, mock_post): "[yearn_timelock] Test message", ) + def test_format_rich_table(self): + table = format_rich_table( + ["Market", "Utilization"], + [["ETH < WETH", "97.4%"], ["USDC", "95.1%"]], + caption="Aave & Morpho", + alignments=["left", "right"], + ) + + self.assertEqual( + table, + '
| Market | Utilization |
|---|---|
| ETH < WETH | 97.4% |
| USDC | 95.1% |
Test
", "test") + + url = mock_post.call_args[0][0] + payload = mock_post.call_args[1]["json"] + self.assertIn("sendRichMessage", url) + self.assertEqual(payload["chat_id"], "test_chat_id") + self.assertEqual(payload["rich_message"], {"html": "Test
", "skip_entity_detection": True}) + self.assertNotIn("parse_mode", payload) + + @patch("utils.telegram.requests.post") + def test_send_telegram_rich_message_with_topic(self, mock_post): + mock_response = unittest.mock.Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = unittest.mock.Mock() + mock_post.return_value = mock_response + + with patch.dict( + os.environ, + { + "TELEGRAM_BOT_TOKEN_DEFAULT": "default_token", + "TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id", + "TELEGRAM_TOPIC_ID_AAVE": "42", + "LOG_LEVEL": "INFO", + }, + ): + send_telegram_rich_message("| ok |
Test
", "aave") + + url = mock_post.call_args[0][0] + payload = mock_post.call_args[1]["json"] + self.assertIn("default_token", url) + self.assertNotIn("aave_token", url) + self.assertEqual(payload["chat_id"], "dummy_group") + self.assertEqual(payload["rich_message"]["html"], "[aave]
Test
") + self.assertNotIn("message_thread_id", payload) + + @patch("utils.telegram.requests.post") + def test_send_telegram_rich_message_falls_back_to_plain_text(self, mock_post): + failure = requests.RequestException("Connection error") + mock_response = unittest.mock.Mock() + mock_response.status_code = 200 + mock_response.raise_for_status = unittest.mock.Mock() + mock_post.side_effect = [failure, mock_response] + + with patch.dict( + os.environ, + { + "TELEGRAM_BOT_TOKEN_TEST": "test_token", + "TELEGRAM_CHAT_ID_TEST": "test_chat_id", + "LOG_LEVEL": "INFO", + }, + ): + send_telegram_rich_message("Test
", "test", fallback_message="Fallback") + + fallback_payload = mock_post.call_args_list[1][1]["json"] + self.assertEqual(fallback_payload["text"], "Fallback") + self.assertNotIn("parse_mode", fallback_payload) + class TestSendErrorMessage(unittest.TestCase): """Tests for utils.telegram.send_error_message (dedicated errors channel).""" diff --git a/utils/telegram.py b/utils/telegram.py index 2348f3b4..b1dcf786 100644 --- a/utils/telegram.py +++ b/utils/telegram.py @@ -1,5 +1,7 @@ import os import re +from collections.abc import Iterable, Sequence +from html import escape as html_escape import requests from dotenv import load_dotenv @@ -13,6 +15,11 @@ # Maximum message length allowed by Telegram API MAX_MESSAGE_LENGTH = 4096 +# Telegram Bot API 10.1 rich messages allow substantially larger structured +# messages than sendMessage. Keep this explicit so rich-message callers can +# fail over before handing Telegram malformed/truncated HTML. +MAX_RICH_MESSAGE_LENGTH = 32768 + # Channel key for operational errors/diagnostics (GraphQL/fetch failures, retries, # crashes). Routed to a dedicated chat so transient noise doesn't spam the # per-protocol alert groups. Resolves via the same env-var scheme as any other @@ -43,12 +50,98 @@ def escape_markdown(text: str) -> str: return text +def escape_rich_html(text: object) -> str: + """Escape text for Telegram rich-message HTML.""" + return html_escape(str(text), quote=True) + + +def format_rich_table( + headers: Sequence[object], + rows: Iterable[Sequence[object]], + caption: object | None = None, + alignments: Sequence[str] | None = None, + bordered: bool = True, + striped: bool = True, +) -> str: + """Render a simple Telegram rich-message HTML table. + + The returned string is meant for ``send_telegram_rich_message``. Cell + content is escaped as plain text; callers that need inline rich formatting + should build the table HTML themselves. + """ + column_count = len(headers) + if column_count == 0: + raise ValueError("Telegram rich tables need at least one column") + if column_count > 20: + raise ValueError("Telegram rich tables support at most 20 columns") + + alignments = alignments or () + if len(alignments) > column_count: + raise ValueError("Table alignments cannot exceed the number of columns") + + normalized_alignments: list[str | None] = [] + for alignment in alignments: + if alignment not in {"left", "center", "right"}: + raise ValueError("Table alignment must be one of: left, center, right") + normalized_alignments.append(alignment) + normalized_alignments.extend([None] * (column_count - len(normalized_alignments))) + + table_rows = [tuple(row) for row in rows] + for row in table_rows: + if len(row) != column_count: + raise ValueError("All Telegram rich table rows must have the same number of columns as headers") + + attrs = [] + if bordered: + attrs.append("bordered") + if striped: + attrs.append("striped") + table_tag = "[{escape_rich_html(protocol)}]
{html}" + rich_message = {"html": labelled_html, "skip_entity_detection": skip_entity_detection} + _post_rich_message(bot_token, test_chat_id, rich_message, disable_notification) + return + bot_token, chat_id, topic_id = _telegram_destination(protocol) if not bot_token or not chat_id: logger.warning("Missing Telegram credentials for %s", protocol) return - _post_message(bot_token, chat_id, message, plain_text, disable_notification, topic_id) + try: + _post_rich_message(bot_token, chat_id, rich_message, disable_notification, topic_id) + except TelegramError: + if fallback_message is None: + raise + logger.exception("Failed to send Telegram rich message for %s; sending fallback", protocol) + send_telegram_message(fallback_message, protocol, disable_notification, plain_text=True) def _error_channel_configured() -> bool: