Files
security-alert-center/backend/app/services/mobile_notify.py
T
PTah 3f671b9899 fix: FCM data-only push so Seaca controls sound (0.9.6)
Send title and body in the data payload instead of notification block so
onMessageReceived always runs and the app can honour silent mode in background.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:03:28 +10:00

245 lines
7.8 KiB
Python

"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1)."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from pathlib import Path
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
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
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
class MobileNotConfiguredError(Exception):
"""FCM отключён или не задан service account."""
class MobileSendError(Exception):
def __init__(self, message: str, *, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class FcmConfig:
enabled: bool
project_id: str
service_account_path: str
@property
def configured(self) -> bool:
return bool(self.project_id.strip() and self.service_account_path.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def get_effective_fcm_config() -> FcmConfig:
settings = get_settings()
return FcmConfig(
enabled=bool(settings.sac_fcm_enabled),
project_id=(settings.sac_fcm_project_id or "").strip(),
service_account_path=(settings.sac_fcm_service_account_json or "").strip(),
)
def _load_service_account(path: str) -> dict:
file_path = Path(path)
if not file_path.is_file():
raise MobileNotConfiguredError(f"FCM service account file not found: {path}")
with file_path.open(encoding="utf-8") as handle:
return json.load(handle)
def _fcm_access_token(service_account_path: str) -> str:
try:
from google.oauth2 import service_account
from google.auth.transport.requests import Request
except ImportError as exc:
msg = str(exc).lower()
if "requests" in msg:
raise MobileNotConfiguredError(
"FCM: установите пакет requests (pip install requests)"
) from exc
raise MobileNotConfiguredError("FCM: установите google-auth") from exc
creds = service_account.Credentials.from_service_account_file(
service_account_path,
scopes=[FCM_SCOPE],
)
creds.refresh(Request())
if not creds.token:
raise MobileSendError("Failed to obtain FCM access token")
return creds.token
def _active_device_tokens(db: Session | None) -> list[str]:
if db is None:
return []
rows = db.scalars(
select(MobileDevice).where(
MobileDevice.revoked_at.is_(None),
MobileDevice.fcm_token.is_not(None),
)
).all()
tokens: list[str] = []
for row in rows:
token = (row.fcm_token or "").strip()
if token:
tokens.append(token)
return tokens
def _send_fcm_data(
tokens: list[str],
data: dict[str, str],
*,
title: str,
body: str,
require_success: bool = False,
) -> None:
if not tokens:
if require_success:
raise MobileSendError("Нет FCM-токенов для отправки")
return
cfg = get_effective_fcm_config()
if not cfg.active:
if require_success:
raise MobileNotConfiguredError("FCM не настроен на сервере")
return
try:
access_token = _fcm_access_token(cfg.service_account_path)
except (MobileNotConfiguredError, MobileSendError) as exc:
logger.warning("FCM skipped: %s", exc)
if require_success:
raise
return
except Exception:
logger.exception("FCM token acquisition failed")
if require_success:
raise MobileSendError("Failed to obtain FCM access token", status_code=502)
return
url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
errors: list[str] = []
# Data-only: Android always calls SeacaMessagingService.onMessageReceived so the app
# can honour per-device notification mode (sound / silent / off).
message_data = {**data, "title": title, "body": body}
with httpx.Client(timeout=12.0) as client:
for token in tokens:
payload = {
"message": {
"token": token,
"data": message_data,
"android": {"priority": "high"},
}
}
try:
response = client.post(url, headers=headers, json=payload)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
errors.append(detail or f"HTTP {exc.response.status_code if exc.response else '?'}")
except Exception as exc:
logger.exception("FCM send failed")
errors.append(str(exc))
if require_success and errors:
raise MobileSendError(errors[0], status_code=502)
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
title = f"[{event.severity}] {event.title}"
body = (event.summary or "")[:200]
data = {
"kind": "event",
"id": str(event.id),
"severity": event.severity,
"type": event.type,
}
return title, body, data
def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]:
title = f"[{problem.severity}] {problem.title}"
body = (problem.summary or "")[:200]
data = {
"kind": "problem",
"id": str(problem.id),
"severity": problem.severity,
"status": problem.status,
}
return title, body, data
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _event_payload(event)
_send_fcm_data(tokens, data, title=title, body=body)
def notify_problem(
problem: Problem,
event: Event | None = None,
*,
db: Session | None = None,
apply_policy_gate: bool = True,
) -> None:
policy = get_effective_notification_policy(db)
if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity):
return
tokens = _active_device_tokens(db)
if not tokens:
return
title, body, data = _problem_payload(problem)
_send_fcm_data(tokens, data, title=title, body=body)
def send_test_push(*, db: Session, device_id: int) -> None:
cfg = get_effective_fcm_config()
if not cfg.enabled:
raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)")
if not cfg.configured:
raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON")
device = db.get(MobileDevice, device_id)
if device is None or device.revoked_at is not None:
raise ValueError("device not found or revoked")
token = (device.fcm_token or "").strip()
if not token:
raise ValueError("device has no FCM token")
_send_fcm_data(
[token],
{"kind": "test", "id": "0", "severity": "info"},
title="SAC: тестовое push",
body="Канал Seaca (FCM) работает.",
require_success=True,
)