import json import logging import base64 import re from datetime import datetime from typing import Optional from urllib.error import URLError, HTTPError from urllib.request import Request as UrlRequest, urlopen from urllib.parse import urlsplit, urlunsplit from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from app.core.auth_service import AuthService from app.core.auth_dependencies import require_permission from app.core.config import settings from app.core.database import execute_query, execute_query_single from app.services.sms_service import SmsService from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest from .service import TelefoniService from .utils import ( digits_only, extract_extension, ip_in_whitelist, is_outbound_call, normalize_e164, phone_suffix_8, ) from .websocket import manager logger = logging.getLogger(__name__) router = APIRouter() @router.post("/sms/send") async def send_sms(payload: SmsSendRequest, request: Request): user_id = getattr(request.state, "user_id", None) logger.info("📨 SMS send requested by user_id=%s", user_id) contact_id = payload.contact_id if not contact_id: suffix8 = phone_suffix_8(payload.to) contact = TelefoniService.find_contact_by_phone_suffix(suffix8) contact_id = int(contact["id"]) if contact and contact.get("id") else None if not contact_id: raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt") try: result = SmsService.send_sms(payload.to, payload.message, payload.sender) execute_query( """ INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) """, ( contact_id, user_id, result.get("recipient") or payload.to, payload.sender or settings.SMS_SENDER, payload.message, "sent", json.dumps(result.get("result") or {}), ), ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except RuntimeError as e: execute_query( """ INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response) VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) """, ( contact_id, user_id, payload.to, payload.sender or settings.SMS_SENDER, payload.message, "failed", json.dumps({"error": str(e)}), ), ) raise HTTPException(status_code=502, detail=str(e)) except Exception as e: logger.error("❌ SMS send failed: %s", e) raise HTTPException(status_code=500, detail="SMS send failed") return { "status": "ok", **result, } def _get_client_ip(request: Request) -> str: cf_ip = request.headers.get("cf-connecting-ip") if cf_ip: return cf_ip.strip() x_real_ip = request.headers.get("x-real-ip") if x_real_ip: return x_real_ip.strip() xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host if request.client else "" def _validate_yealink_request(request: Request, token: Optional[str]) -> None: env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip() db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip() accepted_tokens = {s for s in (env_secret, db_secret) if s} whitelist = (getattr(settings, "TELEFONI_IP_WHITELIST", "") or "").strip() if not accepted_tokens and not whitelist: logger.error("❌ Telefoni callbacks are not secured (no TELEFONI_SHARED_SECRET or TELEFONI_IP_WHITELIST set)") raise HTTPException(status_code=403, detail="Telefoni callbacks not configured") if token and token.strip() in accepted_tokens: return if whitelist: client_ip = _get_client_ip(request) if ip_in_whitelist(client_ip, whitelist): return raise HTTPException(status_code=403, detail="Forbidden") @router.get("/telefoni/established") async def yealink_established( request: Request, callid: Optional[str] = Query(None), call_id: Optional[str] = Query(None), caller: Optional[str] = Query(None), callee: Optional[str] = Query(None), remote: Optional[str] = Query(None), local: Optional[str] = Query(None), active_user: Optional[str] = Query(None), called_number: Optional[str] = Query(None), token: Optional[str] = Query(None), ): """Yealink Action URL: Established""" _validate_yealink_request(request, token) resolved_callid = (callid or call_id or "").strip() if not resolved_callid: raise HTTPException(status_code=422, detail="Missing callid/call_id") def _sanitize(value: Optional[str]) -> Optional[str]: if value is None: return None v = value.strip() if not v: return None if v.startswith("$"): return None return v def _is_external_number(value: Optional[str]) -> bool: d = digits_only(value) return len(d) >= 8 def _is_internal_number(value: Optional[str], local_ext: Optional[str]) -> bool: d = digits_only(value) if not d: return False local_d = digits_only(local_ext) if local_d and d.endswith(local_d): return True return len(d) <= 6 local_value = _sanitize(local) or _sanitize(active_user) caller_value = _sanitize(caller) or _sanitize(remote) callee_value = _sanitize(callee) called_number_value = _sanitize(called_number) local_extension = extract_extension(local_value) or local_value is_outbound = False if called_number_value and _is_external_number(called_number_value): is_outbound = True elif caller_value and local_extension: if not _is_internal_number(caller_value, local_extension): is_outbound = is_outbound_call(caller_value, local_extension) direction = "outbound" if is_outbound else "inbound" candidates = [ callee_value, called_number_value, _sanitize(remote), caller_value, ] if direction == "outbound" else [ caller_value, _sanitize(remote), callee_value, called_number_value, ] ekstern_raw = None for candidate in candidates: if candidate and _is_external_number(candidate): ekstern_raw = candidate break if not ekstern_raw: for candidate in candidates: if candidate: ekstern_raw = candidate break ekstern_e164 = normalize_e164(ekstern_raw) ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None) suffix8 = phone_suffix_8(ekstern_raw) user_ids = TelefoniService.find_user_by_extension(local_extension) kontakt = TelefoniService.find_contact_by_phone_suffix(suffix8) kontakt_id = kontakt.get("id") if kontakt else None # Get extended contact details if we found a contact contact_details = {} if kontakt_id: contact_details = TelefoniService.get_contact_details(kontakt_id) payload = { "callid": resolved_callid, "call_id": call_id, "caller": caller_value, "callee": callee_value, "remote": remote, "local": local_value, "active_user": active_user, "called_number": called_number_value, "direction": direction, "client_ip": _get_client_ip(request), "user_agent": request.headers.get("user-agent"), "received_at": datetime.utcnow().isoformat(), } # Store call with first user_id (or None if no users) primary_user_id = user_ids[0] if user_ids else None row = TelefoniService.upsert_call( callid=resolved_callid, user_id=primary_user_id, direction=direction, ekstern_nummer=ekstern_value, intern_extension=(local_extension or "")[:16] or None, kontakt_id=kontakt_id, raw_payload=json.dumps(payload), started_at=datetime.utcnow(), ) # Send websocket notification to ALL users with this extension if user_ids: call_data = { "call_id": str(row.get("id") or resolved_callid), "number": ekstern_e164 or (ekstern_raw or ""), "direction": direction, "contact": kontakt, "recent_cases": contact_details.get("recent_cases", []), "last_call": contact_details.get("last_call"), } for user_id in user_ids: await manager.send_to_user(user_id, "incoming_call", call_data) logger.info("📞 Telefoni notification sent to %d user(s) for extension=%s", len(user_ids), local_extension) else: logger.info("âš ī¸ Telefoni established: no mapped user for extension=%s (callid=%s)", local_extension, resolved_callid) return {"status": "ok"} @router.get("/telefoni/terminated") async def yealink_terminated( request: Request, callid: Optional[str] = Query(None), call_id: Optional[str] = Query(None), duration: Optional[str] = Query(None), call_duration: Optional[str] = Query(None), token: Optional[str] = Query(None), ): """Yealink Action URL: Terminated""" _validate_yealink_request(request, token) resolved_callid = (callid or call_id or "").strip() if not resolved_callid: raise HTTPException(status_code=422, detail="Missing callid/call_id") duration_raw = (duration or call_duration or "").strip() or None duration_value: Optional[int] = None if duration_raw: try: duration_value = int(duration_raw) except ValueError: # Accept common timer formats from PBX/phones: mm:ss or hh:mm:ss if ":" in duration_raw: parts = duration_raw.split(":") try: nums = [int(p) for p in parts] if len(nums) == 2: duration_value = nums[0] * 60 + nums[1] elif len(nums) == 3: duration_value = nums[0] * 3600 + nums[1] * 60 + nums[2] except ValueError: duration_value = None if duration_value is None: logger.info("âš ī¸ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) updated = TelefoniService.terminate_call(resolved_callid, duration_value) if not updated: logger.info("âš ī¸ Telefoni terminated without established (callid=%s)", resolved_callid) return {"status": "ok"} @router.websocket("/telefoni/ws") async def telefoni_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() if not token: token = (websocket.cookies.get("access_token") or "").strip() or None payload = AuthService.verify_token(token) if token else None if not payload: env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip() db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip() accepted_tokens = {s for s in (env_secret, db_secret) if s} if token and token.strip() in accepted_tokens: user_id_param = websocket.query_params.get("user_id") try: user_id = int(user_id_param) if user_id_param else None except (TypeError, ValueError): user_id = None if not user_id: logger.info("âš ī¸ Telefoni WS rejected: shared secret requires user_id") await websocket.close(code=1008) return else: logger.info("âš ī¸ Telefoni WS rejected: invalid or missing token") await websocket.close(code=1008) return else: user_id_value = payload.get("sub") or payload.get("user_id") try: user_id = int(user_id_value) except (TypeError, ValueError): logger.info("âš ī¸ Telefoni WS rejected: invalid user id in token") await websocket.close(code=1008) return await manager.connect(user_id, websocket) logger.info("✅ Telefoni WS connected for user_id=%s", user_id) try: while True: # Keep alive / ignore client messages await websocket.receive_text() except WebSocketDisconnect: logger.info("â„šī¸ Telefoni WS disconnected for user_id=%s", user_id) await manager.disconnect(user_id, websocket) except Exception: logger.info("â„šī¸ Telefoni WS disconnected (exception) for user_id=%s", user_id) await manager.disconnect(user_id, websocket) @router.post("/telefoni/test-popup") async def telefoni_test_popup( request: Request, token: Optional[str] = Query(None), user_id: Optional[int] = Query(None), extension: Optional[str] = Query(None), ): """Trigger test popup for currently authenticated user.""" target_user_id = getattr(request.state, "user_id", None) env_secret = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip() db_secret = (_get_setting_value("telefoni_shared_secret", "") or "").strip() accepted_tokens = {s for s in (env_secret, db_secret) if s} token_valid = bool(token and token.strip() in accepted_tokens) if not target_user_id: if not token_valid: raise HTTPException(status_code=401, detail="Not authenticated") if user_id: target_user_id = int(user_id) elif extension: # find_user_by_extension now returns list - take first match for test user_ids = TelefoniService.find_user_by_extension(extension) target_user_id = user_ids[0] if user_ids else None if not target_user_id: raise HTTPException(status_code=422, detail="Provide user_id or extension when using token auth") conn_count = await manager.connection_count_for_user(int(target_user_id)) await manager.send_to_user( int(target_user_id), "incoming_call", { "call_id": f"test-{int(datetime.utcnow().timestamp())}", "number": "+4511223344", "direction": "inbound", "contact": { "id": None, "name": "Test popup", "company": "BMC Hub", }, "recent_cases": [ {"id": 1, "titel": "Test sag 1", "created_at": datetime.utcnow()}, {"id": 2, "titel": "Test sag 2", "created_at": datetime.utcnow()}, ], "last_call": { "started_at": datetime.utcnow(), "bruger_navn": "Test Medarbejder", "duration_sec": 125, }, }, ) return { "status": "ok", "message": "Test popup event sent", "user_id": int(target_user_id), "ws_connections": conn_count, "auth_mode": "token" if token_valid and not getattr(request.state, "user_id", None) else "session", } @router.get("/telefoni/users") async def list_telefoni_users(): """List users for log filtering (auth-protected by middleware).""" rows = execute_query( """ SELECT user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username FROM users ORDER BY COALESCE(full_name, username) ASC """, (), ) return rows or [] @router.patch("/telefoni/admin/users/{user_id}", dependencies=[Depends(require_permission("users.manage"))]) async def update_telefoni_user_mapping(user_id: int, data: TelefoniUserMappingUpdate): existing = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not existing: raise HTTPException(status_code=404, detail="User not found") rows = execute_query( """ UPDATE users SET telefoni_extension = COALESCE(%s, telefoni_extension), telefoni_aktiv = COALESCE(%s, telefoni_aktiv), telefoni_phone_ip = COALESCE(%s, telefoni_phone_ip), telefoni_phone_username = COALESCE(%s, telefoni_phone_username), telefoni_phone_password = CASE WHEN %s IS NULL OR %s = '' THEN telefoni_phone_password ELSE %s END WHERE user_id = %s RETURNING user_id, username, full_name, telefoni_extension, telefoni_aktiv, telefoni_phone_ip, telefoni_phone_username """, ( data.telefoni_extension, data.telefoni_aktiv, data.telefoni_phone_ip, data.telefoni_phone_username, data.telefoni_phone_password, data.telefoni_phone_password, data.telefoni_phone_password, user_id, ), ) return rows[0] if rows else {"status": "ok"} def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]: row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,)) if not row: return default value = row.get("value") if value is None or value == "": return default return str(value) @router.post("/telefoni/click-to-call") async def click_to_call(payload: TelefoniClickToCallRequest): enabled = (_get_setting_value("telefoni_click_to_call_enabled", "false") or "false").lower() == "true" if not enabled: raise HTTPException(status_code=400, detail="Click-to-call is disabled") template = _get_setting_value("telefoni_action_url_template", "") or "" if not template: raise HTTPException(status_code=400, detail="telefoni_action_url_template is not configured") number_normalized = normalize_e164(payload.number) or payload.number.strip() extension_value = (payload.extension or "").strip() phone_ip_value = "" phone_username_value = "" phone_password_value = "" if payload.user_id: user_row = execute_query_single( """ SELECT telefoni_extension, telefoni_phone_ip, telefoni_phone_username, telefoni_phone_password FROM users WHERE user_id = %s """, (payload.user_id,), ) if user_row: if not extension_value: extension_value = (user_row.get("telefoni_extension") or "").strip() phone_ip_value = (user_row.get("telefoni_phone_ip") or "").strip() phone_username_value = (user_row.get("telefoni_phone_username") or "").strip() phone_password_value = (user_row.get("telefoni_phone_password") or "").strip() if "{number}" not in template and "{raw_number}" not in template: raise HTTPException(status_code=400, detail="Template must contain {number} or {raw_number}") if "{phone_ip}" in template and not phone_ip_value: raise HTTPException(status_code=400, detail="Template requires {phone_ip}, but selected user has no phone IP") if "{phone_username}" in template and not phone_username_value: raise HTTPException(status_code=400, detail="Template requires {phone_username}, but selected user has no phone username") if "{phone_password}" in template and not phone_password_value: raise HTTPException(status_code=400, detail="Template requires {phone_password}, but selected user has no phone password") raw_number_clean = re.sub(r"\s+", "", payload.number.strip()) resolved_url = ( template .replace("{number}", number_normalized) .replace("{raw_number}", raw_number_clean) .replace("{extension}", extension_value) .replace("{phone_ip}", phone_ip_value) .replace("{phone_username}", phone_username_value) .replace("{phone_password}", phone_password_value) ) auth_header: Optional[str] = None if "@" in resolved_url: parsed = urlsplit(resolved_url) if not parsed.scheme or not parsed.netloc: raise HTTPException(status_code=400, detail="Action URL template resolves to invalid URL") netloc = parsed.netloc if "@" in netloc: userinfo, host_part = netloc.rsplit("@", 1) if ":" in userinfo: username, password = userinfo.split(":", 1) else: username, password = userinfo, "" credentials = f"{username}:{password}".encode("utf-8") auth_header = "Basic " + base64.b64encode(credentials).decode("ascii") resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment)) logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-") try: request = UrlRequest(resolved_url, method="GET") if auth_header: request.add_header("Authorization", auth_header) with urlopen(request, timeout=8) as response: status = getattr(response, "status", 200) except HTTPError as e: logger.error("❌ Click-to-call HTTP error: %s", e) raise HTTPException(status_code=502, detail=f"Action URL returned HTTP {e.code}") except URLError as e: logger.error("❌ Click-to-call URL error: %s", e) raise HTTPException(status_code=502, detail="Could not reach Action URL") except Exception as e: logger.error("❌ Click-to-call failed: %s", e) raise HTTPException(status_code=500, detail="Click-to-call failed") display_url = resolved_url if auth_header: display_url = "[basic-auth] " + resolved_url return { "status": "ok", "action_url": display_url, "http_status": status, "number": number_normalized, } @router.get("/telefoni/calls") async def list_calls( user_id: Optional[int] = Query(None), date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), without_case: bool = Query(False), limit: int = Query(200, ge=1, le=2000), offset: int = Query(0, ge=0), ): where = [] params = [] if user_id is not None: where.append("t.bruger_id = %s") params.append(user_id) if date_from: where.append("t.started_at >= %s") params.append(date_from) if date_to: where.append("t.started_at <= %s") params.append(date_to) if without_case: where.append("t.sag_id IS NULL") where_sql = ("WHERE " + " AND ".join(where)) if where else "" query = f""" SELECT t.id, t.callid, t.bruger_id, t.direction, t.ekstern_nummer, COALESCE( NULLIF(TRIM(t.ekstern_nummer), ''), NULLIF(TRIM(t.raw_payload->>'caller'), ''), NULLIF(TRIM(t.raw_payload->>'callee'), '') ) AS display_number, t.intern_extension, t.kontakt_id, t.sag_id, t.started_at, t.ended_at, t.duration_sec, t.created_at, u.username, u.full_name, CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name, ( SELECT cu.name FROM contact_companies cc JOIN customers cu ON cu.id = cc.customer_id WHERE cc.contact_id = c.id ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC LIMIT 1 ) AS contact_company, s.titel AS sag_titel FROM telefoni_opkald t LEFT JOIN users u ON u.user_id = t.bruger_id LEFT JOIN contacts c ON c.id = t.kontakt_id LEFT JOIN sag_sager s ON s.id = t.sag_id {where_sql} ORDER BY t.started_at DESC LIMIT %s OFFSET %s """ params.extend([limit, offset]) rows = execute_query(query, tuple(params)) return rows or [] @router.patch("/telefoni/calls/{call_id}") async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate): existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,)) if not existing: raise HTTPException(status_code=404, detail="Call not found") fields = [] params = [] if "sag_id" in data.model_fields_set: fields.append("sag_id = %s") params.append(data.sag_id) if "kontakt_id" in data.model_fields_set: fields.append("kontakt_id = %s") params.append(data.kontakt_id) if not fields: raise HTTPException(status_code=400, detail="No fields provided") query = f""" UPDATE telefoni_opkald SET {", ".join(fields)} WHERE id = %s RETURNING * """ params.append(call_id) rows = execute_query(query, tuple(params)) return rows[0] if rows else {"status": "ok"}