feat: always notify agent.lifecycle via SAC with telegram_via routing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-05-31 12:25:48 +10:00
parent 3d4d1f3c76
commit 366a9c83f7
6 changed files with 160 additions and 2 deletions
+9 -1
View File
@@ -17,7 +17,13 @@ from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.schema_validate import validate_event_payload
from app.services.notify_dispatch import notify_daily_report, notify_event, notify_problem
from app.services.notify_dispatch import (
LIFECYCLE_EVENT_TYPE,
notify_daily_report,
notify_event,
notify_lifecycle,
notify_problem,
)
DAILY_REPORT_EVENT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
@@ -68,6 +74,8 @@ def post_event(
problem, problem_created = maybe_create_problem(db, event)
if event.type in DAILY_REPORT_EVENT_TYPES:
notify_daily_report(event, db=db)
elif event.type == LIFECYCLE_EVENT_TYPE:
notify_lifecycle(event, db=db)
else:
notify_event(event, db=db)
if problem is not None and problem_created:
@@ -18,7 +18,7 @@ COOLDOWN_KIND_EVENT = "event"
COOLDOWN_KIND_PROBLEM = "problem"
# Не душим служебные проверки ingest
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test"})
_EVENT_COOLDOWN_EXEMPT_TYPES = frozenset({"agent.test", "agent.lifecycle"})
def _as_utc(dt: datetime) -> datetime:
+26
View File
@@ -12,6 +12,14 @@ from app.services.notification_severity import severity_meets_minimum
logger = logging.getLogger(__name__)
LIFECYCLE_EVENT_TYPE = "agent.lifecycle"
def _event_telegram_via_agent(event: Event) -> bool:
details = event.details if isinstance(event.details, dict) else {}
via = str(details.get("telegram_via") or "").strip().lower()
return via == "agent"
def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> None:
if policy.use_telegram:
@@ -49,9 +57,27 @@ def notify_problem(problem: Problem, event: Event | None = None, *, db: Session
_dispatch_problem_channels(problem, event, db=db, policy=policy)
def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) -> None:
skip_telegram = _event_telegram_via_agent(event)
if policy.use_telegram and not skip_telegram:
telegram_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_webhook:
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
if policy.use_email:
email_notify.notify_event(event, db=db, apply_policy_gate=False)
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
"""Оповещение по суточному отчёту (severity=info, вне порога policy)."""
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_event_channels(event, db=db, policy=policy)
def notify_lifecycle(event: Event, *, db: Session | None = None) -> None:
"""Старт/стоп/reload агента — всегда в каналы SAC (кроме TG, если telegram_via=agent)."""
policy = get_effective_notification_policy(db)
if not should_notify_event(event, db):
return
_dispatch_lifecycle_channels(event, db=db, policy=policy)
@@ -277,7 +277,52 @@ def format_generic_event_html(event: Event) -> str:
return msg.rstrip()
_LIFECYCLE_HEADERS: dict[str, str] = {
"started": "✅ Агент запущен",
"stopped": "⚠️ Агент остановлен",
"settings_reloaded": "🔄 Настройки агента перечитаны",
}
_LIFECYCLE_TRIGGER_LABELS: dict[str, str] = {
"boot": "загрузка ОС / задача планировщика",
"deploy_recycle": "обновление скрипта (recycle)",
"settings_reload": "graceful restart (settings)",
"manual_recycle": "ручной recycle",
"shutdown": "остановка процесса",
}
def format_lifecycle_html(event: Event) -> str:
details = _details_dict(event)
lifecycle = _detail(details, "lifecycle", default="started").strip().lower()
trigger = _detail(details, "trigger", default="").strip().lower()
header = _LIFECYCLE_HEADERS.get(lifecycle, f"📋 Lifecycle: {html_escape(lifecycle)}")
msg = f"<b>{header}</b>\n"
msg += _line("🏢", "Хост", host_label(event.host))
product = ""
version = ""
payload = event.payload if isinstance(event.payload, dict) else {}
source = payload.get("source")
if isinstance(source, dict):
product = str(source.get("product") or "").strip()
version = str(source.get("product_version") or "").strip()
if product or version:
label = " ".join(x for x in (product, version) if x).strip()
if label:
msg += _line("📦", "Версия", html_escape(label))
if trigger and trigger != "-":
trigger_human = _LIFECYCLE_TRIGGER_LABELS.get(trigger, trigger)
msg += _line("🔁", "Причина", html_escape(trigger_human))
msg += _line("🕐", "Время", format_time(event.occurred_at))
if event.summary:
msg += f"\n{html_escape(event.summary)}"
return msg.rstrip()
def _format_event_body_html(event: Event) -> str:
if event.type == "agent.lifecycle":
return format_lifecycle_html(event)
if event.type in ("report.daily.ssh", "report.daily.rdp"):
details = _details_dict(event)
platform = "windows" if event.type == "report.daily.rdp" else "ssh"