release: v2.8.20

This commit is contained in:
Christian 2026-09-26 09:09:19 +02:00
parent 49bc74e199
commit 9b662c3f1a
5 changed files with 128 additions and 9 deletions

View File

@ -1 +1 @@
2.8.19 2.8.20

View File

@ -1,6 +1,7 @@
import json import json
import logging import logging
import io import io
import re
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -25,6 +26,7 @@ class MissionCallEvent(BaseModel):
call_id: str = Field(..., min_length=1, max_length=128) call_id: str = Field(..., min_length=1, max_length=128)
caller_number: Optional[str] = None caller_number: Optional[str] = None
queue_name: Optional[str] = None queue_name: Optional[str] = None
recipient_extension: Optional[str] = None
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None
@ -157,6 +159,7 @@ def _event_from_query(request: Request) -> MissionCallEvent:
call_id=call_id, call_id=call_id,
caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"), caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"),
queue_name=_first_query_param(request, "queue_name", "queue", "group", "line"), 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), timestamp=_parse_query_timestamp(request),
) )
@ -167,15 +170,24 @@ def _get_webhook_token() -> str:
return db_token.strip() or env_token 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() 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 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) logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path)
raise HTTPException(status_code=403, detail="Mission webhook token not configured") 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") 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" source = "query_or_arg"
if not token and request.headers.get("x-mission-token"): if not token and request.headers.get("x-mission-token"):
source = "header" source = "header"
@ -194,6 +206,25 @@ def _validate_mission_webhook_token(request: Request, token: Optional[str] = Non
raise HTTPException(status_code=403, detail="Forbidden") 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]: def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
raw = dict(payload.payload or {}) raw = dict(payload.payload or {})
@ -689,7 +720,7 @@ async def mission_ws(websocket: WebSocket):
@router.post("/mission/webhook/telefoni/ringing") @router.post("/mission/webhook/telefoni/ringing")
async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)): 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( logger.info(
"☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s", "☎️ 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() timestamp = event.timestamp or datetime.utcnow()
context = MissionService.resolve_contact_context(event.caller_number) context = MissionService.resolve_contact_context(event.caller_number)
queue_name = (event.queue_name or "Ukendt kø").strip() queue_name = _display_destination(event)
execute_query( execute_query(
""" """
@ -754,6 +785,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
"call_id": event.call_id, "call_id": event.call_id,
"queue_name": queue_name, "queue_name": queue_name,
"caller_number": event.caller_number, "caller_number": event.caller_number,
"recipient_extension": event.recipient_extension,
**context, **context,
"timestamp": timestamp, "timestamp": timestamp,
} }
@ -767,7 +799,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
@router.get("/mission/webhook/telefoni/ringing") @router.get("/mission/webhook/telefoni/ringing")
async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)): 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. # 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"): if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):

View File

@ -11,6 +11,18 @@
</div> </div>
</div> </div>
<div class="card border-0 shadow-sm mb-4 overflow-hidden" id="liveCallsCard">
<div class="card-body py-3">
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<div class="fw-semibold"><i class="bi bi-broadcast-pin text-danger me-2"></i>Ringer nu</div>
<a class="btn btn-sm btn-outline-secondary" href="/dashboard/mission-control">
Åbn Mission Control <i class="bi bi-arrow-up-right ms-1"></i>
</a>
</div>
<div id="liveCallsList" class="text-muted small">Indlæser aktive opkald…</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
@ -316,6 +328,39 @@ const linkContactState = {
selectedCompanyLabel: '' selectedCompanyLabel: ''
}; };
function renderLiveCalls(calls) {
const list = document.getElementById('liveCallsList');
if (!list) return;
if (!Array.isArray(calls) || !calls.length) {
list.innerHTML = '<span class="text-muted">Ingen telefoner ringer lige nu.</span>';
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 ? ` <span class="text-muted">· ${contactParts.join(' · ')}</span>` : '';
return `<div class="d-flex align-items-center flex-wrap gap-2 py-1">
<span class="badge text-bg-danger">RINGER</span>
<strong>${recipient}</strong>
<span>fra ${caller}</span>${contact}
</div>`;
}).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 = '<span class="text-muted">Live-opkald kan ikke indlæses.</span>';
}
}
async function ensureCurrentUserId() { async function ensureCurrentUserId() {
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId; if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
try { try {
@ -1211,6 +1256,8 @@ document.addEventListener('DOMContentLoaded', async () => {
if (toFilter) toFilter.value = ''; if (toFilter) toFilter.value = '';
if (withoutCaseFilter) withoutCaseFilter.checked = false; if (withoutCaseFilter) withoutCaseFilter.checked = false;
await loadLiveCalls();
window.setInterval(loadLiveCalls, 5000);
await loadUsers(); await loadUsers();
document.getElementById('btnRefresh').addEventListener('click', loadCalls); document.getElementById('btnRefresh').addEventListener('click', loadCalls);
document.getElementById('filterUser').addEventListener('change', loadCalls); document.getElementById('filterUser').addEventListener('change', loadCalls);

View File

@ -500,7 +500,7 @@
<div class="card p-4"> <div class="card p-4">
<h5 class="mb-3 fw-bold">Yealink URL Builder</h5> <h5 class="mb-3 fw-bold">Yealink URL Builder</h5>
<p class="text-muted mb-3">Generér de præcise URL-strenge til telefonernes Action URL felter (Established + Terminated).</p> <p class="text-muted mb-3">Generér de præcise URL-strenge til telefonernes Action URL felter. Indgående opkald vises live i Mission Control og på Telefoni-siden.</p>
<div class="alert alert-info py-2 small mb-3"> <div class="alert alert-info py-2 small mb-3">
Brug Yealink variable values (fx <code>$call_id</code>, <code>$remote</code>, <code>$local</code>, <code>$active_user</code>). Ikke-dokumenterede placeholders som <code>$callid</code>/<code>$caller</code>/<code>$callee</code> bliver ofte ikke erstattet. Brug Yealink variable values (fx <code>$call_id</code>, <code>$remote</code>, <code>$local</code>, <code>$active_user</code>). Ikke-dokumenterede placeholders som <code>$callid</code>/<code>$caller</code>/<code>$callee</code> bliver ofte ikke erstattet.
</div> </div>
@ -517,6 +517,17 @@
<small class="text-muted">Hvis udfyldt, tilføjes <code>?token=...</code> automatisk.</small> <small class="text-muted">Hvis udfyldt, tilføjes <code>?token=...</code> automatisk.</small>
</div> </div>
<div class="col-12">
<label class="form-label">Incoming call URL</label>
<div class="input-group">
<input type="text" class="form-control" id="yealinkIncomingUrl" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyYealinkUrl('yealinkIncomingUrl')">
<i class="bi bi-clipboard"></i>
</button>
</div>
<small class="text-muted">Indsæt denne i Yealink-feltet <strong>Incoming call</strong>.</small>
</div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Established URL</label> <label class="form-label">Established URL</label>
<div class="input-group"> <div class="input-group">
@ -2630,11 +2641,13 @@ function buildYealinkActionUrls() {
const manualToken = (document.getElementById('yealinkBuilderToken')?.value || '').trim(); const manualToken = (document.getElementById('yealinkBuilderToken')?.value || '').trim();
const sharedToken = (document.getElementById('telefoniSharedSecret')?.value || '').trim(); const sharedToken = (document.getElementById('telefoniSharedSecret')?.value || '').trim();
const token = manualToken || sharedToken; const token = manualToken || sharedToken;
const incomingEl = document.getElementById('yealinkIncomingUrl');
const estEl = document.getElementById('yealinkEstablishedUrl'); const estEl = document.getElementById('yealinkEstablishedUrl');
const termEl = document.getElementById('yealinkTerminatedUrl'); const termEl = document.getElementById('yealinkTerminatedUrl');
if (!estEl || !termEl) return; if (!incomingEl || !estEl || !termEl) return;
if (!baseRaw) { if (!baseRaw) {
incomingEl.value = '';
estEl.value = ''; estEl.value = '';
termEl.value = ''; termEl.value = '';
return; return;
@ -2643,6 +2656,7 @@ function buildYealinkActionUrls() {
const base = baseRaw.replace(/\/$/, ''); const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : ''; 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`; 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`; termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
} }

View File

@ -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.router import _is_internal_request_target
from app.modules.telefoni.backend.schemas import TelefoniCallLinkUpdate from app.modules.telefoni.backend.schemas import TelefoniCallLinkUpdate
from app.modules.telefoni.backend.service import TelefoniService from app.modules.telefoni.backend.service import TelefoniService
from app.dashboard.backend import mission_router
def _telefoni_javascript() -> str: 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 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(): 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") migration = Path("migrations/1047_telefoni_reused_callids.sql").read_text(encoding="utf-8")