Skip to content

Commit 5b417d2

Browse files
committed
feat(telegram): add admin incident commands
1 parent 73a384a commit 5b417d2

9 files changed

Lines changed: 463 additions & 4 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
5454

5555
# [Optional] Telegram Bot API Token (e.g. from BotFather)
5656
TELEGRAM_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
57+
TELEGRAM_ADMIN_USER_IDS=123456789,987654321
5758

5859
# [Optional] URL for the Telegram WebApp / Mini-App (default: https://quantara.xyz)
5960
TELEGRAM_WEBAPP_URL=https://quantara.xyz

quantara/web_app/db/crud/telegram.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TypeVar
77

88
from sqlalchemy import update
9+
from sqlalchemy.exc import SQLAlchemyError
910

1011
from web_app.db.models import Base, TelegramUser
1112

@@ -107,3 +108,18 @@ def set_allow_notification(self, telegram_id: str, wallet_id: str) -> bool:
107108
)
108109
)
109110
return True
111+
112+
def get_notification_recipients(self) -> list[str]:
113+
"""Return all Telegram IDs that opted in to notifications."""
114+
with self.Session() as session:
115+
try:
116+
rows = (
117+
session.query(TelegramUser.telegram_id)
118+
.filter(TelegramUser.is_allowed_notification.is_(True))
119+
.distinct()
120+
.all()
121+
)
122+
return [telegram_id for (telegram_id,) in rows]
123+
except SQLAlchemyError as exc:
124+
logger.error("Failed to load Telegram notification recipients: %s", exc)
125+
return []

quantara/web_app/telegram/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
It imports necessary components from the aiogram library and configures logging.
55
"""
66

7-
from aiogram import Bot, Dispatcher
87
import logging
98

9+
from aiogram import Bot, Dispatcher
10+
1011
from .config import TELEGRAM_TOKEN
11-
from .handlers import cmd_router
12+
from .handlers import admin_router, cmd_router
1213

1314
# Set up logging
1415
logging.basicConfig(level=logging.INFO)
@@ -27,4 +28,4 @@
2728
# Create a Dispatcher for handling updates
2829
dp = Dispatcher()
2930
# Include command routers for handling specific commands
30-
dp.include_routers(cmd_router)
31+
dp.include_routers(cmd_router, admin_router)

quantara/web_app/telegram/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,20 @@
1313
# Retrieve the Telegram bot token from environment variables
1414
TELEGRAM_TOKEN = getenv("TELEGRAM_TOKEN")
1515
WEBAPP_URL = getenv("TELEGRAM_WEBAPP_URL", "https://quantara.xyz")
16+
17+
18+
def parse_admin_ids(value: str) -> frozenset[int]:
19+
"""Parse a comma-separated Telegram user ID allowlist."""
20+
admin_ids: set[int] = set()
21+
for item in value.split(","):
22+
item = item.strip()
23+
if not item:
24+
continue
25+
try:
26+
admin_ids.add(int(item))
27+
except ValueError:
28+
continue
29+
return frozenset(admin_ids)
30+
31+
32+
TELEGRAM_ADMIN_USER_IDS = parse_admin_ids(getenv("TELEGRAM_ADMIN_USER_IDS", ""))

quantara/web_app/telegram/handlers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
It serves as an entry point for the command handling functionality.
55
"""
66

7+
from .admin import admin_router
78
from .command import cmd_router
89

910
# Import command router for handling commands in the bot
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Administrative Telegram commands for incident response."""
2+
3+
import asyncio
4+
import logging
5+
import os
6+
import secrets
7+
import time
8+
9+
import redis.asyncio as redis
10+
from aiogram import F, Router
11+
from aiogram.filters import Command, CommandObject
12+
from aiogram.types import (
13+
CallbackQuery,
14+
InlineKeyboardButton,
15+
InlineKeyboardMarkup,
16+
Message,
17+
)
18+
19+
from web_app.db.crud.telegram import TelegramUserDBConnector
20+
from web_app.telegram.config import TELEGRAM_ADMIN_USER_IDS
21+
22+
logger = logging.getLogger(__name__)
23+
24+
admin_router = Router()
25+
telegram_db = TelegramUserDBConnector()
26+
27+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
28+
BROADCAST_RATE_LIMIT_SECONDS = 60 * 60
29+
BROADCAST_CONFIRM_TTL_SECONDS = 10 * 60
30+
HEALTH_REDIS_TIMEOUT_SECONDS = 1.5
31+
NOTIFICATION_PAUSE_KEY = "quantara:telegram:notifications:paused"
32+
BROADCAST_RATE_KEY_PREFIX = "quantara:telegram:admin:broadcast:rate"
33+
BROADCAST_PENDING_KEY_PREFIX = "quantara:telegram:admin:broadcast:pending"
34+
BOT_STARTED_AT = time.monotonic()
35+
36+
_redis_client: redis.Redis | None = None
37+
38+
39+
def get_redis_client() -> redis.Redis:
40+
"""Return the lazily initialized shared Redis client."""
41+
global _redis_client
42+
if _redis_client is None:
43+
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
44+
return _redis_client
45+
46+
47+
def _is_admin(user) -> bool:
48+
"""Return whether an aiogram user is in the configured admin allowlist."""
49+
return bool(user and user.id in TELEGRAM_ADMIN_USER_IDS)
50+
51+
52+
async def _reject_unauthorized(event: Message | CallbackQuery) -> bool:
53+
"""Reply to unauthorized callers and report whether processing must stop."""
54+
if _is_admin(event.from_user):
55+
return False
56+
if isinstance(event, CallbackQuery):
57+
await event.answer("Admin authorization required.", show_alert=True)
58+
else:
59+
await event.answer("Admin authorization required.")
60+
return True
61+
62+
63+
def _rate_key(admin_id: int) -> str:
64+
"""Build the per-admin hourly broadcast rate-limit key."""
65+
return f"{BROADCAST_RATE_KEY_PREFIX}:{admin_id}"
66+
67+
68+
def _pending_key(admin_id: int, nonce: str) -> str:
69+
"""Build the key for a broadcast awaiting confirmation."""
70+
return f"{BROADCAST_PENDING_KEY_PREFIX}:{admin_id}:{nonce}"
71+
72+
73+
async def notifications_are_paused() -> bool:
74+
"""Return whether alert delivery is globally paused, failing open on Redis errors."""
75+
try:
76+
return bool(await get_redis_client().exists(NOTIFICATION_PAUSE_KEY))
77+
except Exception as exc:
78+
logger.error("Unable to read Telegram pause state: %s", exc)
79+
return False
80+
81+
82+
@admin_router.message(
83+
Command("broadcast"), F.from_user.func(lambda user: _is_admin(user))
84+
)
85+
async def broadcast_cmd(message: Message, command: CommandObject) -> None:
86+
"""Stage a broadcast and ask the administrator for confirmation."""
87+
if await _reject_unauthorized(message):
88+
return
89+
90+
text = (command.args or "").strip()
91+
if not text:
92+
await message.answer("Usage: /broadcast <message>")
93+
return
94+
95+
client = get_redis_client()
96+
remaining = await client.ttl(_rate_key(message.from_user.id))
97+
if remaining > 0:
98+
await message.answer(
99+
f"Broadcast rate limit active. Try again in {remaining} seconds."
100+
)
101+
return
102+
103+
nonce = secrets.token_urlsafe(8)
104+
await client.setex(
105+
_pending_key(message.from_user.id, nonce),
106+
BROADCAST_CONFIRM_TTL_SECONDS,
107+
text,
108+
)
109+
keyboard = InlineKeyboardMarkup(
110+
inline_keyboard=[
111+
[
112+
InlineKeyboardButton(
113+
text="Confirm broadcast",
114+
callback_data=f"admin:broadcast:confirm:{nonce}",
115+
),
116+
InlineKeyboardButton(
117+
text="Cancel",
118+
callback_data=f"admin:broadcast:cancel:{nonce}",
119+
),
120+
]
121+
]
122+
)
123+
await message.answer(
124+
f"Broadcast this message to all opted-in users?\n\n{text}",
125+
reply_markup=keyboard,
126+
)
127+
128+
129+
async def _send_broadcast(bot, recipients: list[str], text: str) -> tuple[int, int]:
130+
"""Send a bounded-concurrency broadcast and return sent/failed counts."""
131+
semaphore = asyncio.Semaphore(20)
132+
133+
async def send(telegram_id: str) -> bool:
134+
"""Send to one recipient without aborting the remaining broadcast."""
135+
async with semaphore:
136+
try:
137+
await bot.send_message(chat_id=telegram_id, text=text)
138+
return True
139+
except Exception as exc:
140+
logger.warning("Broadcast delivery failed for %s: %s", telegram_id, exc)
141+
return False
142+
143+
results = await asyncio.gather(*(send(telegram_id) for telegram_id in recipients))
144+
sent = sum(results)
145+
return sent, len(results) - sent
146+
147+
148+
@admin_router.callback_query(
149+
F.data.startswith("admin:broadcast:confirm:"),
150+
F.from_user.func(lambda user: _is_admin(user)),
151+
)
152+
async def confirm_broadcast(callback: CallbackQuery) -> None:
153+
"""Send a staged broadcast after atomically acquiring the hourly limit."""
154+
if await _reject_unauthorized(callback):
155+
return
156+
157+
nonce = callback.data.rsplit(":", 1)[-1]
158+
client = get_redis_client()
159+
pending_key = _pending_key(callback.from_user.id, nonce)
160+
text = await client.get(pending_key)
161+
if text is None:
162+
await callback.answer("This confirmation expired.", show_alert=True)
163+
return
164+
165+
acquired = await client.set(
166+
_rate_key(callback.from_user.id),
167+
"1",
168+
ex=BROADCAST_RATE_LIMIT_SECONDS,
169+
nx=True,
170+
)
171+
if not acquired:
172+
await callback.answer("Broadcast rate limit active.", show_alert=True)
173+
return
174+
175+
await client.delete(pending_key)
176+
await callback.answer("Broadcast started.")
177+
recipients = await asyncio.to_thread(telegram_db.get_notification_recipients)
178+
sent, failed = await _send_broadcast(callback.bot, recipients, text)
179+
await callback.message.edit_text(
180+
f"Broadcast complete: {sent} sent, {failed} failed."
181+
)
182+
183+
184+
@admin_router.callback_query(
185+
F.data.startswith("admin:broadcast:cancel:"),
186+
F.from_user.func(lambda user: _is_admin(user)),
187+
)
188+
async def cancel_broadcast(callback: CallbackQuery) -> None:
189+
"""Discard a staged broadcast."""
190+
if await _reject_unauthorized(callback):
191+
return
192+
193+
nonce = callback.data.rsplit(":", 1)[-1]
194+
await get_redis_client().delete(_pending_key(callback.from_user.id, nonce))
195+
await callback.answer("Broadcast cancelled.")
196+
await callback.message.edit_text("Broadcast cancelled.")
197+
198+
199+
@admin_router.message(Command("pause"), F.from_user.func(lambda user: _is_admin(user)))
200+
async def pause_cmd(message: Message, command: CommandObject) -> None:
201+
"""Pause Telegram alert delivery for the requested number of minutes."""
202+
if await _reject_unauthorized(message):
203+
return
204+
205+
try:
206+
minutes = int((command.args or "").strip())
207+
except ValueError:
208+
minutes = 0
209+
if minutes <= 0:
210+
await message.answer("Usage: /pause <positive minutes>")
211+
return
212+
213+
seconds = minutes * 60
214+
await get_redis_client().set(
215+
NOTIFICATION_PAUSE_KEY,
216+
str(message.from_user.id),
217+
ex=seconds,
218+
)
219+
await message.answer(f"Notifications paused for {minutes} minute(s).")
220+
221+
222+
@admin_router.message(Command("health"), F.from_user.func(lambda user: _is_admin(user)))
223+
async def health_cmd(message: Message) -> None:
224+
"""Report bot uptime and bounded Redis ping latency."""
225+
if await _reject_unauthorized(message):
226+
return
227+
228+
started = time.perf_counter()
229+
try:
230+
await asyncio.wait_for(
231+
get_redis_client().ping(), timeout=HEALTH_REDIS_TIMEOUT_SECONDS
232+
)
233+
redis_status = "up"
234+
except TimeoutError:
235+
redis_status = "timeout"
236+
except Exception:
237+
redis_status = "down"
238+
latency_ms = (time.perf_counter() - started) * 1_000
239+
uptime_seconds = int(time.monotonic() - BOT_STARTED_AT)
240+
await message.answer(
241+
f"Bot uptime: {uptime_seconds}s\n" f"Redis: {redis_status} ({latency_ms:.1f}ms)"
242+
)

quantara/web_app/telegram/notifications.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from decimal import Decimal
88

99
from aiogram.exceptions import TelegramRetryAfter
10+
1011
from web_app.db.crud import TelegramUserDBConnector
1112
from web_app.telegram import bot
1213

1314
from .dedupe import NotificationDedupe
15+
from .handlers.admin import notifications_are_paused
1416
from .texts import i18n
1517

1618
logger = logging.getLogger(__name__)
@@ -32,6 +34,12 @@ async def send_health_ratio_notification(
3234
Send notification about health ratio to user.
3335
Deduplication prevents repeated alerts for the same user/position within 4h.
3436
"""
37+
if await notifications_are_paused():
38+
logger.info(
39+
"Telegram alerts are paused; skipping notification to %s", telegram_id
40+
)
41+
return
42+
3543
if position_id and not await dedupe.should_send(telegram_id, position_id):
3644
return
3745

@@ -55,7 +63,10 @@ async def send_health_ratio_notification(
5563

5664
await asyncio.sleep(retry_after)
5765
await send_health_ratio_notification(
58-
telegram_id, health_ratio, position_id=position_id, retry_count=retry_count - 1
66+
telegram_id,
67+
health_ratio,
68+
position_id=position_id,
69+
retry_count=retry_count - 1,
5970
)
6071
except Exception as e:
6172
if position_id:

quantara/web_app/tests/db/test_telegram_user.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,15 @@ def test_set_allow_notification(telegram_user_db):
163163
with patch.object(telegram_user_db, "save_or_update_user", return_value=True):
164164
result = telegram_user_db.set_allow_notification("t9", "w9")
165165
assert result is True
166+
167+
168+
def test_get_notification_recipients_returns_only_query_results(telegram_user_db):
169+
"""Return the distinct opted-in Telegram IDs selected by the query."""
170+
session = MagicMock()
171+
telegram_user_db.Session.return_value.__enter__.return_value = session
172+
session.query.return_value.filter.return_value.distinct.return_value.all.return_value = [
173+
("t1",),
174+
("t2",),
175+
]
176+
177+
assert telegram_user_db.get_notification_recipients() == ["t1", "t2"]

0 commit comments

Comments
 (0)