feat: Telegram settings in DB with UI edit and test send

Store notification_channels (migration 004), effective config DB over env,
PUT/test API endpoints, Settings form, and pass db session into ingest notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:05:32 +10:00
parent e0ba384705
commit 79a78dc7c9
12 changed files with 564 additions and 116 deletions
@@ -0,0 +1,109 @@
"""Effective notification channel config (DB overrides env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.notification_channel import NotificationChannel
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
CHANNEL_TELEGRAM = "telegram"
@dataclass(frozen=True)
class TelegramConfig:
enabled: bool
bot_token: str
chat_id: str
min_severity: str
source: str # env | db
@property
def configured(self) -> bool:
return bool(self.bot_token.strip() and self.chat_id.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def _telegram_from_env() -> TelegramConfig:
settings = get_settings()
return TelegramConfig(
enabled=bool(settings.telegram_enabled),
bot_token=settings.telegram_bot_token.strip(),
chat_id=settings.telegram_chat_id.strip(),
min_severity=settings.telegram_min_severity.strip() or "high",
source="env",
)
def get_effective_telegram_config(db: Session | None = None) -> TelegramConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_telegram_config(session)
finally:
session.close()
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
env_cfg = _telegram_from_env()
if row is None:
return env_cfg
token = (row.bot_token or "").strip() or env_cfg.bot_token
chat_id = (row.chat_id or "").strip() or env_cfg.chat_id
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
if min_sev not in VALID_SEVERITIES:
min_sev = env_cfg.min_severity
return TelegramConfig(
enabled=bool(row.enabled),
bot_token=token,
chat_id=chat_id,
min_severity=min_sev,
source="db",
)
def upsert_telegram_channel(
db: Session,
*,
enabled: bool | None = None,
bot_token: str | None = None,
chat_id: str | None = None,
min_severity: str | None = None,
) -> TelegramConfig:
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
env_cfg = _telegram_from_env()
if row is None:
row = NotificationChannel(
channel=CHANNEL_TELEGRAM,
enabled=env_cfg.enabled,
bot_token=env_cfg.bot_token or None,
chat_id=env_cfg.chat_id or None,
min_severity=env_cfg.min_severity,
)
db.add(row)
if enabled is not None:
row.enabled = enabled
if min_severity is not None:
if min_severity not in VALID_SEVERITIES:
raise ValueError(f"invalid min_severity: {min_severity}")
row.min_severity = min_severity
if bot_token is not None and bot_token.strip():
row.bot_token = bot_token.strip()
if chat_id is not None and chat_id.strip():
row.chat_id = chat_id.strip()
db.commit()
db.refresh(row)
return get_effective_telegram_config(db)
+56 -25
View File
@@ -1,50 +1,74 @@
import logging
import httpx
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event, Problem
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
logger = logging.getLogger(__name__)
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
class TelegramNotConfiguredError(Exception):
"""Telegram disabled or missing token/chat_id."""
class TelegramSendError(Exception):
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0)
def _is_enabled() -> bool:
settings = get_settings()
return bool(
settings.telegram_enabled
and settings.telegram_bot_token.strip()
and settings.telegram_chat_id.strip()
)
def _send_text(message: str) -> None:
settings = get_settings()
if not _is_enabled():
def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
cfg = config or get_effective_telegram_config()
if not force and not cfg.active:
return
url = f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendMessage"
payload = {"chat_id": settings.telegram_chat_id, "text": message, "disable_web_page_preview": True}
if not cfg.bot_token or not cfg.chat_id:
if force:
raise TelegramNotConfiguredError("Telegram: не задан bot token или chat_id")
return
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
payload = {"chat_id": cfg.chat_id, "text": message, "disable_web_page_preview": True}
try:
with httpx.Client(timeout=8.0) as client:
response = client.post(url, json=payload)
response.raise_for_status()
except Exception:
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
raise TelegramSendError(f"Telegram API HTTP {exc.response.status_code}: {detail}", status_code=exc.response.status_code) from exc
except Exception as exc:
logger.exception("telegram send failed")
raise TelegramSendError(str(exc)) from exc
def _should_notify_severity(severity: str) -> bool:
settings = get_settings()
min_level = _severity_value(settings.telegram_min_severity)
def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
cfg = config or get_effective_telegram_config()
if not cfg.enabled:
raise TelegramNotConfiguredError("Telegram отключён (enabled=false)")
if not cfg.configured:
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
send_telegram_text(
"✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.",
config=cfg,
force=True,
)
def _should_notify_severity(severity: str, *, config: TelegramConfig) -> bool:
min_level = _severity_value(config.min_severity)
return _severity_value(severity) >= min_level
def notify_event(event: Event) -> None:
if not _should_notify_severity(event.severity):
def notify_event(event: Event, *, db: Session | None = None) -> None:
cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
return
host = event.host.hostname if event.host else "unknown"
message = (
@@ -55,11 +79,15 @@ def notify_event(event: Event) -> None:
f"Title: {event.title}\n"
f"Summary: {event.summary}"
)
_send_text(message)
try:
send_telegram_text(message, config=cfg)
except TelegramSendError:
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None) -> None:
if not _should_notify_severity(problem.severity):
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
return
host = problem.host.hostname if problem.host else "unknown"
related = ""
@@ -73,4 +101,7 @@ def notify_problem(problem: Problem, event: Event | None = None) -> None:
f"Title: {problem.title}\n"
f"Summary: {problem.summary}{related}"
)
_send_text(message)
try:
send_telegram_text(message, config=cfg)
except TelegramSendError:
logger.exception("notify_problem telegram failed problem_id=%s", problem.id)