Files
Nexus/server/infrastructure/telegram/__init__.py
T
Your Name 74de9d303e feat: 推送页面 Round 2 迭代 — 同步说明 + 在线标识 + 失败重试 + Telegram通知 + 文件预览
F5: 同步模式详细说明 — 每种模式下方显示说明文字,切换时动态更新
F2: 服务器在线状态标识 — 服务器列表显示🟢/🔴在线/离线标识
F1: 失败自动重试 — 推送失败自动创建重试任务 + 失败行"🔄 重试"按钮
F3: 推送完成 Telegram 通知 — 全成功🟢/部分失败🟡/全失败🔴 三种消息含详情
F4: 文件内容预览 — 文件管理器点击文件名弹出内容预览模态框

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:59:30 +08:00

222 lines
7.8 KiB
Python

"""Nexus — Telegram Bot Notification Module
Sends alerts/recovery/system messages via Telegram Bot API.
"""
import html
import logging
import re
import httpx
from datetime import datetime, timezone
from server.config import settings
logger = logging.getLogger("nexus.telegram")
# Bot API base URL
TELEGRAM_API_BASE = "https://api.telegram.org"
# Reusable httpx client — avoids TCP connection churn during alert storms
_http_client: httpx.AsyncClient | None = None
# F2d-04: never forward raw DB/Redis URLs or credentials to Telegram or logs
_REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"\b(?:mysql|postgresql|redis)(?:\+\w+)?://\S+", re.I), "<connection-url-redacted>"),
(re.compile(r"\bpassword[=:]\S+", re.I), "password=<redacted>"),
(re.compile(r"\bBearer\s+\S+", re.I), "Bearer <redacted>"),
(re.compile(r"\b(?:SECRET|API|ENCRYPTION)_KEY[=:]\S+", re.I), "<key-redacted>"),
]
def sanitize_external_message(text: str, max_len: int = 240) -> str:
"""Strip connection strings and secrets before external notification channels."""
if not text:
return ""
out = text
for pattern, replacement in _REDACT_PATTERNS:
out = pattern.sub(replacement, out)
if len(out) > max_len:
out = out[: max_len - 3] + "..."
return out
async def _get_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=10.0)
return _http_client
async def close_client():
"""Close the shared httpx client — call during app shutdown"""
global _http_client
if _http_client and not _http_client.is_closed:
await _http_client.aclose()
_http_client = None
async def send_telegram(message: str) -> bool:
"""Send a Telegram message via Bot API.
Returns True if sent successfully, False if skipped or failed.
Silently skips if TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not configured.
"""
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
return False # Not configured — silent skip
try:
client = await _get_client()
resp = await client.post(
f"{TELEGRAM_API_BASE}/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage",
json={
"chat_id": settings.TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "HTML",
},
)
if resp.status_code == 200:
logger.info(f"Telegram sent: {sanitize_external_message(message)[:50]}...")
return True
else:
logger.error(f"Telegram API error: {resp.status_code} {resp.text[:100]}")
return False
except Exception as e:
logger.error(f"Telegram send failed: {e}")
return False
def _notify_enabled(setting_value: str) -> bool:
"""Return True unless the setting is explicitly 'false' / '0' / 'no'."""
return setting_value.strip().lower() not in ("false", "0", "no", "off")
async def send_telegram_alert(server_name: str, alert_type: str, value: float) -> bool:
"""Send server resource alert — respects per-type notify toggle."""
# Map alert_type (cpu/mem/disk) to settings toggle
_toggle_map = {
"cpu": "NOTIFY_ALERT_CPU",
"mem": "NOTIFY_ALERT_MEM",
"disk": "NOTIFY_ALERT_DISK",
}
toggle_attr = _toggle_map.get(alert_type.lower(), "NOTIFY_ALERT_CPU")
if not _notify_enabled(getattr(settings, toggle_attr, "true")):
logger.debug(f"Alert notification suppressed by toggle: {toggle_attr}")
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_name = html.escape(server_name)
safe_type = html.escape(alert_type)
return await send_telegram(
f"🔴 <b>告警</b>\n"
f"服务器 <b>{safe_name}</b> {safe_type} 使用率 <b>{value:.1f}%</b>\n"
f"时间: {now}"
)
async def send_telegram_recovery(server_name: str, metric: str, value: float) -> bool:
"""Send server recovery notification — respects NOTIFY_RECOVERY toggle."""
if not _notify_enabled(settings.NOTIFY_RECOVERY):
logger.debug("Recovery notification suppressed by toggle: NOTIFY_RECOVERY")
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_name = html.escape(server_name)
safe_metric = html.escape(metric)
return await send_telegram(
f"🟢 <b>恢复</b>\n"
f"服务器 <b>{safe_name}</b> {safe_metric} 已恢复正常 ({value:.1f}%)\n"
f"时间: {now}"
)
async def send_telegram_system_alert(message: str, notify_key: str = "") -> bool:
"""Send system-level alert.
notify_key: settings attribute name to check toggle, e.g. 'NOTIFY_SYSTEM_REDIS'.
If empty, always send (fallback for callers that don't specify).
"""
if notify_key and not _notify_enabled(getattr(settings, notify_key, "true")):
logger.debug(f"System alert suppressed by toggle: {notify_key}")
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_message = html.escape(sanitize_external_message(message))
return await send_telegram(
f"⚠️ <b>系统告警</b>\n"
f"{safe_message}\n"
f"时间: {now}"
)
async def send_telegram_restart_success() -> bool:
"""Notify that Python backend has successfully restarted — respects NOTIFY_RESTART."""
if not _notify_enabled(settings.NOTIFY_RESTART):
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return await send_telegram(
f"🟢 <b>Nexus后端已恢复</b>\n"
f"Python服务自动重启成功\n"
f"时间: {now}"
)
async def send_telegram_restart_failed() -> bool:
"""Notify that Python backend restart failed — respects NOTIFY_RESTART."""
if not _notify_enabled(settings.NOTIFY_RESTART):
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
return await send_telegram(
f"🔴 <b>Nexus后端重启失败!</b>\n"
f"请人工介入检查\n"
f"时间: {now}"
)
async def send_telegram_sync_complete(
completed: int, failed: int, total: int,
source_path: str, operator: str,
failed_servers: list[str] | None = None,
duration_seconds: int | None = None,
) -> bool:
"""Send push completion notification via Telegram.
- All success → 🟢 green
- Partial failure → 🟡 yellow with failed server list
- All failed → 🔴 red
"""
if not _notify_enabled(getattr(settings, "NOTIFY_SYNC_COMPLETE", "true")):
return False
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
safe_source = html.escape(sanitize_external_message(source_path, max_len=80))
safe_operator = html.escape(operator)
if failed == 0:
icon = "🟢"
title = "推送完成"
elif failed < total:
icon = "🟡"
title = "推送完成(部分失败)"
else:
icon = "🔴"
title = "推送失败"
lines = [
f"{icon} <b>{title}</b>",
f"源路径: <code>{safe_source}</code>",
f"操作人: {safe_operator}",
f"成功: {completed} / 失败: {failed} / 总计: {total}",
]
if duration_seconds is not None:
mins, secs = divmod(duration_seconds, 60)
if mins > 0:
lines.append(f"耗时: {mins}{secs}秒")
else:
lines.append(f"耗时: {secs}秒")
if failed_servers:
names = [html.escape(n) for n in failed_servers[:10]]
lines.append(f"失败服务器: {', '.join(names)}")
if len(failed_servers) > 10:
lines.append(f" ...及其他 {len(failed_servers) - 10} 台")
lines.append(f"时间: {now}")
return await send_telegram("\n".join(lines))