2026-03-03 22:11:45 +01:00
|
|
|
import json
|
|
|
|
|
import logging
|
2026-03-25 13:46:03 +01:00
|
|
|
import io
|
|
|
|
|
import time
|
2026-03-03 22:11:45 +01:00
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any, Dict, Optional
|
2026-03-25 13:46:03 +01:00
|
|
|
from urllib.parse import urlparse
|
2026-03-03 22:11:45 +01:00
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
|
2026-03-25 13:46:03 +01:00
|
|
|
from fastapi.responses import StreamingResponse
|
2026-03-03 22:11:45 +01:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
from app.core.auth_service import AuthService
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
|
|
|
|
|
|
from .mission_service import MissionService
|
|
|
|
|
from .mission_ws import mission_ws_manager
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionCallEvent(BaseModel):
|
|
|
|
|
call_id: str = Field(..., min_length=1, max_length=128)
|
|
|
|
|
caller_number: Optional[str] = None
|
|
|
|
|
queue_name: Optional[str] = None
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionUptimeWebhook(BaseModel):
|
|
|
|
|
status: Optional[str] = None
|
|
|
|
|
service_name: Optional[str] = None
|
|
|
|
|
customer_name: Optional[str] = None
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 13:46:03 +01:00
|
|
|
class MissionCameraConfigUpdate(BaseModel):
|
|
|
|
|
enabled: bool = False
|
|
|
|
|
camera_name: Optional[str] = None
|
|
|
|
|
feed_url: Optional[str] = None
|
|
|
|
|
spotlight_seconds: Optional[int] = 20
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionCameraMotionWebhook(BaseModel):
|
|
|
|
|
camera_name: Optional[str] = None
|
|
|
|
|
motion: Optional[bool] = True
|
|
|
|
|
event_type: Optional[str] = None
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
snapshot_url: Optional[str] = None
|
|
|
|
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionAccessPinUpdate(BaseModel):
|
|
|
|
|
pin: str = Field(..., min_length=4, max_length=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MissionTemperatureWebhook(BaseModel):
|
|
|
|
|
sensor_id: Optional[str] = None
|
|
|
|
|
sensor_name: Optional[str] = None
|
|
|
|
|
temperature: float
|
|
|
|
|
unit: Optional[str] = "°C"
|
|
|
|
|
timestamp: Optional[datetime] = None
|
|
|
|
|
payload: Dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
def _first_query_param(request: Request, *names: str) -> Optional[str]:
|
|
|
|
|
for name in names:
|
|
|
|
|
value = request.query_params.get(name)
|
|
|
|
|
if value and str(value).strip():
|
|
|
|
|
return str(value).strip()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_query_timestamp(request: Request) -> Optional[datetime]:
|
|
|
|
|
raw = _first_query_param(request, "timestamp", "time", "event_time")
|
|
|
|
|
if not raw:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _event_from_query(request: Request) -> MissionCallEvent:
|
|
|
|
|
call_id = _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid")
|
|
|
|
|
if not call_id:
|
2026-03-05 08:41:59 +01:00
|
|
|
logger.warning(
|
|
|
|
|
"⚠️ Mission webhook invalid query path=%s reason=missing_call_id keys=%s",
|
|
|
|
|
request.url.path,
|
|
|
|
|
",".join(sorted(request.query_params.keys())),
|
|
|
|
|
)
|
2026-03-03 23:09:14 +01:00
|
|
|
raise HTTPException(status_code=400, detail="Missing call_id query parameter")
|
|
|
|
|
|
|
|
|
|
return MissionCallEvent(
|
|
|
|
|
call_id=call_id,
|
|
|
|
|
caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"),
|
|
|
|
|
queue_name=_first_query_param(request, "queue_name", "queue", "group", "line"),
|
|
|
|
|
timestamp=_parse_query_timestamp(request),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
def _get_webhook_token() -> str:
|
|
|
|
|
db_token = MissionService.get_setting_value("mission_webhook_token", "") or ""
|
|
|
|
|
env_token = (getattr(settings, "MISSION_WEBHOOK_TOKEN", "") or "").strip()
|
|
|
|
|
return db_token.strip() or env_token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
|
|
|
|
|
configured = _get_webhook_token()
|
2026-03-05 08:41:59 +01:00
|
|
|
path = request.url.path
|
2026-03-03 22:11:45 +01:00
|
|
|
if not configured:
|
2026-03-05 08:41:59 +01:00
|
|
|
logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path)
|
2026-03-03 22:11:45 +01:00
|
|
|
raise HTTPException(status_code=403, detail="Mission webhook token not configured")
|
|
|
|
|
|
|
|
|
|
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
|
|
|
|
|
if not candidate or candidate.strip() != configured:
|
2026-03-05 08:41:59 +01:00
|
|
|
source = "query_or_arg"
|
|
|
|
|
if not token and request.headers.get("x-mission-token"):
|
|
|
|
|
source = "header"
|
|
|
|
|
|
|
|
|
|
masked = "<empty>"
|
|
|
|
|
if candidate:
|
|
|
|
|
c = candidate.strip()
|
|
|
|
|
masked = "***" if len(c) <= 8 else f"{c[:4]}...{c[-4:]}"
|
|
|
|
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
"❌ Mission webhook forbidden path=%s reason=token_mismatch source=%s token=%s",
|
|
|
|
|
path,
|
|
|
|
|
source,
|
|
|
|
|
masked,
|
|
|
|
|
)
|
2026-03-03 22:11:45 +01:00
|
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
|
|
|
|
|
raw = dict(payload.payload or {})
|
|
|
|
|
|
|
|
|
|
status_candidate = payload.status or raw.get("status") or raw.get("event")
|
|
|
|
|
if not status_candidate and isinstance(raw.get("monitor"), dict):
|
|
|
|
|
status_candidate = raw.get("monitor", {}).get("status")
|
|
|
|
|
|
|
|
|
|
service_name = payload.service_name or raw.get("service_name") or raw.get("monitor_name")
|
|
|
|
|
if not service_name and isinstance(raw.get("monitor"), dict):
|
|
|
|
|
service_name = raw.get("monitor", {}).get("name")
|
|
|
|
|
|
|
|
|
|
customer_name = payload.customer_name or raw.get("customer_name") or raw.get("customer")
|
|
|
|
|
timestamp = payload.timestamp or raw.get("timestamp")
|
|
|
|
|
|
|
|
|
|
status = str(status_candidate or "UNKNOWN").upper().strip()
|
|
|
|
|
if status not in {"UP", "DOWN", "DEGRADED"}:
|
|
|
|
|
status = "UNKNOWN"
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": status,
|
|
|
|
|
"service_name": str(service_name or "Unknown Service"),
|
|
|
|
|
"customer_name": str(customer_name or "").strip() or None,
|
|
|
|
|
"timestamp": timestamp,
|
|
|
|
|
"raw": raw,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 13:46:03 +01:00
|
|
|
def _is_valid_feed_url(candidate: Optional[str]) -> bool:
|
|
|
|
|
if not candidate:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
parsed = urlparse(candidate.strip())
|
|
|
|
|
except Exception:
|
|
|
|
|
return False
|
|
|
|
|
return parsed.scheme in {"http", "https", "rtsp"} and bool(parsed.netloc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_authenticated_user(request: Request) -> Dict[str, Any]:
|
|
|
|
|
token = None
|
|
|
|
|
auth_header = (request.headers.get("authorization") or "").strip()
|
|
|
|
|
if auth_header.lower().startswith("bearer "):
|
|
|
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
|
|
|
if not token:
|
|
|
|
|
token = (request.cookies.get("access_token") or "").strip()
|
|
|
|
|
|
|
|
|
|
payload = AuthService.verify_token(token) if token else None
|
|
|
|
|
if not payload or payload.get("scope") == "mission_pin":
|
|
|
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
|
|
|
|
|
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
|
|
|
if not user_id:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
|
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_valid_access_pin(pin: str) -> bool:
|
|
|
|
|
return pin.isdigit() and 4 <= len(pin) <= 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _iter_mjpeg_frames(feed_url: str, target_fps: float = 5.0):
|
|
|
|
|
"""Transcode camera frames to MJPEG for browser playback."""
|
|
|
|
|
try:
|
|
|
|
|
import av
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("❌ PyAV import failed for camera stream: %s", exc)
|
|
|
|
|
raise HTTPException(status_code=503, detail="PyAV ikke installeret på serveren")
|
|
|
|
|
|
|
|
|
|
options = {
|
|
|
|
|
"rtsp_transport": "tcp",
|
|
|
|
|
"fflags": "nobuffer",
|
|
|
|
|
"flags": "low_delay",
|
|
|
|
|
"stimeout": "5000000",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
boundary = b"frame"
|
|
|
|
|
frame_interval = 1.0 / max(1.0, float(target_fps))
|
|
|
|
|
last_emit = 0.0
|
|
|
|
|
container = None
|
|
|
|
|
try:
|
|
|
|
|
container = av.open(feed_url, options=options)
|
|
|
|
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
|
|
|
|
if video_stream is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Feed indeholder ingen video stream")
|
|
|
|
|
|
|
|
|
|
for frame in container.decode(video=0):
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
if now - last_emit < frame_interval:
|
|
|
|
|
continue
|
|
|
|
|
last_emit = now
|
|
|
|
|
|
|
|
|
|
image = frame.to_image()
|
|
|
|
|
buffer = io.BytesIO()
|
|
|
|
|
image.save(buffer, format="JPEG", quality=80)
|
|
|
|
|
jpeg = buffer.getvalue()
|
|
|
|
|
|
|
|
|
|
yield (
|
|
|
|
|
b"--" + boundary + b"\r\n"
|
|
|
|
|
+ b"Content-Type: image/jpeg\r\n"
|
|
|
|
|
+ f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii")
|
|
|
|
|
+ jpeg
|
|
|
|
|
+ b"\r\n"
|
|
|
|
|
)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error("❌ Camera MJPEG stream failed: %s", exc)
|
|
|
|
|
raise HTTPException(status_code=502, detail="Kunne ikke åbne kamera stream")
|
|
|
|
|
finally:
|
|
|
|
|
if container is not None:
|
|
|
|
|
try:
|
|
|
|
|
container.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _probe_camera_stream(feed_url: str) -> Dict[str, Any]:
|
|
|
|
|
"""Attempt opening and decoding one frame to provide actionable diagnostics."""
|
|
|
|
|
try:
|
|
|
|
|
import av
|
|
|
|
|
except Exception:
|
|
|
|
|
return {"ok": False, "detail": "PyAV ikke installeret på serveren"}
|
|
|
|
|
|
|
|
|
|
options = {
|
|
|
|
|
"rtsp_transport": "tcp",
|
|
|
|
|
"fflags": "nobuffer",
|
|
|
|
|
"flags": "low_delay",
|
|
|
|
|
"stimeout": "5000000",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container = None
|
|
|
|
|
try:
|
|
|
|
|
container = av.open(feed_url, options=options)
|
|
|
|
|
video_stream = next((s for s in container.streams if s.type == "video"), None)
|
|
|
|
|
if video_stream is None:
|
|
|
|
|
return {"ok": False, "detail": "Feed indeholder ingen video stream"}
|
|
|
|
|
|
|
|
|
|
frame_found = False
|
|
|
|
|
for _ in container.decode(video=0):
|
|
|
|
|
frame_found = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not frame_found:
|
|
|
|
|
return {"ok": False, "detail": "Ingen frames modtaget fra kamera"}
|
|
|
|
|
|
|
|
|
|
return {"ok": True, "detail": "Stream OK"}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return {"ok": False, "detail": f"Kamera stream fejl: {exc}"}
|
|
|
|
|
finally:
|
|
|
|
|
if container is not None:
|
|
|
|
|
try:
|
|
|
|
|
container.close()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.get("/mission/state")
|
|
|
|
|
async def get_mission_state():
|
|
|
|
|
return MissionService.get_state()
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 13:46:03 +01:00
|
|
|
@router.get("/mission/camera/mjpeg")
|
|
|
|
|
async def mission_camera_mjpeg_stream(fps: float = Query(5.0, ge=1.0, le=15.0)):
|
|
|
|
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
|
|
|
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
|
|
|
|
|
|
|
|
|
if not enabled:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kamera feed er ikke aktiveret")
|
|
|
|
|
if not feed_url:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Kamera feed URL mangler")
|
|
|
|
|
if not _is_valid_feed_url(feed_url):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ugyldig kamera feed URL")
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
_iter_mjpeg_frames(feed_url=feed_url, target_fps=fps),
|
|
|
|
|
media_type="multipart/x-mixed-replace; boundary=frame",
|
|
|
|
|
headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mission/camera/status")
|
|
|
|
|
async def mission_camera_status():
|
|
|
|
|
feed_url = (MissionService.get_setting_value("mission_camera_feed_url", "") or "").strip()
|
|
|
|
|
enabled = str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true"
|
|
|
|
|
|
|
|
|
|
if not enabled:
|
|
|
|
|
return {"ok": False, "detail": "Kamera feed er ikke aktiveret", "enabled": False}
|
|
|
|
|
if not feed_url:
|
|
|
|
|
return {"ok": False, "detail": "Kamera feed URL mangler", "enabled": True}
|
|
|
|
|
if not _is_valid_feed_url(feed_url):
|
|
|
|
|
return {"ok": False, "detail": "Ugyldig kamera feed URL", "enabled": True}
|
|
|
|
|
|
|
|
|
|
probe = _probe_camera_stream(feed_url)
|
|
|
|
|
return {
|
|
|
|
|
"ok": bool(probe.get("ok")),
|
|
|
|
|
"detail": probe.get("detail") or "Ukendt status",
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"feed_scheme": feed_url.split(":", 1)[0].lower() if ":" in feed_url else "unknown",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/mission/camera/config")
|
|
|
|
|
async def update_mission_camera_config(config: MissionCameraConfigUpdate):
|
|
|
|
|
feed_url = (config.feed_url or "").strip()
|
|
|
|
|
camera_name = (config.camera_name or "Mission Kamera").strip() or "Mission Kamera"
|
|
|
|
|
spotlight_seconds = int(config.spotlight_seconds or 20)
|
|
|
|
|
spotlight_seconds = max(5, min(spotlight_seconds, 120))
|
|
|
|
|
|
|
|
|
|
if feed_url and not _is_valid_feed_url(feed_url):
|
|
|
|
|
raise HTTPException(status_code=400, detail="Ugyldig feed URL. Brug rtsp/http/https")
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
|
|
|
|
VALUES
|
|
|
|
|
(%s, %s, 'mission', 'Enable one camera feed in Mission Control', 'boolean', true),
|
|
|
|
|
(%s, %s, 'mission', 'Camera name for Mission Control', 'string', true),
|
|
|
|
|
(%s, %s, 'mission', 'Camera feed URL for Mission Control', 'string', true),
|
|
|
|
|
(%s, %s, 'mission', 'Camera spotlight duration in seconds for motion events', 'integer', true)
|
|
|
|
|
ON CONFLICT (key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
value = EXCLUDED.value,
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
"mission_camera_enabled",
|
|
|
|
|
"true" if config.enabled else "false",
|
|
|
|
|
"mission_camera_name",
|
|
|
|
|
camera_name,
|
|
|
|
|
"mission_camera_feed_url",
|
|
|
|
|
feed_url,
|
|
|
|
|
"mission_camera_spotlight_seconds",
|
|
|
|
|
str(spotlight_seconds),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"camera": {
|
|
|
|
|
"enabled": config.enabled,
|
|
|
|
|
"camera_name": camera_name,
|
|
|
|
|
"feed_url": feed_url,
|
|
|
|
|
"spotlight_seconds": spotlight_seconds,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/mission/access-pin")
|
|
|
|
|
async def update_mission_access_pin(request: Request, payload: MissionAccessPinUpdate):
|
|
|
|
|
_require_authenticated_user(request)
|
|
|
|
|
|
|
|
|
|
new_pin = (payload.pin or "").strip()
|
|
|
|
|
if not _is_valid_access_pin(new_pin):
|
|
|
|
|
raise HTTPException(status_code=400, detail="PIN skal være 4-10 cifre")
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
|
|
|
|
VALUES (%s, %s, 'mission', 'Access PIN for Mission Control kiosk mode', 'string', false)
|
|
|
|
|
ON CONFLICT (key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
value = EXCLUDED.value,
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
""",
|
|
|
|
|
("mission_access_pin", new_pin),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {"status": "ok", "message": "Mission PIN opdateret"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.websocket("/mission/ws")
|
|
|
|
|
async def mission_ws(websocket: WebSocket):
|
|
|
|
|
token = websocket.query_params.get("token")
|
|
|
|
|
auth_header = (websocket.headers.get("authorization") or "").strip()
|
|
|
|
|
if not token and auth_header.lower().startswith("bearer "):
|
|
|
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
|
|
|
|
|
|
|
|
payload = AuthService.verify_token(token) if token else None
|
2026-03-25 13:46:03 +01:00
|
|
|
if not payload:
|
|
|
|
|
access_cookie_token = (websocket.cookies.get("access_token") or "").strip() or None
|
|
|
|
|
payload = AuthService.verify_token(access_cookie_token) if access_cookie_token else None
|
|
|
|
|
if not payload:
|
|
|
|
|
mission_pin_cookie_token = (websocket.cookies.get("mission_pin_token") or "").strip() or None
|
|
|
|
|
payload = AuthService.verify_token(mission_pin_cookie_token) if mission_pin_cookie_token else None
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
if not payload:
|
|
|
|
|
await websocket.close(code=1008)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.connect(websocket)
|
|
|
|
|
try:
|
|
|
|
|
await mission_ws_manager.broadcast("mission_state", MissionService.get_state())
|
|
|
|
|
while True:
|
|
|
|
|
await websocket.receive_text()
|
|
|
|
|
except WebSocketDisconnect:
|
|
|
|
|
await mission_ws_manager.disconnect(websocket)
|
|
|
|
|
except Exception:
|
|
|
|
|
await mission_ws_manager.disconnect(websocket)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mission/webhook/telefoni/ringing")
|
|
|
|
|
async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
timestamp = event.timestamp or datetime.utcnow()
|
|
|
|
|
context = MissionService.resolve_contact_context(event.caller_number)
|
|
|
|
|
queue_name = (event.queue_name or "Ukendt kø").strip()
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_call_state (
|
|
|
|
|
call_id, queue_name, caller_number, contact_name, company_name, customer_tag,
|
|
|
|
|
state, started_at, answered_at, ended_at, updated_at, last_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'ringing', %s, NULL, NULL, NOW(), %s::jsonb)
|
|
|
|
|
ON CONFLICT (call_id)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
queue_name = EXCLUDED.queue_name,
|
|
|
|
|
caller_number = EXCLUDED.caller_number,
|
|
|
|
|
contact_name = EXCLUDED.contact_name,
|
|
|
|
|
company_name = EXCLUDED.company_name,
|
|
|
|
|
customer_tag = EXCLUDED.customer_tag,
|
|
|
|
|
state = 'ringing',
|
|
|
|
|
ended_at = NULL,
|
|
|
|
|
answered_at = NULL,
|
|
|
|
|
started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = EXCLUDED.last_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
event.call_id,
|
|
|
|
|
queue_name,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
context.get("contact_name"),
|
|
|
|
|
context.get("company_name"),
|
|
|
|
|
context.get("customer_tag"),
|
|
|
|
|
timestamp,
|
|
|
|
|
json.dumps(event.model_dump(mode="json")),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="incoming_call",
|
|
|
|
|
title=f"Indgående opkald i {queue_name}",
|
|
|
|
|
severity="warning",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
customer_name=context.get("company_name"),
|
|
|
|
|
payload={
|
|
|
|
|
"call_id": event.call_id,
|
|
|
|
|
"queue_name": queue_name,
|
|
|
|
|
"caller_number": event.caller_number,
|
|
|
|
|
**context,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
call_payload = {
|
|
|
|
|
"call_id": event.call_id,
|
|
|
|
|
"queue_name": queue_name,
|
|
|
|
|
"caller_number": event.caller_number,
|
|
|
|
|
**context,
|
|
|
|
|
"timestamp": timestamp,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_ringing", call_payload)
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
await mission_ws_manager.broadcast("kpi_update", MissionService.get_kpis())
|
|
|
|
|
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/ringing")
|
|
|
|
|
async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)):
|
2026-03-04 07:22:39 +01:00
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
# Allow token-only GET calls (no call payload) for phone webhook validation/ping.
|
|
|
|
|
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
|
|
|
|
|
logger.info("☎️ Mission webhook ringing ping method=%s", request.method)
|
|
|
|
|
return {"status": "ok", "mode": "ping"}
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_ringing(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/telefoni/answered")
|
|
|
|
|
async def mission_telefoni_answered(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"✅ Mission webhook answered call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE mission_call_state
|
|
|
|
|
SET state = 'answered',
|
|
|
|
|
answered_at = COALESCE(answered_at, NOW()),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = %s::jsonb
|
|
|
|
|
WHERE call_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(json.dumps(event.model_dump(mode="json")), event.call_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="call_answered",
|
|
|
|
|
title="Opkald besvaret",
|
|
|
|
|
severity="info",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_answered", {"call_id": event.call_id})
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/answered")
|
|
|
|
|
async def mission_telefoni_answered_get(request: Request, token: Optional[str] = Query(None)):
|
2026-03-04 07:40:18 +01:00
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
|
|
|
|
|
logger.info("✅ Mission webhook answered ping method=%s", request.method)
|
|
|
|
|
return {"status": "ok", "mode": "ping"}
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_answered(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/telefoni/hangup")
|
|
|
|
|
async def mission_telefoni_hangup(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
2026-03-03 23:49:24 +01:00
|
|
|
logger.info(
|
|
|
|
|
"📴 Mission webhook hangup call_id=%s caller=%s queue=%s method=%s",
|
|
|
|
|
event.call_id,
|
|
|
|
|
event.caller_number,
|
|
|
|
|
event.queue_name,
|
|
|
|
|
request.method,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE mission_call_state
|
|
|
|
|
SET state = 'hangup',
|
|
|
|
|
ended_at = NOW(),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
last_payload = %s::jsonb
|
|
|
|
|
WHERE call_id = %s
|
|
|
|
|
""",
|
|
|
|
|
(json.dumps(event.model_dump(mode="json")), event.call_id),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="call_ended",
|
|
|
|
|
title="Opkald afsluttet",
|
|
|
|
|
severity="info",
|
|
|
|
|
source="telefoni",
|
|
|
|
|
payload={"call_id": event.call_id, "queue_name": event.queue_name, "caller_number": event.caller_number},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast("call_hangup", {"call_id": event.call_id})
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
@router.get("/mission/webhook/telefoni/hangup")
|
|
|
|
|
async def mission_telefoni_hangup_get(request: Request, token: Optional[str] = Query(None)):
|
2026-03-04 07:40:18 +01:00
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):
|
|
|
|
|
logger.info("📴 Mission webhook hangup ping method=%s", request.method)
|
|
|
|
|
return {"status": "ok", "mode": "ping"}
|
|
|
|
|
|
2026-03-03 23:09:14 +01:00
|
|
|
event = _event_from_query(request)
|
|
|
|
|
return await mission_telefoni_hangup(event, request, token)
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 22:11:45 +01:00
|
|
|
@router.post("/mission/webhook/uptime")
|
|
|
|
|
async def mission_uptime_webhook(payload: MissionUptimeWebhook, request: Request, token: Optional[str] = Query(None)):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
normalized = _normalize_uptime_payload(payload)
|
|
|
|
|
status = normalized["status"]
|
|
|
|
|
service_name = normalized["service_name"]
|
|
|
|
|
customer_name = normalized["customer_name"]
|
|
|
|
|
alert_key = MissionService.build_alert_key(service_name, customer_name)
|
|
|
|
|
|
|
|
|
|
current = execute_query_single("SELECT is_active, started_at FROM mission_uptime_alerts WHERE alert_key = %s", (alert_key,))
|
|
|
|
|
|
|
|
|
|
if status in {"DOWN", "DEGRADED"}:
|
|
|
|
|
started_at = (current or {}).get("started_at")
|
|
|
|
|
is_active = bool((current or {}).get("is_active"))
|
|
|
|
|
if not started_at or not is_active:
|
|
|
|
|
started_at = datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_uptime_alerts (
|
|
|
|
|
alert_key, service_name, customer_name, status, is_active, started_at, resolved_at,
|
|
|
|
|
updated_at, raw_payload, normalized_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, TRUE, %s, NULL, NOW(), %s::jsonb, %s::jsonb)
|
|
|
|
|
ON CONFLICT (alert_key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
is_active = TRUE,
|
|
|
|
|
started_at = COALESCE(mission_uptime_alerts.started_at, EXCLUDED.started_at),
|
|
|
|
|
resolved_at = NULL,
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
|
|
|
normalized_payload = EXCLUDED.normalized_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
alert_key,
|
|
|
|
|
service_name,
|
|
|
|
|
customer_name,
|
|
|
|
|
status,
|
|
|
|
|
started_at,
|
|
|
|
|
json.dumps(payload.model_dump(mode="json")),
|
|
|
|
|
json.dumps(normalized, default=str),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_type = "uptime_down" if status == "DOWN" else "uptime_degraded"
|
|
|
|
|
severity = "critical" if status == "DOWN" else "warning"
|
|
|
|
|
title = f"{service_name} er {status}"
|
|
|
|
|
elif status == "UP":
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO mission_uptime_alerts (
|
|
|
|
|
alert_key, service_name, customer_name, status, is_active, started_at, resolved_at,
|
|
|
|
|
updated_at, raw_payload, normalized_payload
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, FALSE, NULL, NOW(), NOW(), %s::jsonb, %s::jsonb)
|
|
|
|
|
ON CONFLICT (alert_key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
is_active = FALSE,
|
|
|
|
|
resolved_at = NOW(),
|
|
|
|
|
updated_at = NOW(),
|
|
|
|
|
raw_payload = EXCLUDED.raw_payload,
|
|
|
|
|
normalized_payload = EXCLUDED.normalized_payload
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
alert_key,
|
|
|
|
|
service_name,
|
|
|
|
|
customer_name,
|
|
|
|
|
status,
|
|
|
|
|
json.dumps(payload.model_dump(mode="json")),
|
|
|
|
|
json.dumps(normalized, default=str),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_type = "uptime_up"
|
|
|
|
|
severity = "success"
|
|
|
|
|
title = f"{service_name} er UP"
|
|
|
|
|
else:
|
|
|
|
|
event_type = "uptime_unknown"
|
|
|
|
|
severity = "info"
|
|
|
|
|
title = f"{service_name} status ukendt"
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type=event_type,
|
|
|
|
|
title=title,
|
|
|
|
|
severity=severity,
|
|
|
|
|
source="uptime",
|
|
|
|
|
customer_name=customer_name,
|
|
|
|
|
payload={"alert_key": alert_key, **normalized},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast(
|
|
|
|
|
"uptime_alert",
|
|
|
|
|
{
|
|
|
|
|
"alert_key": alert_key,
|
|
|
|
|
"status": status,
|
|
|
|
|
"service_name": service_name,
|
|
|
|
|
"customer_name": customer_name,
|
|
|
|
|
"active_alerts": MissionService.get_active_alerts(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
|
|
|
|
|
return {"status": "ok", "normalized": normalized}
|
2026-03-25 13:46:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mission/webhook/camera/motion")
|
|
|
|
|
async def mission_camera_motion_webhook(
|
|
|
|
|
payload: MissionCameraMotionWebhook,
|
|
|
|
|
request: Request,
|
|
|
|
|
token: Optional[str] = Query(None),
|
|
|
|
|
):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
raw_payload = dict(payload.payload or {})
|
|
|
|
|
motion_detected = bool(payload.motion)
|
|
|
|
|
if payload.event_type and str(payload.event_type).strip().lower() in {"no_motion", "idle", "clear"}:
|
|
|
|
|
motion_detected = False
|
|
|
|
|
|
|
|
|
|
camera_name = (payload.camera_name or MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera").strip()
|
|
|
|
|
event_timestamp = payload.timestamp or datetime.utcnow()
|
|
|
|
|
event_timestamp_iso = event_timestamp.isoformat()
|
|
|
|
|
snapshot_url = (payload.snapshot_url or "").strip() or None
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast(
|
|
|
|
|
"camera_motion",
|
|
|
|
|
{
|
|
|
|
|
"camera_name": camera_name,
|
|
|
|
|
"motion": motion_detected,
|
|
|
|
|
"timestamp": event_timestamp_iso,
|
|
|
|
|
"snapshot_url": snapshot_url,
|
|
|
|
|
"payload": raw_payload,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"camera_name": camera_name,
|
|
|
|
|
"motion": motion_detected,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/mission/webhook/environment/temperature")
|
|
|
|
|
async def mission_environment_temperature_webhook(
|
|
|
|
|
payload: MissionTemperatureWebhook,
|
|
|
|
|
request: Request,
|
|
|
|
|
token: Optional[str] = Query(None),
|
|
|
|
|
):
|
|
|
|
|
_validate_mission_webhook_token(request, token)
|
|
|
|
|
|
|
|
|
|
sensor_id = (payload.sensor_id or "").strip() or None
|
|
|
|
|
sensor_name = (payload.sensor_name or "").strip() or sensor_id or "Temperatur"
|
|
|
|
|
unit = (payload.unit or "°C").strip() or "°C"
|
|
|
|
|
timestamp = payload.timestamp or datetime.utcnow()
|
|
|
|
|
|
|
|
|
|
raw_payload = dict(payload.payload or {})
|
|
|
|
|
reading = {
|
|
|
|
|
"sensor_id": sensor_id,
|
|
|
|
|
"sensor_name": sensor_name,
|
|
|
|
|
"temperature": float(payload.temperature),
|
|
|
|
|
"unit": unit,
|
|
|
|
|
"timestamp": timestamp.isoformat(),
|
|
|
|
|
"payload": raw_payload,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing = MissionService.parse_json_setting("mission_environment_readings", [])
|
|
|
|
|
if not isinstance(existing, list):
|
|
|
|
|
existing = []
|
|
|
|
|
|
|
|
|
|
merged: list[Dict[str, Any]] = []
|
|
|
|
|
replaced = False
|
|
|
|
|
for item in existing:
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
continue
|
|
|
|
|
item_sensor_id = str(item.get("sensor_id") or "").strip() or None
|
|
|
|
|
item_sensor_name = str(item.get("sensor_name") or "").strip()
|
|
|
|
|
|
|
|
|
|
# Keep one latest entry per sensor when possible.
|
|
|
|
|
if sensor_id and item_sensor_id == sensor_id and not replaced:
|
|
|
|
|
merged.append(reading)
|
|
|
|
|
replaced = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if (not sensor_id) and item_sensor_name and item_sensor_name == sensor_name and not replaced:
|
|
|
|
|
merged.append(reading)
|
|
|
|
|
replaced = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
merged.append(item)
|
|
|
|
|
|
|
|
|
|
if not replaced:
|
|
|
|
|
merged.insert(0, reading)
|
|
|
|
|
|
|
|
|
|
merged = merged[:12]
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
|
|
|
|
VALUES (%s, %s, 'mission', 'Latest environment sensor readings for Mission Control', 'json', true)
|
|
|
|
|
ON CONFLICT (key)
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
value = EXCLUDED.value,
|
|
|
|
|
updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
""",
|
|
|
|
|
("mission_environment_readings", json.dumps(merged, ensure_ascii=False)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event_row = MissionService.insert_event(
|
|
|
|
|
event_type="environment_temperature",
|
|
|
|
|
title=f"Temperatur {sensor_name}: {payload.temperature:.1f}{unit}",
|
|
|
|
|
severity="info",
|
|
|
|
|
source="home_assistant",
|
|
|
|
|
payload=reading,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await mission_ws_manager.broadcast(
|
|
|
|
|
"mission_environment_temperature",
|
|
|
|
|
{"reading": reading, "environment_readings": merged},
|
|
|
|
|
)
|
|
|
|
|
if event_row:
|
|
|
|
|
await mission_ws_manager.broadcast("live_feed_event", event_row)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"reading": reading,
|
|
|
|
|
"count": len(merged),
|
|
|
|
|
}
|