Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/_run-monitoring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ 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) ──
# 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 }}

Expand Down
79 changes: 76 additions & 3 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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
Expand All @@ -107,23 +108,95 @@ 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(
os.environ,
{
"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."""
Expand Down
21 changes: 15 additions & 6 deletions utils/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down