Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/telephony/softphone_service.py
2026-08-09 02:12:21 +03:30

632 lines
20 KiB
Python

"""سرویس Softphone — سشن، سلامت، خروجی relay، همگام‌سازی با CTI."""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session, joinedload
from adapters.db.models.telephony import (
TelephonyCall,
TelephonyCommand,
TelephonyPbxConnection,
TelephonySoftphoneSession,
TelephonyUserExtension,
)
from app.core.responses import ApiError
from app.core.security import hash_api_key
from app.services.realtime import realtime_manager
from app.services.telephony.media_edge import (
fetch_media_edge_snapshot,
is_media_edge_process,
process_role,
require_media_edge_process,
)
from app.services.telephony.media_hub import MEDIA_PROFILE_PCM_WS_V1, media_hub
from app.services.telephony import telephony_service as tel
def _now() -> datetime:
return datetime.utcnow()
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None
SESSION_TTL_MINUTES = 120
VALID_ENDPOINT_MODES = ("relay", "direct", "desk")
ACTIVE_SESSION_STATES = ("creating", "registered", "ringing", "in_call")
def softphone_settings_from_pbx(pbx: TelephonyPbxConnection) -> Dict[str, Any]:
settings = dict(pbx.settings or {})
soft = dict(settings.get("softphone") or {})
soft.setdefault("relay_enabled", True)
soft.setdefault("direct_enabled", False)
soft.setdefault("media_codec_prefs", ["pcm_ws_v1"])
soft.setdefault("max_concurrent_softphone_sessions", 50)
soft.setdefault("audiosocket_host", "127.0.0.1")
soft.setdefault("audiosocket_port", 9092)
return soft
def session_to_dict(row: TelephonySoftphoneSession, *, include_secrets: bool = False) -> Dict[str, Any]:
out: Dict[str, Any] = {
"id": row.id,
"session_id": row.session_id,
"business_id": row.business_id,
"pbx_id": row.pbx_id,
"user_id": row.user_id,
"extension": row.extension,
"mode": row.mode,
"state": row.state,
"media_edge_node": row.media_edge_node,
"connector_tunnel_id": row.connector_tunnel_id,
"call_id": row.call_id,
"ice_policy": row.ice_policy,
"turn_used": bool(row.turn_used),
"last_quality": row.last_quality_json or {},
"client_info": row.client_info or {},
"last_heartbeat_at": _iso(row.last_heartbeat_at),
"expires_at": _iso(row.expires_at),
"ended_at": _iso(row.ended_at),
"end_reason": row.end_reason,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
if include_secrets:
out["media_ticket_prefix"] = row.media_ticket_prefix
return out
def _primary_user_extension(db: Session, business_id: int, user_id: int) -> TelephonyUserExtension:
row = (
db.query(TelephonyUserExtension)
.options(joinedload(TelephonyUserExtension.extension_row))
.filter(
TelephonyUserExtension.business_id == business_id,
TelephonyUserExtension.user_id == user_id,
)
.order_by(TelephonyUserExtension.is_primary.desc(), TelephonyUserExtension.id.asc())
.first()
)
if not row or not row.extension_row:
raise ApiError(
"SOFTPHONE_NO_EXTENSION",
"برای Softphone باید داخلی به کاربر نگاشت شده باشد.",
http_status=400,
)
return row
def _resolve_mode(link: TelephonyUserExtension, soft: Dict[str, Any], requested: Optional[str]) -> str:
mode = (requested or link.endpoint_mode or "desk").strip().lower()
if mode not in VALID_ENDPOINT_MODES:
raise ApiError("VALIDATION_ERROR", "endpoint_mode نامعتبر است.", http_status=400)
if mode == "desk":
raise ApiError(
"SOFTPHONE_DESK_MODE",
"حالت این کاربر desk است؛ از Click-to-Call استفاده کنید یا حالت را به relay/direct تغییر دهید.",
http_status=400,
)
if mode == "relay" and not soft.get("relay_enabled", True):
if link.allow_mode_fallback and soft.get("direct_enabled"):
return "direct"
raise ApiError("SOFTPHONE_RELAY_DISABLED", "حالت رله روی این PBX غیرفعال است.", http_status=400)
if mode == "direct" and not soft.get("direct_enabled", False):
if link.allow_mode_fallback and soft.get("relay_enabled", True):
return "relay"
raise ApiError("SOFTPHONE_DIRECT_DISABLED", "حالت مستقیم روی این PBX غیرفعال است.", http_status=400)
return mode
def softphone_health(db: Session, business_id: int, user_id: int) -> Dict[str, Any]:
try:
link = _primary_user_extension(db, business_id, user_id)
pbx = db.get(TelephonyPbxConnection, link.pbx_id)
except ApiError:
hub = media_hub.health() if is_media_edge_process() else (fetch_media_edge_snapshot() or {}).get("media_hub")
return {
"ok": False,
"reason": "no_extension",
"media_hub": hub or media_hub.health(),
"tunnel": None,
"endpoint_mode": None,
"process_role": process_role(),
"ws_client_connected": False,
}
soft = softphone_settings_from_pbx(pbx) if pbx else {}
if is_media_edge_process():
hub = media_hub.health()
tunnel = media_hub.tunnel_snapshot(link.pbx_id) if pbx else None
else:
snap = fetch_media_edge_snapshot(pbx_id=link.pbx_id) or {}
hub = snap.get("media_hub") or {"node_id": None, "clients": 0, "tunnels": 0, "bridges": 0}
tunnel = snap.get("tunnel")
active = (
db.query(TelephonySoftphoneSession)
.filter(
TelephonySoftphoneSession.business_id == business_id,
TelephonySoftphoneSession.user_id == user_id,
TelephonySoftphoneSession.state.in_(ACTIVE_SESSION_STATES),
TelephonySoftphoneSession.expires_at > _now(),
)
.order_by(TelephonySoftphoneSession.id.desc())
.first()
)
ws_connected = bool(active and is_media_edge_process() and media_hub.get_client(active.session_id))
if active and not is_media_edge_process():
# روی API worker فقط از شمارش clients لبه تقریبی نداریم؛ state DB را گزارش می‌کنیم
ws_connected = bool(active.state in ("registered", "ringing", "in_call"))
ok = bool(pbx and pbx.status == "online" and (tunnel and tunnel.get("online")))
if link.endpoint_mode == "direct" and soft.get("direct_enabled"):
ok = bool(pbx and pbx.is_active)
return {
"ok": ok,
"reason": None if ok else ("tunnel_offline" if not (tunnel and tunnel.get("online")) else "pbx_offline"),
"endpoint_mode": link.endpoint_mode,
"extension": link.extension_row.extension if link.extension_row else None,
"pbx_id": link.pbx_id,
"pbx_status": pbx.status if pbx else None,
"softphone_settings": soft,
"tunnel": tunnel,
"active_session": session_to_dict(active) if active else None,
"media_hub": hub,
"supported_profiles": [MEDIA_PROFILE_PCM_WS_V1],
"process_role": process_role(),
"ws_client_connected": ws_connected,
}
def create_session(
db: Session,
business_id: int,
user_id: int,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
payload = payload or {}
require_media_edge_process()
link = _primary_user_extension(db, business_id, user_id)
pbx = db.get(TelephonyPbxConnection, link.pbx_id)
if not pbx or not pbx.is_active:
raise ApiError("PBX_NOT_FOUND", "مرکز تلفن فعال یافت نشد.", http_status=404)
soft = softphone_settings_from_pbx(pbx)
mode = _resolve_mode(link, soft, payload.get("mode") or link.endpoint_mode)
if mode == "relay" and not media_hub.tunnel_online(pbx.id):
raise ApiError(
"SOFTPHONE_TUNNEL_OFFLINE",
"تونل رسانه Connector قطع است. روی سرور تلفن Connector را به‌روز و سرویس را ری‌استارت کنید.",
http_status=503,
)
# پایان سشن‌های قبلی همان کاربر
old_rows = (
db.query(TelephonySoftphoneSession)
.filter(
TelephonySoftphoneSession.business_id == business_id,
TelephonySoftphoneSession.user_id == user_id,
TelephonySoftphoneSession.state.in_(ACTIVE_SESSION_STATES),
)
.all()
)
for old in old_rows:
old.state = "ended"
old.ended_at = _now()
old.end_reason = "replaced"
old.updated_at = _now()
active_count = (
db.query(TelephonySoftphoneSession)
.filter(
TelephonySoftphoneSession.pbx_id == pbx.id,
TelephonySoftphoneSession.state.in_(ACTIVE_SESSION_STATES),
TelephonySoftphoneSession.expires_at > _now(),
)
.count()
)
max_sessions = int(soft.get("max_concurrent_softphone_sessions") or 50)
if active_count >= max_sessions:
raise ApiError("SOFTPHONE_CAPACITY", "سقف سشن همزمان Softphone پر است.", http_status=429)
session_id = str(uuid.uuid4())
ticket = "hsx_media_" + secrets.token_urlsafe(24)
ticket_hash = hash_api_key(ticket)
now = _now()
row = TelephonySoftphoneSession(
session_id=session_id,
business_id=business_id,
pbx_id=pbx.id,
user_id=user_id,
extension=link.extension_row.extension,
mode=mode,
state="creating",
media_ticket_hash=ticket_hash,
media_ticket_prefix=ticket[:16],
media_edge_node=media_hub.node_id,
connector_tunnel_id=(media_hub.tunnel_snapshot(pbx.id) or {}).get("tunnel_id"),
ice_policy="relay_first" if mode == "relay" else "all",
client_info=payload.get("client_info") if isinstance(payload.get("client_info"), dict) else {},
last_heartbeat_at=now,
expires_at=now + timedelta(minutes=SESSION_TTL_MINUTES),
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
devices = softphone_devices_config(db, business_id, pbx)
return {
"session": session_to_dict(row),
"media_ticket": ticket,
"ws_path": "/ws/telephony/softphone",
"media_profile": MEDIA_PROFILE_PCM_WS_V1,
"devices_config": devices,
"health": softphone_health(db, business_id, user_id),
}
def heartbeat_session(db: Session, business_id: int, user_id: int, session_id: str) -> Dict[str, Any]:
row = _get_user_session(db, business_id, user_id, session_id)
if row.state not in ACTIVE_SESSION_STATES:
raise ApiError("SOFTPHONE_SESSION_ENDED", "سشن Softphone پایان یافته است.", http_status=409)
row.last_heartbeat_at = _now()
row.expires_at = _now() + timedelta(minutes=SESSION_TTL_MINUTES)
row.updated_at = _now()
# فقط وقتی WebSocket واقعاً روی همین Media Edge ثبت شده، creating→registered
# (قبلاً heartbeat بدون WS سشن را registered می‌کرد و UI فکر می‌کرد آنلاین است)
if row.state == "creating" and is_media_edge_process() and media_hub.get_client(session_id):
row.state = "registered"
db.commit()
db.refresh(row)
return session_to_dict(row)
def end_session(
db: Session,
business_id: int,
user_id: int,
session_id: str,
*,
reason: str = "logout",
) -> Dict[str, Any]:
row = _get_user_session(db, business_id, user_id, session_id)
row.state = "ended"
row.ended_at = _now()
row.end_reason = reason
row.updated_at = _now()
db.commit()
db.refresh(row)
return session_to_dict(row)
def _get_user_session(
db: Session, business_id: int, user_id: int, session_id: str
) -> TelephonySoftphoneSession:
row = (
db.query(TelephonySoftphoneSession)
.filter(
TelephonySoftphoneSession.session_id == session_id,
TelephonySoftphoneSession.business_id == business_id,
TelephonySoftphoneSession.user_id == user_id,
)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "سشن Softphone یافت نشد.", http_status=404)
return row
def get_session_by_ticket(db: Session, session_id: str, media_ticket: str) -> TelephonySoftphoneSession:
row = (
db.query(TelephonySoftphoneSession)
.filter(TelephonySoftphoneSession.session_id == session_id)
.first()
)
if not row or row.state not in ACTIVE_SESSION_STATES:
raise ApiError("UNAUTHORIZED", "سشن نامعتبر است.", http_status=401)
if row.expires_at < _now():
raise ApiError("UNAUTHORIZED", "سشن منقضی شده است.", http_status=401)
if hash_api_key(media_ticket) != row.media_ticket_hash:
raise ApiError("UNAUTHORIZED", "بلیت رسانه نامعتبر است.", http_status=401)
return row
def softphone_devices_config(
db: Session, business_id: int, pbx: Optional[TelephonyPbxConnection] = None
) -> Dict[str, Any]:
# TURN از تنظیمات سیستم/محیط قابل تزریق است؛ پیش‌فرض فقط STUN عمومی برای Direct
from app.core.settings import get_settings
settings = get_settings()
ice_servers: List[Dict[str, Any]] = [{"urls": ["stun:stun.l.google.com:19302"]}]
turn_url = getattr(settings, "telephony_turn_url", None) or None
turn_user = getattr(settings, "telephony_turn_username", None) or None
turn_pass = getattr(settings, "telephony_turn_password", None) or None
if turn_url:
entry: Dict[str, Any] = {"urls": [turn_url]}
if turn_user:
entry["username"] = turn_user
if turn_pass:
entry["credential"] = turn_pass
ice_servers.append(entry)
return {
"ice_servers": ice_servers,
"media_profile": MEDIA_PROFILE_PCM_WS_V1,
"pcm": {"sample_rate": 8000, "channels": 1, "encoding": "pcm_s16le", "frame_ms": 20},
"business_id": business_id,
"pbx_id": pbx.id if pbx else None,
}
def mark_session_state(
db: Session,
session_id: str,
state: str,
*,
call_id: Optional[int] = None,
quality: Optional[Dict[str, Any]] = None,
) -> None:
row = (
db.query(TelephonySoftphoneSession)
.filter(TelephonySoftphoneSession.session_id == session_id)
.first()
)
if not row:
return
row.state = state
if call_id is not None:
row.call_id = call_id
if quality is not None:
row.last_quality_json = quality
row.updated_at = _now()
db.commit()
async def notify_softphone_event(user_id: int, business_id: int, event_type: str, payload: Dict[str, Any]) -> None:
try:
await realtime_manager.send_to_user(
user_id,
{
"type": event_type,
"business_id": business_id,
**payload,
},
)
except Exception:
pass
def start_outbound_relay_call(
db: Session,
business_id: int,
user_id: int,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""شروع تماس خروجی در حالت relay: پل رسانه + Originate سمت مقصد."""
require_media_edge_process()
session_id = str(payload.get("session_id") or "").strip()
destination = str(payload.get("destination") or "").strip()
if not session_id or not destination:
raise ApiError("VALIDATION_ERROR", "session_id و destination الزامی است.", http_status=400)
row = _get_user_session(db, business_id, user_id, session_id)
if row.mode != "relay":
raise ApiError("SOFTPHONE_MODE", "خروجی relay فقط در حالت relay مجاز است.", http_status=400)
if row.state not in ("registered", "in_call"):
raise ApiError(
"SOFTPHONE_SESSION_STATE",
"سشن برای تماس آماده نیست. ابتدا Softphone را آنلاین کنید تا WebSocket وصل شود.",
http_status=409,
)
if not media_hub.get_client(session_id):
raise ApiError(
"SOFTPHONE_WS_REQUIRED",
"کانال Softphone روی Media Edge ثبت نشده است. صفحه را رفرش کنید و دوباره آنلاین شوید.",
http_status=409,
)
if not media_hub.tunnel_online(row.pbx_id):
raise ApiError("SOFTPHONE_TUNNEL_OFFLINE", "تونل رسانه قطع است.", http_status=503)
# از مسیر CTI موجود برای ساخت تماس/دستور استفاده می‌کنیم با payload ویژه softphone
from app.services.telephony.phone_normalizer import normalize_iran_phone
norm = normalize_iran_phone(destination)
pbx = db.get(TelephonyPbxConnection, row.pbx_id)
if not pbx:
raise ApiError("PBX_NOT_FOUND", "PBX یافت نشد.", http_status=404)
soft = softphone_settings_from_pbx(pbx)
settings = pbx.settings or {}
context = settings.get("originate_context") or "from-internal"
timeout_ms = int(settings.get("originate_timeout_ms") or 30000)
uniqueid = f"hsx-sf-{uuid.uuid4().hex[:16]}"
as_uuid = str(uuid.uuid4())
call = TelephonyCall(
business_id=business_id,
pbx_id=row.pbx_id,
asterisk_uniqueid=uniqueid,
asterisk_linkedid=uniqueid,
direction="outbound",
status="ringing",
from_number_raw=row.extension,
from_number_normalized=row.extension,
to_number_raw=destination,
to_number_normalized=norm.get("canonical") or destination,
extension=row.extension,
started_at=_now(),
assigned_user_id=user_id,
person_id=payload.get("person_id"),
lead_id=payload.get("lead_id"),
match_method="manual" if payload.get("person_id") or payload.get("lead_id") else None,
is_anonymous=False,
extra_info={"softphone": True, "session_id": session_id, "mode": "relay", "audiosocket_uuid": as_uuid},
created_at=_now(),
updated_at=_now(),
)
db.add(call)
db.flush()
cmd = TelephonyCommand(
business_id=business_id,
pbx_id=row.pbx_id,
command_id=str(uuid.uuid4()),
command_type="softphone_bridge_out",
payload={
"type": "softphone_bridge_out",
"session_id": session_id,
"extension": row.extension,
"destination": destination,
"context": context,
"timeout_ms": timeout_ms,
"audiosocket_uuid": as_uuid,
"audiosocket_host": soft.get("audiosocket_host") or "127.0.0.1",
"audiosocket_port": int(soft.get("audiosocket_port") or 9092),
"call_id": call.id,
"uniqueid": uniqueid,
},
status="pending",
created_by_user_id=user_id,
call_id=call.id,
created_at=_now(),
expires_at=_now() + timedelta(seconds=120),
)
db.add(cmd)
row.state = "ringing"
row.call_id = call.id
row.updated_at = _now()
db.commit()
db.refresh(call)
return {
"call": tel.call_to_dict(call),
"session_id": session_id,
"audiosocket_uuid": as_uuid,
"command_id": cmd.command_id,
}
def enqueue_answer_inbound(
db: Session,
business_id: int,
user_id: int,
call_id: int,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
payload = payload or {}
require_media_edge_process()
session_id = str(payload.get("session_id") or "").strip()
row = _get_user_session(db, business_id, user_id, session_id) if session_id else None
if row is None:
# آخرین سشن فعال
row = (
db.query(TelephonySoftphoneSession)
.filter(
TelephonySoftphoneSession.business_id == business_id,
TelephonySoftphoneSession.user_id == user_id,
TelephonySoftphoneSession.state.in_(ACTIVE_SESSION_STATES),
)
.order_by(TelephonySoftphoneSession.id.desc())
.first()
)
if not row:
raise ApiError("SOFTPHONE_NO_SESSION", "سشن Softphone فعال نیست.", http_status=409)
if not media_hub.get_client(row.session_id):
raise ApiError(
"SOFTPHONE_WS_REQUIRED",
"کانال Softphone روی Media Edge ثبت نشده است. صفحه را رفرش کنید و دوباره آنلاین شوید.",
http_status=409,
)
call = (
db.query(TelephonyCall)
.filter(TelephonyCall.id == call_id, TelephonyCall.business_id == business_id)
.first()
)
if not call:
raise ApiError("NOT_FOUND", "تماس یافت نشد.", http_status=404)
as_uuid = str(uuid.uuid4())
soft = softphone_settings_from_pbx(db.get(TelephonyPbxConnection, row.pbx_id))
cmd = TelephonyCommand(
business_id=business_id,
pbx_id=row.pbx_id,
command_id=str(uuid.uuid4()),
command_type="softphone_bridge_in",
payload={
"type": "softphone_bridge_in",
"session_id": row.session_id,
"extension": row.extension,
"call_id": call.id,
"uniqueid": call.asterisk_uniqueid,
"channel": (call.extra_info or {}).get("channel"),
"audiosocket_uuid": as_uuid,
"audiosocket_host": soft.get("audiosocket_host") or "127.0.0.1",
"audiosocket_port": int(soft.get("audiosocket_port") or 9092),
},
status="pending",
created_by_user_id=user_id,
call_id=call.id,
created_at=_now(),
expires_at=_now() + timedelta(seconds=60),
)
db.add(cmd)
row.state = "in_call"
row.call_id = call.id
row.updated_at = _now()
call.status = "answered"
if not call.answered_at:
call.answered_at = _now()
call.updated_at = _now()
db.commit()
return {
"call": tel.call_to_dict(call),
"session_id": row.session_id,
"audiosocket_uuid": as_uuid,
"command_id": cmd.command_id,
}
def enqueue_dtmf(
db: Session,
business_id: int,
user_id: int,
call_id: int,
digit: str,
session_id: Optional[str] = None,
) -> Dict[str, Any]:
digit = (digit or "").strip()
if not digit or len(digit) > 8:
raise ApiError("VALIDATION_ERROR", "رقم DTMF نامعتبر است.", http_status=400)
call = (
db.query(TelephonyCall)
.filter(TelephonyCall.id == call_id, TelephonyCall.business_id == business_id)
.first()
)
if not call:
raise ApiError("NOT_FOUND", "تماس یافت نشد.", http_status=404)
cmd = TelephonyCommand(
business_id=business_id,
pbx_id=call.pbx_id,
command_id=str(uuid.uuid4()),
command_type="softphone_dtmf",
payload={
"type": "softphone_dtmf",
"call_id": call.id,
"digit": digit,
"channel": (call.extra_info or {}).get("channel"),
"session_id": session_id,
},
status="pending",
created_by_user_id=user_id,
call_id=call.id,
created_at=_now(),
expires_at=_now() + timedelta(seconds=30),
)
db.add(cmd)
db.commit()
return {"accepted": True, "command_id": cmd.command_id}