Files
Nexus/server/infrastructure/telegram/__init__.py
T
Nexus Agent fe8b24ca0b fix(notify): 资源恢复 Telegram 联动 CPU/内存/磁盘开关
关闭分项告警后不再推送对应恢复消息;仪表盘移除服务器列表并更新设置页说明。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:22:38 +08:00

318 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Nexus — Telegram Bot Notification Module
Sends alerts/recovery/system messages via Telegram Bot API.
All outbound messages use detailed Simplified Chinese.
"""
import html
import logging
import re
import httpx
from datetime import datetime, timezone
from server.config import settings
from server.utils.notify_toggles import notify_attr_enabled, resource_alert_notify_enabled, resource_recovery_notify_enabled
logger = logging.getLogger("nexus.telegram")
TELEGRAM_API_BASE = "https://api.telegram.org"
_http_client: httpx.AsyncClient | None = None
_REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"\b(?:mysql|postgresql|redis)(?:\+\w+)?://\S+", re.I), "<连接地址已隐藏>"),
(re.compile(r"\bpassword[=:]\S+", re.I), "password=<已隐藏>"),
(re.compile(r"\bBearer\s+\S+", re.I), "Bearer <已隐藏>"),
(re.compile(r"\b(?:SECRET|API|ENCRYPTION)_KEY[=:]\S+", re.I), "<密钥已隐藏>"),
]
_METRIC_CN: dict[str, str] = {
"cpu": "CPU",
"mem": "内存",
"disk": "磁盘",
"time_drift": "时钟偏差",
}
def _now_cn() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def _metric_cn(metric: str) -> str:
return _METRIC_CN.get(metric.lower(), metric)
def _threshold_for(alert_type: str) -> int:
mapping = {
"cpu": settings.CPU_ALERT_THRESHOLD,
"mem": settings.MEM_ALERT_THRESHOLD,
"disk": settings.DISK_ALERT_THRESHOLD,
}
return mapping.get(alert_type.lower(), 80)
def _system_name() -> str:
name = (settings.SYSTEM_NAME or "Nexus").strip()
return html.escape(name)
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."""
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
return False
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("Telegram 已发送: %s...", sanitize_external_message(message)[:50])
return True
logger.error("Telegram API 错误: %s %s", resp.status_code, resp.text[:100])
return False
except Exception as e:
logger.error("Telegram 发送失败: %s", e)
return False
async def send_telegram_alert(
server_name: str,
alert_type: str,
value: float,
server_id: int | None = None,
) -> bool:
"""Send server resource alert — respects per-type notify toggle."""
if not resource_alert_notify_enabled(alert_type):
logger.debug("告警通知已关闭: %s", alert_type)
return False
metric = _metric_cn(alert_type)
threshold = _threshold_for(alert_type)
safe_name = html.escape(server_name)
sid_line = f"\n子机 ID<code>{server_id}</code>" if server_id is not None else ""
return await send_telegram(
f"🔴 <b>【资源告警】{metric} 使用率超过阈值</b>\n"
f"平台:{_system_name()}\n"
f"子机名称:<b>{safe_name}</b>{sid_line}\n"
f"当前 {metric} 使用率:<b>{value:.1f}%</b>\n"
f"告警阈值:{threshold}%\n"
f"检测来源:Agent 心跳(Layer 2 应用内告警)\n"
f"具体情况:子机 {metric} 持续高于阈值,可能存在负载过高、进程异常或磁盘/内存不足\n"
f"建议操作:登录子机排查高占用进程,或在 Nexus 服务器页查看详情\n"
f"时间:{_now_cn()}"
)
async def send_telegram_recovery(
server_name: str,
metric: str,
value: float,
server_id: int | None = None,
) -> bool:
"""Send server recovery notification — respects NOTIFY_RECOVERY + per-metric toggle."""
if not resource_recovery_notify_enabled(metric):
logger.debug("恢复通知已关闭: %s / NOTIFY_RECOVERY", metric)
return False
metric_cn = _metric_cn(metric)
threshold = _threshold_for(metric)
safe_name = html.escape(server_name)
sid_line = f"\n子机 ID<code>{server_id}</code>" if server_id is not None else ""
return await send_telegram(
f"🟢 <b>【资源恢复】{metric_cn} 已回落至正常范围</b>\n"
f"平台:{_system_name()}\n"
f"子机名称:<b>{safe_name}</b>{sid_line}\n"
f"当前 {metric_cn} 使用率:{value:.1f}%(阈值 {threshold}%\n"
f"检测来源:Agent 心跳(Layer 2 应用内告警)\n"
f"具体情况:指标已恢复正常,本次告警状态已清除;若再次超阈值将重新推送\n"
f"时间:{_now_cn()}"
)
async def send_telegram_system_alert(
summary: str,
notify_key: str = "",
*,
title: str = "系统告警",
details: list[str] | None = None,
action: str = "",
icon: str = "⚠️",
) -> bool:
"""Send system-level alert with structured Chinese detail lines."""
if notify_key and not notify_attr_enabled(notify_key):
logger.debug("系统通知已关闭: %s", notify_key)
return False
lines = [
f"{icon} <b>【{html.escape(title)}】</b>",
f"平台:{_system_name()}",
html.escape(sanitize_external_message(summary)),
]
if details:
for item in details:
lines.append(f"• {html.escape(sanitize_external_message(item))}")
if action:
lines.append(f"建议操作:{html.escape(sanitize_external_message(action))}")
lines.append(f"时间:{_now_cn()}")
return await send_telegram("\n".join(lines))
async def send_telegram_restart_success() -> bool:
"""Notify that Python backend has successfully restarted."""
if not notify_attr_enabled("NOTIFY_RESTART"):
return False
return await send_telegram(
f"🟢 <b>【服务恢复】Nexus 后端进程已自动重启成功</b>\n"
f"平台:{_system_name()}\n"
f"检测来源:Layer 2 self_monitor30 秒周期)\n"
f"具体情况:Python/FastAPI 进程此前异常退出或僵死,监控已触发自动拉起\n"
f"当前状态:服务已恢复,API 与 WebSocket 应可正常访问\n"
f"时间:{_now_cn()}"
)
async def send_telegram_restart_failed() -> bool:
"""Notify that Python backend restart failed."""
if not notify_attr_enabled("NOTIFY_RESTART"):
return False
return await send_telegram(
f"🔴 <b>【服务故障】Nexus 后端自动重启失败</b>\n"
f"平台:{_system_name()}\n"
f"检测来源:Layer 2 self_monitor30 秒周期)\n"
f"具体情况:监控尝试重启 Python 进程但未成功,平台可能无法提供 API\n"
f"建议操作:SSH 登录宿主机,检查 docker logs / supervisor 日志并人工介入\n"
f"时间:{_now_cn()}"
)
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/sync completion notification via Telegram."""
if not notify_attr_enabled("NOTIFY_SYNC_COMPLETE"):
return False
safe_source = html.escape(sanitize_external_message(source_path, max_len=80))
safe_operator = html.escape(operator)
if failed == 0:
icon = "🟢"
title = "文件推送完成"
situation = "全部目标子机推送成功,无失败项"
elif failed < total:
icon = "🟡"
title = "文件推送部分失败"
situation = f"共 {total} 台子机,{failed} 台推送失败,请查看失败列表"
else:
icon = "🔴"
title = "文件推送全部失败"
situation = "所有目标子机均未推送成功,请检查源路径、权限与子机在线状态"
lines = [
f"{icon} <b>【{title}】</b>",
f"平台:{_system_name()}",
f"具体情况:{situation}",
f"源路径:<code>{safe_source}</code>",
f"操作人:{safe_operator}",
f"统计:成功 {completed} 台 / 失败 {failed} 台 / 总计 {total} 台",
]
if duration_seconds is not None:
mins, secs = divmod(duration_seconds, 60)
duration = f"{mins}{secs} 秒" if mins > 0 else f"{secs} 秒"
lines.append(f"耗时:{duration}")
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_cn()}")
return await send_telegram("\n".join(lines))
async def send_telegram_script_summary(
execution_id: int,
script_label: str,
operator: str,
status: str,
success_count: int,
failed_count: int,
total: int,
board_url: str,
) -> bool:
"""Send script execution summary (no per-server failure list) via Telegram."""
if not notify_attr_enabled("NOTIFY_SCRIPT_COMPLETE"):
return False
safe_label = html.escape(sanitize_external_message(script_label, max_len=80))
safe_operator = html.escape(operator)
safe_url = html.escape(board_url)
if status == "completed":
icon = "🟢"
title = "脚本执行完成"
elif status == "failed":
icon = "🔴"
title = "脚本执行失败"
else:
icon = "🟡"
title = "脚本执行部分失败"
if failed_count > 0 and success_count == 0:
stat_line = f"失败 {failed_count} 台"
elif failed_count > 0:
stat_line = f"失败 {failed_count} 台(成功 {success_count} 台)"
else:
stat_line = f"成功 {success_count} 台"
lines = [
f"{icon} <b>【{title}】</b>",
f"平台:{_system_name()}",
f"脚本:{safe_label} · 执行 #{execution_id}",
f"统计:{stat_line}",
f"操作人:{safe_operator}",
f"详情:<a href=\"{safe_url}\">打开执行看板</a>",
f"时间:{_now_cn()}",
]
return await send_telegram("\n".join(lines))