release: v2.8.20
This commit is contained in:
parent
49bc74e199
commit
9b662c3f1a
@ -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"):
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user