Skip to content

Commit 30e13b0

Browse files
authored
feat: Telegram topics support (#166)
1 parent 063fd45 commit 30e13b0

4 files changed

Lines changed: 120 additions & 10 deletions

File tree

.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ ENVIO_GRAPHQL_URL=""
2121
TELEGRAM_BOT_TOKEN_DEFAULT=your-default-bot-token
2222
TELEGRAM_CHAT_ID_DEFAULT=your-default-chat-id
2323

24-
# Protocol-specific Telegram settings
24+
# Telegram Topics (forum-style group) — single group with per-protocol topics
25+
# TELEGRAM_CHAT_ID_TOPICS=your-topics-group-chat-id
26+
# TELEGRAM_TOPIC_ID_AAVE=123
27+
# TELEGRAM_TOPIC_ID_COMPOUND=456
28+
29+
# Protocol-specific Telegram settings (legacy per-protocol chats)
2530
TELEGRAM_BOT_TOKEN_AAVE=your-aave-bot-token
2631
TELEGRAM_CHAT_ID_AAVE=your-aave-chat-id
2732

.github/workflows/_run-monitoring.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,29 @@ env:
8282
TELEGRAM_CHAT_ID_USDAI: ${{ secrets.TELEGRAM_CHAT_ID_USDAI }}
8383
TELEGRAM_CHAT_ID_YEARN: ${{ secrets.TELEGRAM_CHAT_ID_YEARN }}
8484

85+
# ── Telegram Topics (forum-style group routing) ──
86+
# These are non-sensitive config, so they use repo variables (vars.*) instead of secrets.
87+
TELEGRAM_CHAT_ID_TOPICS: ${{ vars.TELEGRAM_CHAT_ID_TOPICS }}
88+
TELEGRAM_TOPIC_ID_3JANE: ${{ vars.TELEGRAM_TOPIC_ID_3JANE }}
89+
TELEGRAM_TOPIC_ID_AAVE: ${{ vars.TELEGRAM_TOPIC_ID_AAVE }}
90+
TELEGRAM_TOPIC_ID_CAP: ${{ vars.TELEGRAM_TOPIC_ID_CAP }}
91+
TELEGRAM_TOPIC_ID_COMP: ${{ vars.TELEGRAM_TOPIC_ID_COMP }}
92+
TELEGRAM_TOPIC_ID_ETHENA: ${{ vars.TELEGRAM_TOPIC_ID_ETHENA }}
93+
TELEGRAM_TOPIC_ID_EULER: ${{ vars.TELEGRAM_TOPIC_ID_EULER }}
94+
TELEGRAM_TOPIC_ID_FLUID: ${{ vars.TELEGRAM_TOPIC_ID_FLUID }}
95+
TELEGRAM_TOPIC_ID_INFINIFI: ${{ vars.TELEGRAM_TOPIC_ID_INFINIFI }}
96+
TELEGRAM_TOPIC_ID_LIDO: ${{ vars.TELEGRAM_TOPIC_ID_LIDO }}
97+
TELEGRAM_TOPIC_ID_LRT: ${{ vars.TELEGRAM_TOPIC_ID_LRT }}
98+
TELEGRAM_TOPIC_ID_MAKER: ${{ vars.TELEGRAM_TOPIC_ID_MAKER }}
99+
TELEGRAM_TOPIC_ID_MAPLE: ${{ vars.TELEGRAM_TOPIC_ID_MAPLE }}
100+
TELEGRAM_TOPIC_ID_MORPHO: ${{ vars.TELEGRAM_TOPIC_ID_MORPHO }}
101+
TELEGRAM_TOPIC_ID_PENDLE: ${{ vars.TELEGRAM_TOPIC_ID_PENDLE }}
102+
TELEGRAM_TOPIC_ID_RTOKEN: ${{ vars.TELEGRAM_TOPIC_ID_RTOKEN }}
103+
TELEGRAM_TOPIC_ID_SILO: ${{ vars.TELEGRAM_TOPIC_ID_SILO }}
104+
TELEGRAM_TOPIC_ID_STRATA: ${{ vars.TELEGRAM_TOPIC_ID_STRATA }}
105+
TELEGRAM_TOPIC_ID_USDAI: ${{ vars.TELEGRAM_TOPIC_ID_USDAI }}
106+
TELEGRAM_TOPIC_ID_YEARN: ${{ vars.TELEGRAM_TOPIC_ID_YEARN }}
107+
85108
# ── GitHub PAT (dispatch) ──
86109
PAT_DISPATCH: ${{ secrets.PAT_DISPATCH }}
87110

tests/test_utils.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def test_send_telegram_message_success(self, mock_post):
8484
{
8585
"TELEGRAM_BOT_TOKEN_TEST": "test_token",
8686
"TELEGRAM_CHAT_ID_TEST": "test_chat_id",
87+
"LOG_LEVEL": "INFO",
8788
},
8889
):
8990
# Should not raise any exceptions
@@ -107,23 +108,95 @@ def test_send_telegram_message_missing_credentials(self, mock_get):
107108
# Verify no request was made
108109
mock_get.assert_not_called()
109110

110-
@patch("utils.telegram.requests.get")
111-
def test_send_telegram_message_failure(self, mock_get):
111+
@patch("utils.telegram.requests.post")
112+
def test_send_telegram_message_failure(self, mock_post):
112113
# Setup mock response for failure
113-
mock_get.side_effect = requests.RequestException("Connection error")
114+
mock_post.side_effect = requests.RequestException("Connection error")
114115

115116
# Test with environment variables
116117
with patch.dict(
117118
os.environ,
118119
{
119120
"TELEGRAM_BOT_TOKEN_TEST": "test_token",
120121
"TELEGRAM_CHAT_ID_TEST": "test_chat_id",
122+
"LOG_LEVEL": "INFO",
121123
},
122124
):
123125
# Should raise TelegramError
124126
with self.assertRaises(TelegramError):
125127
send_telegram_message("Test message", "test")
126128

129+
@patch("utils.telegram.requests.post")
130+
def test_send_telegram_message_with_topic(self, mock_post):
131+
"""When TELEGRAM_TOPIC_ID is set, message goes to topics chat with message_thread_id."""
132+
mock_response = unittest.mock.Mock()
133+
mock_response.status_code = 200
134+
mock_response.raise_for_status = unittest.mock.Mock()
135+
mock_post.return_value = mock_response
136+
137+
with patch.dict(
138+
os.environ,
139+
{
140+
"TELEGRAM_BOT_TOKEN_DEFAULT": "default_token",
141+
"TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id",
142+
"TELEGRAM_TOPIC_ID_AAVE": "42",
143+
"LOG_LEVEL": "INFO",
144+
},
145+
):
146+
send_telegram_message("Test message", "aave")
147+
148+
mock_post.assert_called_once()
149+
url = mock_post.call_args[0][0]
150+
kwargs = mock_post.call_args[1]
151+
self.assertEqual(kwargs["json"]["chat_id"], "topics_chat_id")
152+
self.assertEqual(kwargs["json"]["message_thread_id"], 42)
153+
self.assertIn("default_token", url)
154+
155+
@patch("utils.telegram.requests.post")
156+
def test_send_telegram_message_topic_uses_default_bot(self, mock_post):
157+
"""Topic routing always uses the default bot, even if protocol-specific bot exists."""
158+
mock_response = unittest.mock.Mock()
159+
mock_response.status_code = 200
160+
mock_response.raise_for_status = unittest.mock.Mock()
161+
mock_post.return_value = mock_response
162+
163+
with patch.dict(
164+
os.environ,
165+
{
166+
"TELEGRAM_BOT_TOKEN_DEFAULT": "default_token",
167+
"TELEGRAM_BOT_TOKEN_AAVE": "aave_specific_token",
168+
"TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id",
169+
"TELEGRAM_TOPIC_ID_AAVE": "7",
170+
"LOG_LEVEL": "INFO",
171+
},
172+
):
173+
send_telegram_message("Test", "aave")
174+
url = mock_post.call_args[0][0]
175+
self.assertIn("default_token", url)
176+
self.assertNotIn("aave_specific_token", url)
177+
178+
@patch("utils.telegram.requests.post")
179+
def test_send_telegram_message_no_topic_falls_back(self, mock_post):
180+
"""Without topic ID, uses legacy per-protocol chat routing."""
181+
mock_response = unittest.mock.Mock()
182+
mock_response.status_code = 200
183+
mock_response.raise_for_status = unittest.mock.Mock()
184+
mock_post.return_value = mock_response
185+
186+
with patch.dict(
187+
os.environ,
188+
{
189+
"TELEGRAM_BOT_TOKEN_AAVE": "aave_token",
190+
"TELEGRAM_CHAT_ID_AAVE": "aave_chat_id",
191+
"TELEGRAM_CHAT_ID_TOPICS": "topics_chat_id",
192+
"LOG_LEVEL": "INFO",
193+
},
194+
):
195+
send_telegram_message("Test", "aave")
196+
kwargs = mock_post.call_args[1]
197+
self.assertEqual(kwargs["json"]["chat_id"], "aave_chat_id")
198+
self.assertNotIn("message_thread_id", kwargs["json"])
199+
127200

128201
class TestAlert(unittest.TestCase):
129202
"""Tests for the Alert system."""

utils/telegram.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,33 @@ def send_telegram_message(
4747
message = message[: MAX_MESSAGE_LENGTH - 3] + "..."
4848
plain_text = True
4949

50-
# Get bot token and chat ID from environment variables
51-
bot_token = os.getenv(f"TELEGRAM_BOT_TOKEN_{protocol.upper()}")
52-
if not bot_token:
53-
bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT")
50+
# Check if this protocol has a topic ID configured (forum-style group)
51+
topic_id = os.getenv(f"TELEGRAM_TOPIC_ID_{protocol.upper()}")
5452

55-
chat_id = os.getenv(f"TELEGRAM_CHAT_ID_{protocol.upper()}")
53+
if topic_id:
54+
# Topics always use the default bot and the shared topics chat
55+
bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT")
56+
chat_id = os.getenv("TELEGRAM_CHAT_ID_TOPICS")
57+
else:
58+
# Legacy per-protocol chat routing
59+
bot_token = os.getenv(f"TELEGRAM_BOT_TOKEN_{protocol.upper()}")
60+
if not bot_token:
61+
bot_token = os.getenv("TELEGRAM_BOT_TOKEN_DEFAULT")
62+
chat_id = os.getenv(f"TELEGRAM_CHAT_ID_{protocol.upper()}")
5663

5764
if not bot_token or not chat_id:
5865
logger.warning("Missing Telegram credentials for %s", protocol)
5966
return
6067

6168
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
62-
payload = {
69+
payload: dict[str, object] = {
6370
"chat_id": chat_id,
6471
"text": message,
6572
"parse_mode": "Markdown" if not plain_text else None,
6673
"disable_notification": disable_notification,
6774
}
75+
if topic_id:
76+
payload["message_thread_id"] = int(topic_id)
6877

6978
try:
7079
response = requests.post(url, json=payload, timeout=10)

0 commit comments

Comments
 (0)