feat: HTML Telegram templates for RDP/SSH events (notif-30)
Agent-style messages from event details, parse_mode HTML, templates for login/sudo/RDG and problems; unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models import Event, Problem
|
from app.models import Event, Problem
|
||||||
from app.services.notification_severity import severity_meets_minimum
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
|
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
|
||||||
|
from app.services.telegram_templates import format_event_telegram_html, format_problem_telegram_html
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,7 +21,13 @@ class TelegramSendError(Exception):
|
|||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
|
def send_telegram_text(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
config: TelegramConfig | None = None,
|
||||||
|
force: bool = False,
|
||||||
|
parse_mode: str | None = "HTML",
|
||||||
|
) -> None:
|
||||||
cfg = config or get_effective_telegram_config()
|
cfg = config or get_effective_telegram_config()
|
||||||
if not force and not cfg.active:
|
if not force and not cfg.active:
|
||||||
return
|
return
|
||||||
@@ -30,14 +37,23 @@ def send_telegram_text(message: str, *, config: TelegramConfig | None = None, fo
|
|||||||
return
|
return
|
||||||
|
|
||||||
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
|
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
|
||||||
payload = {"chat_id": cfg.chat_id, "text": message, "disable_web_page_preview": True}
|
payload: dict[str, str | bool] = {
|
||||||
|
"chat_id": cfg.chat_id,
|
||||||
|
"text": message,
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
if parse_mode:
|
||||||
|
payload["parse_mode"] = parse_mode
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=8.0) as client:
|
with httpx.Client(timeout=8.0) as client:
|
||||||
response = client.post(url, json=payload)
|
response = client.post(url, json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
detail = exc.response.text[:200] if exc.response is not None else str(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
|
raise TelegramSendError(
|
||||||
|
f"Telegram API HTTP {exc.response.status_code}: {detail}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("telegram send failed")
|
logger.exception("telegram send failed")
|
||||||
raise TelegramSendError(str(exc)) from exc
|
raise TelegramSendError(str(exc)) from exc
|
||||||
@@ -50,7 +66,7 @@ def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
|
|||||||
if not cfg.configured:
|
if not cfg.configured:
|
||||||
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
|
||||||
send_telegram_text(
|
send_telegram_text(
|
||||||
"✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.",
|
"✅ <b>SAC: тестовое сообщение</b>\nКанал Telegram для оповещений работает.",
|
||||||
config=cfg,
|
config=cfg,
|
||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
@@ -62,39 +78,26 @@ def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate:
|
|||||||
return
|
return
|
||||||
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
if apply_policy_gate and not severity_meets_minimum(event.severity, cfg.min_severity):
|
||||||
return
|
return
|
||||||
host = event.host.hostname if event.host else "unknown"
|
message = format_event_telegram_html(event)
|
||||||
message = (
|
|
||||||
f"🚨 SAC событие\n"
|
|
||||||
f"Host: {host}\n"
|
|
||||||
f"Severity: {event.severity}\n"
|
|
||||||
f"Type: {event.type}\n"
|
|
||||||
f"Title: {event.title}\n"
|
|
||||||
f"Summary: {event.summary}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
send_telegram_text(message, config=cfg)
|
send_telegram_text(message, config=cfg)
|
||||||
except TelegramSendError:
|
except TelegramSendError:
|
||||||
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
|
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
|
||||||
|
|
||||||
|
|
||||||
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
def notify_problem(
|
||||||
|
problem: Problem,
|
||||||
|
event: Event | None = None,
|
||||||
|
*,
|
||||||
|
db: Session | None = None,
|
||||||
|
apply_policy_gate: bool = True,
|
||||||
|
) -> None:
|
||||||
cfg = get_effective_telegram_config(db)
|
cfg = get_effective_telegram_config(db)
|
||||||
if not cfg.active:
|
if not cfg.active:
|
||||||
return
|
return
|
||||||
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
if apply_policy_gate and not severity_meets_minimum(problem.severity, cfg.min_severity):
|
||||||
return
|
return
|
||||||
host = problem.host.hostname if problem.host else "unknown"
|
message = format_problem_telegram_html(problem, event)
|
||||||
related = ""
|
|
||||||
if event is not None:
|
|
||||||
related = f"\nEvent: {event.type} ({event.severity})"
|
|
||||||
message = (
|
|
||||||
f"🔥 SAC Problem opened\n"
|
|
||||||
f"Host: {host}\n"
|
|
||||||
f"Severity: {problem.severity}\n"
|
|
||||||
f"Rule: {problem.rule_id or '-'}\n"
|
|
||||||
f"Title: {problem.title}\n"
|
|
||||||
f"Summary: {problem.summary}{related}"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
send_telegram_text(message, config=cfg)
|
send_telegram_text(message, config=cfg)
|
||||||
except TelegramSendError:
|
except TelegramSendError:
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""HTML Telegram message templates (RDP/SSH style, notif-30)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models import Event, Host, Problem
|
||||||
|
|
||||||
|
LOGON_TYPE_NAMES: dict[int, str] = {
|
||||||
|
2: "Интерактивный (консоль)",
|
||||||
|
3: "Сеть/RDP (Network)",
|
||||||
|
4: "Пакетный (Batch)",
|
||||||
|
5: "Сервис (Service)",
|
||||||
|
7: "Разблокировка (Unlock)",
|
||||||
|
8: "Сетевой с явными данными",
|
||||||
|
9: "Новые учетные данные",
|
||||||
|
10: "Удаленный интерактивный (RDP)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def html_escape(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return html.escape(str(value), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _details_dict(event: Event | None) -> dict[str, Any]:
|
||||||
|
if event is None or not isinstance(event.details, dict):
|
||||||
|
return {}
|
||||||
|
return event.details
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(details: dict[str, Any], *keys: str, default: str = "-") -> str:
|
||||||
|
for key in keys:
|
||||||
|
val = details.get(key)
|
||||||
|
if val is not None and str(val).strip() not in ("", "-"):
|
||||||
|
return str(val).strip()
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def logon_type_label(logon_type: Any) -> str:
|
||||||
|
try:
|
||||||
|
code = int(logon_type)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return html_escape(logon_type) if logon_type not in (None, "") else "-"
|
||||||
|
name = LOGON_TYPE_NAMES.get(code, f"Тип {code}")
|
||||||
|
return f"{html_escape(name)} ({code})"
|
||||||
|
|
||||||
|
|
||||||
|
def host_label(host: Host | None, *, fallback: str = "unknown") -> str:
|
||||||
|
if host is None:
|
||||||
|
return html_escape(fallback)
|
||||||
|
if host.display_name and host.display_name.strip():
|
||||||
|
return html_escape(host.display_name.strip())
|
||||||
|
return html_escape(host.hostname)
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(dt: datetime | None) -> str:
|
||||||
|
if dt is None:
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
return html_escape(dt.strftime("%d.%m.%Y %H:%M:%S"))
|
||||||
|
except Exception:
|
||||||
|
return html_escape(str(dt))
|
||||||
|
|
||||||
|
|
||||||
|
def _line(emoji: str, label: str, value: str) -> str:
|
||||||
|
return f"{emoji} {label}: {value}\n"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rdp_login_html(event: Event) -> str:
|
||||||
|
details = _details_dict(event)
|
||||||
|
is_success = event.type == "rdp.login.success"
|
||||||
|
header = "✅ УСПЕШНЫЙ ВХОД" if is_success else "❌ НЕУДАЧНАЯ ПОПЫТКА"
|
||||||
|
win_id = _detail(details, "event_id_windows", default="")
|
||||||
|
if not win_id and event.type == "rdp.login.failed":
|
||||||
|
win_id = "4625"
|
||||||
|
elif not win_id and is_success:
|
||||||
|
win_id = "4624"
|
||||||
|
|
||||||
|
user = html_escape(_detail(details, "user", "username"))
|
||||||
|
ip = html_escape(_detail(details, "ip_address", "source_ip", "ip"))
|
||||||
|
workstation = html_escape(_detail(details, "workstation_name", "computer_name"))
|
||||||
|
process = html_escape(_detail(details, "process_name", "process", default=""))
|
||||||
|
logon = logon_type_label(details.get("logon_type"))
|
||||||
|
|
||||||
|
msg = f"<b>{header}</b>\n"
|
||||||
|
msg += _line("👤", "Пользователь", user)
|
||||||
|
msg += _line("🏢", "Сервер", host_label(event.host))
|
||||||
|
msg += _line("🖥️", "Рабочая станция", workstation)
|
||||||
|
msg += _line("🌐", "IP адрес", ip)
|
||||||
|
if process and process != "-":
|
||||||
|
msg += _line("⚙️", "Процесс", process)
|
||||||
|
msg += _line("🔑", "Тип входа", logon)
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
if win_id:
|
||||||
|
msg += f"🔢 Event ID: {html_escape(win_id)}"
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_ssh_auth_html(event: Event) -> str:
|
||||||
|
details = _details_dict(event)
|
||||||
|
is_success = event.type == "ssh.login.success"
|
||||||
|
header = "✅ SSH: успешный вход" if is_success else "❌ SSH: неудачная попытка"
|
||||||
|
|
||||||
|
user = html_escape(_detail(details, "user", "username"))
|
||||||
|
ip = html_escape(_detail(details, "source_ip", "ip_address", "ip"))
|
||||||
|
port = html_escape(_detail(details, "port", default=""))
|
||||||
|
attempt = _detail(details, "attempt_number", default="")
|
||||||
|
max_attempts = _detail(details, "max_attempts", default="")
|
||||||
|
|
||||||
|
msg = f"<b>{header}</b>\n"
|
||||||
|
msg += _line("👤", "Пользователь", user)
|
||||||
|
msg += _line("🏢", "Хост", host_label(event.host))
|
||||||
|
msg += _line("🌐", "IP", ip)
|
||||||
|
if port and port != "-":
|
||||||
|
msg += _line("🔌", "Порт", port)
|
||||||
|
if attempt != "-" and max_attempts != "-":
|
||||||
|
msg += _line("🔢", "Попытка", f"{html_escape(attempt)} / {html_escape(max_attempts)}")
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
msg += f"📋 {html_escape(event.type)}"
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_sudo_html(event: Event) -> str:
|
||||||
|
details = _details_dict(event)
|
||||||
|
risk = _detail(details, "risk_level", default="")
|
||||||
|
header = "⚠️ SUDO"
|
||||||
|
if risk and risk.lower() == "critical":
|
||||||
|
header = "🔥 SUDO (critical)"
|
||||||
|
|
||||||
|
msg = f"<b>{header}</b>\n"
|
||||||
|
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||||
|
msg += _line("🏢", "Хост", host_label(event.host))
|
||||||
|
msg += _line("▶️", "Команда", html_escape(_detail(details, "command", default=event.summary[:200])))
|
||||||
|
run_as = _detail(details, "run_as", default="")
|
||||||
|
if run_as != "-":
|
||||||
|
msg += _line("👥", "От имени", html_escape(run_as))
|
||||||
|
pwd = _detail(details, "pwd", default="")
|
||||||
|
if pwd != "-":
|
||||||
|
msg += _line("📁", "Каталог", html_escape(pwd))
|
||||||
|
if risk != "-":
|
||||||
|
msg += _line("⚡", "Risk", html_escape(risk))
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_generic_event_html(event: Event) -> str:
|
||||||
|
sev = event.severity.upper()
|
||||||
|
msg = f"<b>🚨 SAC: {html_escape(event.title)}</b>\n"
|
||||||
|
msg += _line("🏢", "Хост", host_label(event.host))
|
||||||
|
msg += _line("📋", "Тип", html_escape(event.type))
|
||||||
|
msg += _line("⚡", "Severity", html_escape(sev))
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
if event.summary:
|
||||||
|
msg += f"\n{html_escape(event.summary)}"
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_event_telegram_html(event: Event) -> str:
|
||||||
|
if event.type in ("rdp.login.success", "rdp.login.failed"):
|
||||||
|
return format_rdp_login_html(event)
|
||||||
|
if event.type in ("ssh.login.success", "ssh.login.failed"):
|
||||||
|
return format_ssh_auth_html(event)
|
||||||
|
if event.type == "privilege.sudo.command":
|
||||||
|
return format_sudo_html(event)
|
||||||
|
if event.type.startswith("rdg."):
|
||||||
|
return _format_rdg_html(event)
|
||||||
|
return format_generic_event_html(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rdg_html(event: Event) -> str:
|
||||||
|
details = _details_dict(event)
|
||||||
|
ok = event.type.endswith(".success")
|
||||||
|
header = "✅ RD Gateway: подключение" if ok else "❌ RD Gateway: ошибка"
|
||||||
|
msg = f"<b>{header}</b>\n"
|
||||||
|
msg += _line("👤", "Пользователь", html_escape(_detail(details, "user", "username")))
|
||||||
|
msg += _line("🏢", "Хост", host_label(event.host))
|
||||||
|
msg += _line("🌐", "Внешний IP", html_escape(_detail(details, "external_ip", "ip_address")))
|
||||||
|
msg += _line("🏠", "Внутренний IP", html_escape(_detail(details, "internal_ip", default="-")))
|
||||||
|
err = _detail(details, "gateway_error_code", "error_code", default="")
|
||||||
|
if err != "-":
|
||||||
|
msg += _line("⚠️", "Код ошибки", html_escape(err))
|
||||||
|
msg += _line("🕐", "Время", format_time(event.occurred_at))
|
||||||
|
win_id = _detail(details, "event_id_windows", default="")
|
||||||
|
if win_id != "-":
|
||||||
|
msg += f"\n🔢 Event ID: {html_escape(win_id)}"
|
||||||
|
return msg.rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_problem_telegram_html(problem: Problem, event: Event | None = None) -> str:
|
||||||
|
msg = "<b>🔥 SAC Problem</b>\n"
|
||||||
|
msg += _line("🏢", "Хост", host_label(problem.host))
|
||||||
|
msg += _line("📏", "Severity", html_escape(problem.severity.upper()))
|
||||||
|
if problem.rule_id:
|
||||||
|
msg += _line("📐", "Правило", html_escape(problem.rule_id))
|
||||||
|
msg += _line("🔢", "Событий", html_escape(problem.event_count))
|
||||||
|
msg += _line("🕐", "Last seen", format_time(problem.last_seen_at))
|
||||||
|
msg += f"\n<b>{html_escape(problem.title)}</b>\n{html_escape(problem.summary)}"
|
||||||
|
if event is not None:
|
||||||
|
msg += f"\n\n<i>Триггер:</i> {html_escape(event.type)} ({html_escape(event.severity)})"
|
||||||
|
if event.type in ("rdp.login.success", "rdp.login.failed", "ssh.login.success", "ssh.login.failed"):
|
||||||
|
msg += "\n" + format_event_telegram_html(event)
|
||||||
|
return msg.rstrip()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Telegram HTML templates (notif-30)."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_escape_special_chars():
|
||||||
|
assert html_escape("a<b>&") == "a<b>&"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdp_failed_template_includes_user_ip_logon():
|
||||||
|
host = Host(hostname="K6A-DC3", display_name="UNMS Kalina", os_family="windows")
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000501",
|
||||||
|
host_id=1,
|
||||||
|
host=host,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 10, 30, 0, tzinfo=timezone.utc),
|
||||||
|
category="auth",
|
||||||
|
type="rdp.login.failed",
|
||||||
|
severity="warning",
|
||||||
|
title="RDP login failed",
|
||||||
|
summary="4625 user from 10.0.0.5",
|
||||||
|
details={
|
||||||
|
"user": "DOMAIN\\admin",
|
||||||
|
"ip_address": "10.0.0.5",
|
||||||
|
"logon_type": 10,
|
||||||
|
"event_id_windows": 4625,
|
||||||
|
"workstation_name": "CLIENT01",
|
||||||
|
},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
text = format_rdp_login_html(event)
|
||||||
|
assert "НЕУДАЧНАЯ" in text
|
||||||
|
assert "DOMAIN\\admin" in text or "DOMAIN" in text
|
||||||
|
assert "10.0.0.5" in text
|
||||||
|
assert "Удаленный интерактивный" in text
|
||||||
|
assert "(10)" in text
|
||||||
|
assert "UNMS Kalina" in text
|
||||||
|
assert "CLIENT01" in text
|
||||||
|
assert "4625" in text
|
||||||
|
assert "<b>" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rdp_success_template():
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000502",
|
||||||
|
host_id=1,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 11, 0, tzinfo=timezone.utc),
|
||||||
|
category="auth",
|
||||||
|
type="rdp.login.success",
|
||||||
|
severity="info",
|
||||||
|
title="ok",
|
||||||
|
summary="ok",
|
||||||
|
details={"user": "u1", "ip_address": "1.2.3.4", "logon_type": 3},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
text = format_event_telegram_html(event)
|
||||||
|
assert "УСПЕШНЫЙ" in text
|
||||||
|
assert "Сеть/RDP" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssh_failed_template():
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000503",
|
||||||
|
host_id=1,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc),
|
||||||
|
category="auth",
|
||||||
|
type="ssh.login.failed",
|
||||||
|
severity="warning",
|
||||||
|
title="ssh fail",
|
||||||
|
summary="fail",
|
||||||
|
details={"user": "root", "source_ip": "10.10.36.9", "port": 22, "attempt_number": 3, "max_attempts": 5},
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
text = format_event_telegram_html(event)
|
||||||
|
assert "SSH" in text
|
||||||
|
assert "root" in text
|
||||||
|
assert "10.10.36.9" in text
|
||||||
|
assert "3 / 5" in text
|
||||||
@@ -68,6 +68,8 @@ TELEGRAM_CHAT_ID=<chat>
|
|||||||
|
|
||||||
Events и problems используют **одно** глобальное правило (`notification_policy` в БД, миграция `007`).
|
Events и problems используют **одно** глобальное правило (`notification_policy` в БД, миграция `007`).
|
||||||
|
|
||||||
|
Telegram из SAC отправляется с **`parse_mode=HTML`** (как у RDP-login-monitor): для `rdp.login.*` — пользователь, IP, `logon_type`, рабочая станция, Event ID Windows; для `ssh.login.*` и `privilege.sudo.command` — поля из `details`.
|
||||||
|
|
||||||
UI **Настройки** (`/settings`) — правило + параметры каналов (JWT admin). После деплоя: `alembic upgrade head`.
|
UI **Настройки** (`/settings`) — правило + параметры каналов (JWT admin). После деплоя: `alembic upgrade head`.
|
||||||
|
|
||||||
### 1.4. Версии и доставка обновлений
|
### 1.4. Версии и доставка обновлений
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
| ID | Задача |
|
| ID | Задача |
|
||||||
|----|--------|
|
|----|--------|
|
||||||
| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) |
|
| `notif-30` | Шаблон TG ближе к агенту (HTML, поля user/ip/logon_type из `details`) | ✅ `telegram_templates.py`, `parse_mode=HTML` |
|
||||||
| `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst |
|
| `notif-31` | Cooldown/dedup на уровне SAC (F-NOT-03) — не дублировать problem при burst |
|
||||||
| `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) |
|
| `notif-32` | F-NOT-05: daily report из SAC при exclusive (отдельный эпик) |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user