diff --git a/VERSION b/VERSION index 3db58c9..9423d08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.8.19 +2.8.20 diff --git a/app/dashboard/backend/mission_router.py b/app/dashboard/backend/mission_router.py index e9d3ab7..0c82376 100644 --- a/app/dashboard/backend/mission_router.py +++ b/app/dashboard/backend/mission_router.py @@ -1,6 +1,7 @@ import json import logging import io +import re import time from datetime import datetime from typing import Any, Dict, Optional @@ -25,6 +26,7 @@ class MissionCallEvent(BaseModel): call_id: str = Field(..., min_length=1, max_length=128) caller_number: Optional[str] = None queue_name: Optional[str] = None + recipient_extension: Optional[str] = None timestamp: Optional[datetime] = None @@ -157,6 +159,7 @@ def _event_from_query(request: Request) -> 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"), + recipient_extension=_first_query_param(request, "recipient_extension", "extension", "active_user", "local"), timestamp=_parse_query_timestamp(request), ) @@ -167,15 +170,24 @@ def _get_webhook_token() -> str: return db_token.strip() or env_token -def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None: +def _validate_mission_webhook_token( + request: Request, + token: Optional[str] = None, + *, + allow_telefoni_token: bool = False, +) -> None: configured = _get_webhook_token() + telefoni_token = (MissionService.get_setting_value("telefoni_shared_secret", "") or "").strip() + if not telefoni_token: + telefoni_token = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip() path = request.url.path - if not configured: + accepted_tokens = {value for value in (configured, telefoni_token if allow_telefoni_token else "") if value} + if not accepted_tokens: logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path) 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: + if not candidate or candidate.strip() not in accepted_tokens: source = "query_or_arg" if not token and request.headers.get("x-mission-token"): source = "header" @@ -194,6 +206,25 @@ def _validate_mission_webhook_token(request: Request, token: Optional[str] = Non raise HTTPException(status_code=403, detail="Forbidden") +def _display_destination(event: MissionCallEvent) -> str: + """Return the person/phone receiving a Yealink incoming call.""" + raw = (event.recipient_extension or "").strip() + match = re.search(r"(?:sip:)?(\d+)", raw, flags=re.IGNORECASE) + extension = match.group(1) if match else "" + if extension: + user = execute_query_single( + """SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), '')) AS name + FROM users + WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s + ORDER BY user_id LIMIT 1""", + (extension,), + ) + if user and user.get("name"): + return f"{user['name']} ({extension})" + return f"Telefon {extension}" + return (event.queue_name or "Ukendt telefon").strip() + + def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]: raw = dict(payload.payload or {}) @@ -689,7 +720,7 @@ async def mission_ws(websocket: 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) + _validate_mission_webhook_token(request, token, allow_telefoni_token=True) logger.info( "☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s", @@ -701,7 +732,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to timestamp = event.timestamp or datetime.utcnow() context = MissionService.resolve_contact_context(event.caller_number) - queue_name = (event.queue_name or "Ukendt kø").strip() + queue_name = _display_destination(event) execute_query( """ @@ -754,6 +785,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to "call_id": event.call_id, "queue_name": queue_name, "caller_number": event.caller_number, + "recipient_extension": event.recipient_extension, **context, "timestamp": timestamp, } @@ -767,7 +799,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to @router.get("/mission/webhook/telefoni/ringing") async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)): - _validate_mission_webhook_token(request, token) + _validate_mission_webhook_token(request, token, allow_telefoni_token=True) # 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"): diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html index 186c33c..7e34967 100644 --- a/app/modules/telefoni/templates/log.html +++ b/app/modules/telefoni/templates/log.html @@ -11,6 +11,18 @@ +
+
+ +
Indlæser aktive opkald…
+
+
+
@@ -316,6 +328,39 @@ const linkContactState = { selectedCompanyLabel: '' }; +function renderLiveCalls(calls) { + const list = document.getElementById('liveCallsList'); + if (!list) return; + if (!Array.isArray(calls) || !calls.length) { + list.innerHTML = 'Ingen telefoner ringer lige nu.'; + return; + } + + list.innerHTML = calls.map((call) => { + const caller = escapeHtml(normalizeDisplayNumber(call.caller_number) || call.caller_number || 'Ukendt nummer'); + const recipient = escapeHtml(call.queue_name || 'Ukendt telefon'); + const contactParts = [call.contact_name, call.company_name].filter(Boolean).map(escapeHtml); + const contact = contactParts.length ? ` · ${contactParts.join(' · ')}` : ''; + return `
+ RINGER + ${recipient} + fra ${caller}${contact} +
`; + }).join(''); +} + +async function loadLiveCalls() { + try { + const response = await fetch('/api/v1/mission/state', { credentials: 'include' }); + if (!response.ok) throw new Error('Kunne ikke hente opkald'); + const state = await response.json(); + renderLiveCalls(state?.active_calls || []); + } catch (error) { + const list = document.getElementById('liveCallsList'); + if (list) list.innerHTML = 'Live-opkald kan ikke indlæses.'; + } +} + async function ensureCurrentUserId() { if (telefoniCurrentUserId !== null) return telefoniCurrentUserId; try { @@ -1211,6 +1256,8 @@ document.addEventListener('DOMContentLoaded', async () => { if (toFilter) toFilter.value = ''; if (withoutCaseFilter) withoutCaseFilter.checked = false; + await loadLiveCalls(); + window.setInterval(loadLiveCalls, 5000); await loadUsers(); document.getElementById('btnRefresh').addEventListener('click', loadCalls); document.getElementById('filterUser').addEventListener('change', loadCalls); diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html index 8e2e516..17a0b13 100644 --- a/app/settings/frontend/settings.html +++ b/app/settings/frontend/settings.html @@ -500,7 +500,7 @@
Yealink URL Builder
-

Generér de præcise URL-strenge til telefonernes Action URL felter (Established + Terminated).

+

Generér de præcise URL-strenge til telefonernes Action URL felter. Indgående opkald vises live i Mission Control og på Telefoni-siden.

Brug Yealink variable values (fx $call_id, $remote, $local, $active_user). Ikke-dokumenterede placeholders som $callid/$caller/$callee bliver ofte ikke erstattet.
@@ -517,6 +517,17 @@ Hvis udfyldt, tilføjes ?token=... automatisk.
+
+ +
+ + +
+ Indsæt denne i Yealink-feltet Incoming call. +
+
@@ -2630,11 +2641,13 @@ function buildYealinkActionUrls() { const manualToken = (document.getElementById('yealinkBuilderToken')?.value || '').trim(); const sharedToken = (document.getElementById('telefoniSharedSecret')?.value || '').trim(); const token = manualToken || sharedToken; + const incomingEl = document.getElementById('yealinkIncomingUrl'); const estEl = document.getElementById('yealinkEstablishedUrl'); const termEl = document.getElementById('yealinkTerminatedUrl'); - if (!estEl || !termEl) return; + if (!incomingEl || !estEl || !termEl) return; if (!baseRaw) { + incomingEl.value = ''; estEl.value = ''; termEl.value = ''; return; @@ -2643,6 +2656,7 @@ function buildYealinkActionUrls() { const base = baseRaw.replace(/\/$/, ''); const tokenPart = token ? `token=${encodeURIComponent(token)}&` : ''; + incomingEl.value = `${base}/api/v1/mission/webhook/telefoni/ringing?${tokenPart}call_id=$call_id&caller_number=$display_remote&queue_name=$local&recipient_extension=$active_user`; estEl.value = `${base}/api/v1/telefoni/established?${tokenPart}callid=$call_id&remote=$remote&display_remote=$display_remote&caller_id=$callerID&local=$local&active_user=$active_user&called_number=$calledNumber`; termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`; } diff --git a/tests/test_telefoni_call_logging.py b/tests/test_telefoni_call_logging.py index d31a08f..5dbfe60 100644 --- a/tests/test_telefoni_call_logging.py +++ b/tests/test_telefoni_call_logging.py @@ -10,6 +10,7 @@ from app.modules.telefoni.backend import router as telefoni_router from app.modules.telefoni.backend.router import _is_internal_request_target from app.modules.telefoni.backend.schemas import TelefoniCallLinkUpdate from app.modules.telefoni.backend.service import TelefoniService +from app.dashboard.backend import mission_router def _telefoni_javascript() -> str: @@ -195,6 +196,31 @@ def test_forwarded_call_does_not_store_forwarding_sip_uri(monkeypatch): assert captured["ekstern_nummer"] is None +def test_yealink_incoming_url_contains_call_and_recipient_variables(): + settings_template = Path("app/settings/frontend/settings.html").read_text(encoding="utf-8") + + assert 'id="yealinkIncomingUrl"' in settings_template + assert "call_id=$call_id" in settings_template + assert "caller_number=$display_remote" in settings_template + assert "recipient_extension=$active_user" in settings_template + + +def test_mission_incoming_call_resolves_the_receiving_phone_user(monkeypatch): + monkeypatch.setattr( + mission_router, + "execute_query_single", + lambda _query, params: {"name": "Per Krag"} if params == ("206",) else None, + ) + + event = mission_router.MissionCallEvent( + call_id="123", + queue_name="sip:206@pbx.sipserver.dk", + recipient_extension="206_99773", + ) + + assert mission_router._display_destination(event) == "Per Krag (206)" + + def test_callid_recovery_uses_system_log_without_overwriting_existing_current_calls(): migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8")