diff --git a/backend/app/services/mobile_notify.py b/backend/app/services/mobile_notify.py
index f5bb27d..5e238bd 100644
--- a/backend/app/services/mobile_notify.py
+++ b/backend/app/services/mobile_notify.py
@@ -16,6 +16,7 @@ from app.models import Event, Problem
from app.models.mobile_device import MobileDevice
from app.services.notification_severity import severity_meets_minimum
from app.services.notification_policy import get_effective_notification_policy
+from app.services.telegram_templates import format_event_mobile_push
logger = logging.getLogger(__name__)
@@ -170,8 +171,7 @@ def _send_fcm_data(
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
- title = f"[{event.severity}] {event.title}"
- body = (event.summary or "")[:200]
+ title, body = format_event_mobile_push(event)
data = {
"kind": "event",
"id": str(event.id),
diff --git a/backend/app/services/telegram_templates.py b/backend/app/services/telegram_templates.py
index 0537453..0391cfa 100644
--- a/backend/app/services/telegram_templates.py
+++ b/backend/app/services/telegram_templates.py
@@ -506,6 +506,43 @@ def format_event_telegram_html(event: Event) -> str:
return _append_event_source(_format_event_body_html(event), event)
+_TELEGRAM_INLINE_TAGS = re.compile(r"?(?:b|i|u|s|code|pre|tg-spoiler)(\s[^>]*)?>", re.I)
+_TELEGRAM_ANCHOR_OPEN = re.compile(r"]*>", re.I)
+_TELEGRAM_ANCHOR_CLOSE = re.compile(r"", re.I)
+
+_MOBILE_PUSH_BODY_MAX = 3500
+
+
+def telegram_html_to_plaintext(text: str) -> str:
+ """Plain text for Seaca push from the same HTML templates as Telegram."""
+ out = sanitize_telegram_html(text)
+ out = _TELEGRAM_INLINE_TAGS.sub("", out)
+ out = _TELEGRAM_ANCHOR_OPEN.sub("", out)
+ out = _TELEGRAM_ANCHOR_CLOSE.sub("", out)
+ out = html.unescape(out)
+ out = re.sub(r"\n{3,}", "\n\n", out)
+ return out.strip()
+
+
+def format_event_mobile_push(event: Event) -> tuple[str, str]:
+ """Title + body for FCM data payload (mirrors Telegram wording)."""
+ plain = telegram_html_to_plaintext(format_event_telegram_html(event))
+ lines = [line.strip() for line in plain.split("\n") if line.strip()]
+ if lines:
+ title = lines[0]
+ body = "\n".join(lines[1:]).strip()
+ if not body:
+ body = (event.summary or "").strip()
+ else:
+ title = f"[{event.severity}] {event.title}"
+ body = (event.summary or "").strip()
+ if len(title) > 120:
+ title = title[:117] + "…"
+ if len(body) > _MOBILE_PUSH_BODY_MAX:
+ body = body[: _MOBILE_PUSH_BODY_MAX - 1] + "…"
+ return title, body
+
+
def _format_rdg_html(event: Event) -> str:
details = _details_dict(event)
if event.type.endswith(".success"):
diff --git a/backend/app/version.py b/backend/app/version.py
index 3af75ca..21e3042 100644
--- a/backend/app/version.py
+++ b/backend/app/version.py
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
-APP_VERSION = "0.9.10"
+APP_VERSION = "0.9.11"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index cbf17d3..e2f888c 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
- assert APP_VERSION == "0.9.10"
+ assert APP_VERSION == "0.9.11"
assert APP_NAME == "Security Alert Center"
- assert APP_VERSION_LABEL == "Security Alert Center v.0.9.10"
+ assert APP_VERSION_LABEL == "Security Alert Center v.0.9.11"
diff --git a/backend/tests/test_mobile_notify.py b/backend/tests/test_mobile_notify.py
index 9a0f7d1..8aca1ac 100644
--- a/backend/tests/test_mobile_notify.py
+++ b/backend/tests/test_mobile_notify.py
@@ -1,10 +1,33 @@
"""FCM push must not break event ingest."""
+from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
-from app.services.mobile_notify import MobileNotConfiguredError, _send_fcm_data
+from app.models import Event, Host
+from app.services.mobile_notify import MobileNotConfiguredError, _event_payload, _send_fcm_data
+
+
+def test_event_payload_lifecycle_uses_telegram_wording():
+ host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
+ event = Event(
+ event_id="00000000-0000-4000-8000-000000000511",
+ host_id=1,
+ host=host,
+ occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
+ category="agent",
+ type="agent.lifecycle",
+ severity="info",
+ title="started",
+ summary="short",
+ details={"lifecycle": "started", "trigger": "settings_reload", "telegram_via": "sac"},
+ payload={"source": {"product": "ssh-monitor", "product_version": "1.4.0-SAC"}},
+ )
+ title, body, data = _event_payload(event)
+ assert title == "✅ Агент запущен"
+ assert "graceful restart" in body
+ assert data["type"] == "agent.lifecycle"
def test_send_fcm_data_swallows_token_errors_by_default():
diff --git a/backend/tests/test_telegram_templates.py b/backend/tests/test_telegram_templates.py
index 342c35f..c7b5834 100644
--- a/backend/tests/test_telegram_templates.py
+++ b/backend/tests/test_telegram_templates.py
@@ -3,7 +3,13 @@
from datetime import datetime, timezone
from app.models import Event, Host
-from app.services.telegram_templates import format_event_telegram_html, format_rdp_login_html, html_escape
+from app.services.telegram_templates import (
+ format_event_mobile_push,
+ format_event_telegram_html,
+ format_rdp_login_html,
+ html_escape,
+ telegram_html_to_plaintext,
+)
def test_html_escape_special_chars():
@@ -145,6 +151,61 @@ def test_event_telegram_includes_sac_source_for_sac_daily_report():
assert "📡 Оповещение: SAC (Security Alert Center)" in text
+def test_telegram_html_to_plaintext_strips_tags():
+ assert telegram_html_to_plaintext("✅ Агент запущен\n🏢 Хост: WIN01") == (
+ "✅ Агент запущен\n🏢 Хост: WIN01"
+ )
+
+
+def test_format_event_mobile_push_lifecycle_includes_trigger():
+ host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
+ event = Event(
+ event_id="00000000-0000-4000-8000-000000000509",
+ host_id=1,
+ host=host,
+ occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
+ category="agent",
+ type="agent.lifecycle",
+ severity="info",
+ title="started",
+ summary="Мониторинг запущен",
+ details={"lifecycle": "started", "trigger": "deploy_recycle", "telegram_via": "sac"},
+ payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}},
+ )
+ title, body = format_event_mobile_push(event)
+ assert title == "✅ Агент запущен"
+ assert "K6A-DC3" in body
+ assert "1.2.30-SAC" in body
+ assert "обновление скрипта" in body
+ assert "📡 Оповещение: SAC" in body
+
+
+def test_format_event_mobile_push_uses_notification_body():
+ host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
+ body_text = "✅ Мониторинг логинов ЗАПУЩЕН\n🖥️ Сервер: K6A-DC3 (10.0.0.1)"
+ event = Event(
+ event_id="00000000-0000-4000-8000-000000000510",
+ host_id=1,
+ host=host,
+ occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
+ category="agent",
+ type="agent.lifecycle",
+ severity="info",
+ title="started",
+ summary="Мониторинг запущен",
+ details={
+ "lifecycle": "started",
+ "trigger": "boot",
+ "notification_body": body_text,
+ "telegram_via": "sac",
+ },
+ payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.31-SAC"}},
+ )
+ title, body = format_event_mobile_push(event)
+ assert title == "✅ Мониторинг логинов ЗАПУЩЕН"
+ assert "K6A-DC3 (10.0.0.1)" in body
+
+
def test_lifecycle_started_template():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
event = Event(
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index 56f486f..6e238db 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center";
-export const APP_VERSION = "0.9.10";
+export const APP_VERSION = "0.9.11";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;