From 318672bc5ff6d642692c6d6d7b085eeb3ed12b32 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Thu, 12 Mar 2026 22:33:29 +0100 Subject: [PATCH 1/3] feat: support Telegram topics for per-protocol routing (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add message_thread_id support to send_telegram_message so protocols can be routed to topics within a single forum-style group instead of separate chats. Configured via TELEGRAM_CHAT_ID_TOPICS and TELEGRAM_TOPIC_ID_{PROTOCOL} env vars. Backwards compatible — protocols without a topic ID keep using legacy per-protocol chat routing. Co-Authored-By: Claude Opus 4.6 --- .env.example | 7 +++- tests/test_utils.py | 79 +++++++++++++++++++++++++++++++++++++++++++-- utils/telegram.py | 21 ++++++++---- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 8b38e55..9beab1e 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,12 @@ ENVIO_GRAPHQL_URL="" TELEGRAM_BOT_TOKEN_DEFAULT=your-default-bot-token TELEGRAM_CHAT_ID_DEFAULT=your-default-chat-id -# Protocol-specific Telegram settings +# Telegram Topics (forum-style group) — single group with per-protocol topics +# TELEGRAM_CHAT_ID_TOPICS=your-topics-group-chat-id +# TELEGRAM_TOPIC_ID_AAVE=123 +# TELEGRAM_TOPIC_ID_COMPOUND=456 + +# Protocol-specific Telegram settings (legacy per-protocol chats) TELEGRAM_BOT_TOKEN_AAVE=your-aave-bot-token TELEGRAM_CHAT_ID_AAVE=your-aave-chat-id diff --git a/tests/test_utils.py b/tests/test_utils.py index f47df36..2264e80 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -81,6 +81,7 @@ def test_send_telegram_message_success(self, mock_post): { "TELEGRAM_BOT_TOKEN_TEST": "test_token", "TELEGRAM_CHAT_ID_TEST": "test_chat_id", + "LOG_LEVEL": "INFO", }, ): # Should not raise any exceptions @@ -104,10 +105,10 @@ def test_send_telegram_message_missing_credentials(self, mock_get): # Verify no request was made mock_get.assert_not_called() - @patch("utils.telegram.requests.get") - def test_send_telegram_message_failure(self, mock_get): + @patch("utils.telegram.requests.post") + def test_send_telegram_message_failure(self, mock_post): # Setup mock response for failure - mock_get.side_effect = requests.RequestException("Connection error") + mock_post.side_effect = requests.RequestException("Connection error") # Test with environment variables with patch.dict( @@ -115,12 +116,84 @@ def test_send_telegram_message_failure(self, mock_get): { "TELEGRAM_BOT_TOKEN_TEST": "test_token", "TELEGRAM_CHAT_ID_TEST": "test_chat_id", + "LOG_LEVEL": "INFO", }, ): # Should raise TelegramError with self.assertRaises(TelegramError): send_telegram_message("Test message", "test") + @patch("utils.telegram.requests.post") + def test_send_telegram_message_with_topic(self, mock_post): + """When TELEGRAM_TOPIC_ID is set, message goes to topics chat with message_thread_id.""" + 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_message("Test message", "aave") + + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + kwargs = mock_post.call_args[1] + self.assertEqual(kwargs["json"]["chat_id"], "topics_chat_id") + self.assertEqual(kwargs["json"]["message_thread_id"], 42) + self.assertIn("default_token", url) + + @patch("utils.telegram.requests.post") + def test_send_telegram_message_topic_uses_default_bot(self, mock_post): + """Topic routing always uses the default bot, even if protocol-specific bot exists.""" + 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_BOT_TOKEN_AAVE": "aave_specific_token", + "TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id", + "TELEGRAM_TOPIC_ID_AAVE": "7", + "LOG_LEVEL": "INFO", + }, + ): + send_telegram_message("Test", "aave") + url = mock_post.call_args[0][0] + self.assertIn("default_token", url) + self.assertNotIn("aave_specific_token", url) + + @patch("utils.telegram.requests.post") + def test_send_telegram_message_no_topic_falls_back(self, mock_post): + """Without topic ID, uses legacy per-protocol chat routing.""" + 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_AAVE": "aave_token", + "TELEGRAM_CHAT_ID_AAVE": "aave_chat_id", + "TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id", + "LOG_LEVEL": "INFO", + }, + ): + send_telegram_message("Test", "aave") + kwargs = mock_post.call_args[1] + self.assertEqual(kwargs["json"]["chat_id"], "aave_chat_id") + self.assertNotIn("message_thread_id", kwargs["json"]) + class TestAlert(unittest.TestCase): """Tests for the Alert system.""" diff --git a/utils/telegram.py b/utils/telegram.py index 7236c3c..15e40c0 100644 --- a/utils/telegram.py +++ b/utils/telegram.py @@ -47,24 +47,33 @@ def send_telegram_message( message = message[: MAX_MESSAGE_LENGTH - 3] + "..." plain_text = True - # Get bot token and chat ID from environment variables - bot_token = os.getenv(f"TELEGRAM_BOT_TOKEN_{protocol.upper()}") - if not bot_token: - bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT") + # Check if this protocol has a topic ID configured (forum-style group) + topic_id = os.getenv(f"TELEGRAM_TOPIC_ID_{protocol.upper()}") - chat_id = os.getenv(f"TELEGRAM_CHAT_ID_{protocol.upper()}") + if topic_id: + # Topics always use the default bot and the shared topics chat + bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT") + chat_id = os.getenv("TELEGRAM_CHAT_ID_TOPICS") + else: + # Legacy per-protocol chat routing + bot_token = os.getenv(f"TELEGRAM_BOT_TOKEN_{protocol.upper()}") + if not bot_token: + bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT") + chat_id = os.getenv(f"TELEGRAM_CHAT_ID_{protocol.upper()}") if not bot_token or not chat_id: logger.warning("Missing Telegram credentials for %s", protocol) return url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - payload = { + payload: dict[str, object] = { "chat_id": chat_id, "text": message, "parse_mode": "Markdown" if not plain_text else None, "disable_notification": disable_notification, } + if topic_id: + payload["message_thread_id"] = int(topic_id) try: response = requests.post(url, json=payload, timeout=10) From 4a7ca131e88230d151e739d0acf8b0385b6e6a9a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 31 Mar 2026 11:56:14 +0200 Subject: [PATCH 2/3] feat: wire Telegram topic secrets into CI workflow Add all TELEGRAM_TOPIC_ID_* and TELEGRAM_CHAT_ID_TOPICS env vars to _run-monitoring.yml so forum-style topic routing works in GitHub Actions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/_run-monitoring.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/_run-monitoring.yml b/.github/workflows/_run-monitoring.yml index ef4b67e..6c92b83 100644 --- a/.github/workflows/_run-monitoring.yml +++ b/.github/workflows/_run-monitoring.yml @@ -82,6 +82,28 @@ env: TELEGRAM_CHAT_ID_USDAI: ${{ secrets.TELEGRAM_CHAT_ID_USDAI }} TELEGRAM_CHAT_ID_YEARN: ${{ secrets.TELEGRAM_CHAT_ID_YEARN }} + # ── Telegram Topics (forum-style group routing) ── + TELEGRAM_CHAT_ID_TOPICS: ${{ secrets.TELEGRAM_CHAT_ID_TOPICS }} + TELEGRAM_TOPIC_ID_3JANE: ${{ secrets.TELEGRAM_TOPIC_ID_3JANE }} + TELEGRAM_TOPIC_ID_AAVE: ${{ secrets.TELEGRAM_TOPIC_ID_AAVE }} + TELEGRAM_TOPIC_ID_CAP: ${{ secrets.TELEGRAM_TOPIC_ID_CAP }} + TELEGRAM_TOPIC_ID_COMP: ${{ secrets.TELEGRAM_TOPIC_ID_COMP }} + TELEGRAM_TOPIC_ID_ETHENA: ${{ secrets.TELEGRAM_TOPIC_ID_ETHENA }} + TELEGRAM_TOPIC_ID_EULER: ${{ secrets.TELEGRAM_TOPIC_ID_EULER }} + TELEGRAM_TOPIC_ID_FLUID: ${{ secrets.TELEGRAM_TOPIC_ID_FLUID }} + TELEGRAM_TOPIC_ID_INFINIFI: ${{ secrets.TELEGRAM_TOPIC_ID_INFINIFI }} + TELEGRAM_TOPIC_ID_LIDO: ${{ secrets.TELEGRAM_TOPIC_ID_LIDO }} + TELEGRAM_TOPIC_ID_LRT: ${{ secrets.TELEGRAM_TOPIC_ID_LRT }} + TELEGRAM_TOPIC_ID_MAKER: ${{ secrets.TELEGRAM_TOPIC_ID_MAKER }} + TELEGRAM_TOPIC_ID_MAPLE: ${{ secrets.TELEGRAM_TOPIC_ID_MAPLE }} + TELEGRAM_TOPIC_ID_MORPHO: ${{ secrets.TELEGRAM_TOPIC_ID_MORPHO }} + TELEGRAM_TOPIC_ID_PENDLE: ${{ secrets.TELEGRAM_TOPIC_ID_PENDLE }} + TELEGRAM_TOPIC_ID_RTOKEN: ${{ secrets.TELEGRAM_TOPIC_ID_RTOKEN }} + TELEGRAM_TOPIC_ID_SILO: ${{ secrets.TELEGRAM_TOPIC_ID_SILO }} + TELEGRAM_TOPIC_ID_STRATA: ${{ secrets.TELEGRAM_TOPIC_ID_STRATA }} + TELEGRAM_TOPIC_ID_USDAI: ${{ secrets.TELEGRAM_TOPIC_ID_USDAI }} + TELEGRAM_TOPIC_ID_YEARN: ${{ secrets.TELEGRAM_TOPIC_ID_YEARN }} + # ── GitHub PAT (dispatch) ── PAT_DISPATCH: ${{ secrets.PAT_DISPATCH }} From 65cbfc81291c80cc6ea034552251bab443078d0d Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 31 Mar 2026 12:01:32 +0200 Subject: [PATCH 3/3] refactor: use repo variables instead of secrets for topic IDs Topic IDs and the topics chat ID are non-sensitive configuration. Moving them from secrets to vars.* makes them visible in the GitHub UI and easier to debug in workflow logs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/_run-monitoring.yml | 41 ++++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/.github/workflows/_run-monitoring.yml b/.github/workflows/_run-monitoring.yml index 6c92b83..5ed61f7 100644 --- a/.github/workflows/_run-monitoring.yml +++ b/.github/workflows/_run-monitoring.yml @@ -83,26 +83,27 @@ env: TELEGRAM_CHAT_ID_YEARN: ${{ secrets.TELEGRAM_CHAT_ID_YEARN }} # ── Telegram Topics (forum-style group routing) ── - TELEGRAM_CHAT_ID_TOPICS: ${{ secrets.TELEGRAM_CHAT_ID_TOPICS }} - TELEGRAM_TOPIC_ID_3JANE: ${{ secrets.TELEGRAM_TOPIC_ID_3JANE }} - TELEGRAM_TOPIC_ID_AAVE: ${{ secrets.TELEGRAM_TOPIC_ID_AAVE }} - TELEGRAM_TOPIC_ID_CAP: ${{ secrets.TELEGRAM_TOPIC_ID_CAP }} - TELEGRAM_TOPIC_ID_COMP: ${{ secrets.TELEGRAM_TOPIC_ID_COMP }} - TELEGRAM_TOPIC_ID_ETHENA: ${{ secrets.TELEGRAM_TOPIC_ID_ETHENA }} - TELEGRAM_TOPIC_ID_EULER: ${{ secrets.TELEGRAM_TOPIC_ID_EULER }} - TELEGRAM_TOPIC_ID_FLUID: ${{ secrets.TELEGRAM_TOPIC_ID_FLUID }} - TELEGRAM_TOPIC_ID_INFINIFI: ${{ secrets.TELEGRAM_TOPIC_ID_INFINIFI }} - TELEGRAM_TOPIC_ID_LIDO: ${{ secrets.TELEGRAM_TOPIC_ID_LIDO }} - TELEGRAM_TOPIC_ID_LRT: ${{ secrets.TELEGRAM_TOPIC_ID_LRT }} - TELEGRAM_TOPIC_ID_MAKER: ${{ secrets.TELEGRAM_TOPIC_ID_MAKER }} - TELEGRAM_TOPIC_ID_MAPLE: ${{ secrets.TELEGRAM_TOPIC_ID_MAPLE }} - TELEGRAM_TOPIC_ID_MORPHO: ${{ secrets.TELEGRAM_TOPIC_ID_MORPHO }} - TELEGRAM_TOPIC_ID_PENDLE: ${{ secrets.TELEGRAM_TOPIC_ID_PENDLE }} - TELEGRAM_TOPIC_ID_RTOKEN: ${{ secrets.TELEGRAM_TOPIC_ID_RTOKEN }} - TELEGRAM_TOPIC_ID_SILO: ${{ secrets.TELEGRAM_TOPIC_ID_SILO }} - TELEGRAM_TOPIC_ID_STRATA: ${{ secrets.TELEGRAM_TOPIC_ID_STRATA }} - TELEGRAM_TOPIC_ID_USDAI: ${{ secrets.TELEGRAM_TOPIC_ID_USDAI }} - TELEGRAM_TOPIC_ID_YEARN: ${{ secrets.TELEGRAM_TOPIC_ID_YEARN }} + # These are non-sensitive config, so they use repo variables (vars.*) instead of secrets. + TELEGRAM_CHAT_ID_TOPICS: ${{ vars.TELEGRAM_CHAT_ID_TOPICS }} + TELEGRAM_TOPIC_ID_3JANE: ${{ vars.TELEGRAM_TOPIC_ID_3JANE }} + TELEGRAM_TOPIC_ID_AAVE: ${{ vars.TELEGRAM_TOPIC_ID_AAVE }} + TELEGRAM_TOPIC_ID_CAP: ${{ vars.TELEGRAM_TOPIC_ID_CAP }} + TELEGRAM_TOPIC_ID_COMP: ${{ vars.TELEGRAM_TOPIC_ID_COMP }} + TELEGRAM_TOPIC_ID_ETHENA: ${{ vars.TELEGRAM_TOPIC_ID_ETHENA }} + TELEGRAM_TOPIC_ID_EULER: ${{ vars.TELEGRAM_TOPIC_ID_EULER }} + TELEGRAM_TOPIC_ID_FLUID: ${{ vars.TELEGRAM_TOPIC_ID_FLUID }} + TELEGRAM_TOPIC_ID_INFINIFI: ${{ vars.TELEGRAM_TOPIC_ID_INFINIFI }} + TELEGRAM_TOPIC_ID_LIDO: ${{ vars.TELEGRAM_TOPIC_ID_LIDO }} + TELEGRAM_TOPIC_ID_LRT: ${{ vars.TELEGRAM_TOPIC_ID_LRT }} + TELEGRAM_TOPIC_ID_MAKER: ${{ vars.TELEGRAM_TOPIC_ID_MAKER }} + TELEGRAM_TOPIC_ID_MAPLE: ${{ vars.TELEGRAM_TOPIC_ID_MAPLE }} + TELEGRAM_TOPIC_ID_MORPHO: ${{ vars.TELEGRAM_TOPIC_ID_MORPHO }} + TELEGRAM_TOPIC_ID_PENDLE: ${{ vars.TELEGRAM_TOPIC_ID_PENDLE }} + TELEGRAM_TOPIC_ID_RTOKEN: ${{ vars.TELEGRAM_TOPIC_ID_RTOKEN }} + TELEGRAM_TOPIC_ID_SILO: ${{ vars.TELEGRAM_TOPIC_ID_SILO }} + TELEGRAM_TOPIC_ID_STRATA: ${{ vars.TELEGRAM_TOPIC_ID_STRATA }} + TELEGRAM_TOPIC_ID_USDAI: ${{ vars.TELEGRAM_TOPIC_ID_USDAI }} + TELEGRAM_TOPIC_ID_YEARN: ${{ vars.TELEGRAM_TOPIC_ID_YEARN }} # ── GitHub PAT (dispatch) ── PAT_DISPATCH: ${{ secrets.PAT_DISPATCH }}