Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/adapters/api/v1/telephony.py

993 lines
36 KiB
Python

"""API افزونه اتصال به استریسک و ایزابل."""
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Body, Depends, Header, Query, Request
from sqlalchemy.orm import Session
from adapters.db.session import get_db
from app.core.auth_dependency import AuthContext, get_current_user
from app.core.i18n import locale_dependency
from app.core.permissions import require_business_access_dep, require_business_permission_dep
from app.core.responses import ApiError, format_datetime_fields, success_response
from app.core.telephony_plugin_dependency import (
require_telephony_click_or_view,
require_telephony_plugin_active,
)
from app.services.telephony import telephony_service as svc
router = APIRouter(prefix="/telephony", tags=["telephony"])
def _resp(data: Any, request: Request, **kwargs) -> dict:
return success_response(format_datetime_fields(data, request), request, **kwargs)
def _bearer_token(authorization: Optional[str]) -> str:
if not authorization:
raise ApiError("UNAUTHORIZED", "Missing Authorization header.", http_status=401)
parts = authorization.split()
if len(parts) == 2 and parts[0].lower() == "bearer":
return parts[1].strip()
raise ApiError("UNAUTHORIZED", "Invalid Authorization header.", http_status=401)
# ─── Settings / context ─────────────────────────────────────────────────────
@router.get("/business/{business_id}/settings")
def get_settings(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
row = svc.get_or_create_settings(db, business_id)
return _resp(svc.settings_to_dict(row), request)
@router.put("/business/{business_id}/settings")
def put_settings(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.update_settings(db, business_id, payload), request)
@router.get("/business/{business_id}/me/context")
def my_context(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.get_my_extension_context(db, business_id, ctx.get_user_id()), request)
@router.get("/business/{business_id}/dashboard")
def dashboard(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.dashboard_summary(db, business_id, user_id=ctx.get_user_id()), request)
# ─── PBX ────────────────────────────────────────────────────────────────────
@router.get("/business/{business_id}/pbx")
def list_pbx(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp({"items": svc.list_pbx(db, business_id)}, request)
@router.post("/business/{business_id}/pbx")
def create_pbx(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.create_pbx(db, business_id, payload), request)
@router.patch("/business/{business_id}/pbx/{pbx_id}")
def patch_pbx(
request: Request,
business_id: int,
pbx_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.update_pbx(db, business_id, pbx_id, payload), request)
@router.post("/business/{business_id}/pbx/{pbx_id}/rotate-token")
def rotate_token(
request: Request,
business_id: int,
pbx_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.rotate_pbx_token(db, business_id, pbx_id), request)
# ─── Extensions ─────────────────────────────────────────────────────────────
@router.get("/business/{business_id}/extensions")
def list_extensions(
request: Request,
business_id: int,
pbx_id: Optional[int] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp({"items": svc.list_extensions(db, business_id, pbx_id=pbx_id)}, request)
@router.post("/business/{business_id}/extensions")
def upsert_extension(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.upsert_extension(db, business_id, payload), request)
@router.get("/business/{business_id}/user-extensions")
def list_user_extensions(
request: Request,
business_id: int,
user_id: Optional[int] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp({"items": svc.list_user_extensions(db, business_id, user_id=user_id)}, request)
@router.put("/business/{business_id}/user-extensions")
def upsert_user_extension(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.upsert_user_extension(db, business_id, payload), request)
@router.delete("/business/{business_id}/user-extensions/{link_id}")
def delete_user_extension(
request: Request,
business_id: int,
link_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
svc.delete_user_extension(db, business_id, link_id)
return _resp({"ok": True}, request)
# ─── Calls ──────────────────────────────────────────────────────────────────
@router.get("/business/{business_id}/calls")
def list_calls(
request: Request,
business_id: int,
direction: Optional[str] = Query(None),
status: Optional[str] = Query(None),
assigned_user_id: Optional[int] = Query(None),
person_id: Optional[int] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(
svc.list_calls(
db,
business_id,
direction=direction,
status=status,
assigned_user_id=assigned_user_id,
person_id=person_id,
q=q,
limit=limit,
offset=offset,
),
request,
)
@router.get("/business/{business_id}/calls/{call_id}")
def get_call(
request: Request,
business_id: int,
call_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.get_call(db, business_id, call_id), request)
@router.patch("/business/{business_id}/calls/{call_id}")
def patch_call(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.patch_call(db, business_id, call_id, payload), request)
@router.get("/business/{business_id}/calls/{call_id}/screen-pop-context")
def screen_pop(
request: Request,
business_id: int,
call_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.screen_pop_context(db, business_id, call_id), request)
@router.post("/business/{business_id}/calls/{call_id}/create-person")
def create_person(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(default=None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.create_person_from_call(db, business_id, call_id, payload or {}, ctx.get_user_id()), request)
@router.post("/business/{business_id}/calls/{call_id}/create-lead")
def create_lead(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(default=None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.create_lead_from_call(db, business_id, call_id, payload or {}, ctx.get_user_id()), request)
@router.post("/business/{business_id}/click-to-call")
def click_to_call(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_telephony_click_or_view()),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.click_to_call(db, business_id, ctx.get_user_id(), payload), request)
@router.get("/business/{business_id}/lookup-number")
def lookup_number(
request: Request,
business_id: int,
number: str = Query(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "view")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
return _resp(svc.match_caller(db, business_id, number), request)
# ─── Connector endpoints ────────────────────────────────────────────────────
@router.post("/connector/heartbeat")
def connector_heartbeat(
request: Request,
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(None),
x_hesabix_business_id: Optional[int] = Header(None, alias="X-Hesabix-Business-Id"),
x_hesabix_pbx_id: Optional[int] = Header(None, alias="X-Hesabix-Pbx-Id"),
db: Session = Depends(get_db),
) -> dict:
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or payload.get("business_id") or 0)
pbx_id = int(x_hesabix_pbx_id or payload.get("pbx_id") or 0)
if not business_id or not pbx_id:
raise ApiError("VALIDATION_ERROR", "business_id and pbx_id required.", http_status=400)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
return _resp(svc.connector_heartbeat(db, pbx, payload), request)
@router.post("/connector/events")
def connector_events(
request: Request,
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(None),
x_hesabix_business_id: Optional[int] = Header(None, alias="X-Hesabix-Business-Id"),
x_hesabix_pbx_id: Optional[int] = Header(None, alias="X-Hesabix-Pbx-Id"),
db: Session = Depends(get_db),
) -> dict:
from app.services.telephony import recording_service as rec_svc
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or payload.get("business_id") or 0)
pbx_id = int(x_hesabix_pbx_id or payload.get("pbx_id") or 0)
if not business_id or not pbx_id:
raise ApiError("VALIDATION_ERROR", "business_id and pbx_id required.", http_status=400)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
try:
result = svc.process_connector_event(db, pbx, payload)
return _resp(result, request)
except ApiError:
raise
except Exception as e:
try:
rec_svc.push_dead_letter(
db, business_id=business_id, pbx_id=pbx_id, payload=payload, error=str(e)
)
db.commit()
except Exception:
db.rollback()
raise ApiError("EVENT_PROCESSING_FAILED", str(e), http_status=500)
@router.get("/connector/commands/poll")
def connector_poll(
request: Request,
authorization: Optional[str] = Header(None),
x_hesabix_business_id: Optional[int] = Header(None, alias="X-Hesabix-Business-Id"),
x_hesabix_pbx_id: Optional[int] = Header(None, alias="X-Hesabix-Pbx-Id"),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
) -> dict:
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or 0)
pbx_id = int(x_hesabix_pbx_id or 0)
if not business_id or not pbx_id:
raise ApiError("VALIDATION_ERROR", "business_id and pbx_id required.", http_status=400)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
return _resp({"items": svc.poll_commands(db, pbx, limit=limit)}, request)
@router.post("/connector/commands/{command_id}/ack")
def connector_ack(
request: Request,
command_id: str,
payload: Dict[str, Any] = Body(default=None),
authorization: Optional[str] = Header(None),
x_hesabix_business_id: Optional[int] = Header(None, alias="X-Hesabix-Business-Id"),
x_hesabix_pbx_id: Optional[int] = Header(None, alias="X-Hesabix-Pbx-Id"),
db: Session = Depends(get_db),
) -> dict:
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or 0)
pbx_id = int(x_hesabix_pbx_id or 0)
if not business_id or not pbx_id:
raise ApiError("VALIDATION_ERROR", "business_id and pbx_id required.", http_status=400)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
return _resp(svc.ack_command(db, pbx, command_id, payload or {}), request)
@router.post("/connector/recordings/meta")
def connector_recording_meta(
request: Request,
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(None),
x_hesabix_business_id: Optional[int] = Header(None, alias="X-Hesabix-Business-Id"),
x_hesabix_pbx_id: Optional[int] = Header(None, alias="X-Hesabix-Pbx-Id"),
db: Session = Depends(get_db),
) -> dict:
from adapters.db.models.telephony import TelephonyCall
from app.services.telephony.recording_service import apply_recording_meta, bump_metric
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or payload.get("business_id") or 0)
pbx_id = int(x_hesabix_pbx_id or payload.get("pbx_id") or 0)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
uniqueid = str(payload.get("uniqueid") or "")
call = (
db.query(TelephonyCall)
.filter(TelephonyCall.pbx_id == pbx.id, TelephonyCall.asterisk_uniqueid == uniqueid)
.first()
)
if not call:
raise ApiError("NOT_FOUND", "Call not found for recording meta.", http_status=404)
apply_recording_meta(db, call, payload)
bump_metric(db, business_id, "recordings_ready")
db.commit()
return _resp(svc.call_to_dict(call), request)
# ─── Phase 2 reports ────────────────────────────────────────────────────────
@router.get("/business/{business_id}/reports/summary")
def report_summary(
request: Request,
business_id: int,
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "reports")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import reports_service as reports
return _resp(reports.summary_report(db, business_id, date_from=date_from, date_to=date_to), request)
@router.get("/business/{business_id}/reports/operators")
def report_operators(
request: Request,
business_id: int,
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "reports")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import reports_service as reports
return _resp(reports.operators_report(db, business_id, date_from=date_from, date_to=date_to), request)
@router.get("/business/{business_id}/reports/by-person")
def report_by_person(
request: Request,
business_id: int,
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "reports")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import reports_service as reports
return _resp(reports.by_person_report(db, business_id, date_from=date_from, date_to=date_to), request)
@router.get("/business/{business_id}/reports/by-extension")
def report_by_extension(
request: Request,
business_id: int,
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "reports")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import reports_service as reports
return _resp(reports.by_extension_report(db, business_id, date_from=date_from, date_to=date_to), request)
# ─── Phase 2 recording / Phase 3 live / Phase 4 ops ──────────────────────────
@router.get("/business/{business_id}/calls/{call_id}/recording")
def get_recording(
request: Request,
business_id: int,
call_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "listen_recordings")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import recording_service as rec_svc
rec_svc.assert_can_listen(ctx, db, business_id)
return _resp(rec_svc.recording_info(db, business_id, call_id), request)
@router.get("/business/{business_id}/live/snapshot")
def live_snapshot(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "live_monitor")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import live_service as live
return _resp(live.live_snapshot(db, business_id), request)
@router.post("/business/{business_id}/calls/{call_id}/control")
def control_call(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "control_calls")),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import live_service as live
return _resp(
live.enqueue_control_command(
db,
business_id,
ctx.get_user_id(),
command_type=str(payload.get("action") or payload.get("type") or ""),
call_id=call_id,
target=payload.get("target"),
session_id=str(payload.get("session_id") or "") or None,
),
request,
)
@router.get("/business/{business_id}/ops/metrics")
def ops_metrics(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import recording_service as rec_svc
return _resp(rec_svc.metrics_summary(db, business_id), request)
@router.get("/business/{business_id}/ops/dead-letters")
def ops_dlq(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import recording_service as rec_svc
return _resp({"items": rec_svc.list_dead_letters(db, business_id)}, request)
@router.post("/business/{business_id}/ops/dead-letters/{dlq_id}/resolve")
def ops_dlq_resolve(
request: Request,
business_id: int,
dlq_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(require_business_permission_dep("telephony", "manage")),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import recording_service as rec_svc
return _resp(rec_svc.resolve_dead_letter(db, business_id, dlq_id), request)
# ─── Softphone (Media Relay) ─────────────────────────────────────────────────
def _require_softphone_operator():
"""softphone یا click_to_call یا view."""
def _dep(
business_id: int,
ctx: AuthContext = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
from app.core.permissions import has_business_permission_for_business
uid_ok = (
has_business_permission_for_business(ctx, db, business_id, "telephony", "softphone")
or has_business_permission_for_business(ctx, db, business_id, "telephony", "click_to_call")
or has_business_permission_for_business(ctx, db, business_id, "telephony", "view")
)
if not uid_ok:
raise ApiError("FORBIDDEN", "دسترسی Softphone ندارید.", http_status=403)
return _dep
def _require_media_edge() -> None:
"""Softphone media ops فقط روی Media Edge (workers=1)."""
from app.services.telephony.media_edge import require_media_edge_process
require_media_edge_process()
@router.get("/internal/media-edge/snapshot")
def media_edge_internal_snapshot(
request: Request,
pbx_id: Optional[int] = Query(default=None),
x_hesabix_media_edge_token: Optional[str] = Header(default=None),
) -> dict:
"""وضعیت media_hub برای API workers (فقط localhost یا token داخلی)."""
from app.core.settings import get_settings
from app.services.telephony.media_edge import is_media_edge_process, process_role
from app.services.telephony.media_hub import media_hub
client = request.client.host if request.client else ""
settings = get_settings()
expected = (settings.hesabix_media_edge_token or "").strip()
token_ok = bool(expected) and (x_hesabix_media_edge_token or "").strip() == expected
localhost_ok = client in ("127.0.0.1", "::1", "localhost")
if not (localhost_ok or token_ok):
raise ApiError("FORBIDDEN", "internal only", http_status=403)
if not is_media_edge_process():
raise ApiError("SOFTPHONE_MEDIA_EDGE_REQUIRED", "این نود Media Edge نیست.", http_status=503)
data: Dict[str, Any] = {
"process_role": process_role(),
"media_hub": media_hub.health(),
"tunnel": media_hub.tunnel_snapshot(pbx_id) if pbx_id is not None else None,
}
if pbx_id is not None:
data["tunnel_online"] = media_hub.tunnel_online(pbx_id)
return _resp(data, request)
@router.get("/business/{business_id}/softphone/health")
def softphone_health(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
return _resp(soft_svc.softphone_health(db, business_id, ctx.get_user_id()), request)
@router.get("/business/{business_id}/softphone/devices-config")
def softphone_devices_config(
request: Request,
business_id: int,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
db: Session = Depends(get_db),
_ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
return _resp(soft_svc.softphone_devices_config(db, business_id), request)
@router.post("/business/{business_id}/softphone/sessions")
def softphone_create_session(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(default=None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
_____: None = Depends(_require_media_edge),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
return _resp(soft_svc.create_session(db, business_id, ctx.get_user_id(), payload or {}), request)
@router.post("/business/{business_id}/softphone/sessions/{session_id}/heartbeat")
def softphone_session_heartbeat(
request: Request,
business_id: int,
session_id: str,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
return _resp(soft_svc.heartbeat_session(db, business_id, ctx.get_user_id(), session_id), request)
@router.delete("/business/{business_id}/softphone/sessions/{session_id}")
def softphone_end_session(
request: Request,
business_id: int,
session_id: str,
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
_____: None = Depends(_require_media_edge),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
from app.services.telephony.media_hub import media_hub
import asyncio
data = soft_svc.end_session(db, business_id, ctx.get_user_id(), session_id, reason="api_delete")
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.create_task(media_hub.unregister_client(session_id, reason="api_delete"))
else:
loop.run_until_complete(media_hub.unregister_client(session_id, reason="api_delete"))
except Exception:
pass
return _resp(data, request)
@router.post("/business/{business_id}/softphone/calls")
async def softphone_outbound_call(
request: Request,
business_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
_____: None = Depends(_require_media_edge),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
"""تماس خروجی Softphone — باید await start_bridge شود وگرنه Media Hub همه PCM را drop می‌کند."""
import logging
from app.services.telephony import softphone_service as soft_svc
from app.services.telephony.media_hub import media_hub
log = logging.getLogger("hesabix.telephony.api")
result = soft_svc.start_outbound_relay_call(db, business_id, ctx.get_user_id(), payload or {})
session_id = result.get("session_id")
call = result.get("call") or {}
as_uuid = result.get("audiosocket_uuid")
if session_id:
try:
await media_hub.start_bridge(
session_id=session_id,
call_id=call.get("id"),
direction="outbound",
audiosocket_uuid=as_uuid,
)
except Exception as e:
# Originate از طریق poll ادامه می‌یابد؛ ensure_bridge_active روی bridge.active کانکتور پل را می‌سازد.
log.warning("start_bridge outbound deferred session=%s: %s", session_id, e)
return _resp(result, request)
@router.post("/business/{business_id}/softphone/calls/{call_id}/answer")
async def softphone_answer_call(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(default=None),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
_____: None = Depends(_require_media_edge),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
import logging
from app.services.telephony import softphone_service as soft_svc
from app.services.telephony.media_hub import media_hub
log = logging.getLogger("hesabix.telephony.api")
result = soft_svc.enqueue_answer_inbound(db, business_id, ctx.get_user_id(), call_id, payload or {})
session_id = result.get("session_id")
as_uuid = result.get("audiosocket_uuid")
if session_id:
try:
await media_hub.start_bridge(
session_id=session_id,
call_id=call_id,
direction="inbound",
audiosocket_uuid=as_uuid,
)
except Exception as e:
log.warning("start_bridge inbound deferred session=%s: %s", session_id, e)
return _resp(result, request)
@router.post("/business/{business_id}/softphone/calls/{call_id}/dtmf")
def softphone_dtmf(
request: Request,
business_id: int,
call_id: int,
payload: Dict[str, Any] = Body(...),
_: None = Depends(locale_dependency),
__: None = Depends(require_business_access_dep),
___: None = Depends(require_telephony_plugin_active()),
____: None = Depends(_require_softphone_operator()),
db: Session = Depends(get_db),
ctx: AuthContext = Depends(get_current_user),
) -> dict:
from app.services.telephony import softphone_service as soft_svc
digit = str((payload or {}).get("digit") or "")
session_id = (payload or {}).get("session_id")
return _resp(
soft_svc.enqueue_dtmf(db, business_id, ctx.get_user_id(), call_id, digit, session_id=session_id),
request,
)
@router.post("/connector/media/events")
def connector_media_events(
request: Request,
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
x_hesabix_business_id: Optional[str] = Header(default=None),
x_hesabix_pbx_id: Optional[str] = Header(default=None),
_: None = Depends(_require_media_edge),
db: Session = Depends(get_db),
) -> dict:
"""رویدادهای کنترل رسانه از Connector (علاوه بر WSS)."""
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or 0)
pbx_id = int(x_hesabix_pbx_id or 0)
pbx = svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
from app.services.telephony.media_hub import media_hub
tunnel = media_hub.tunnel_snapshot(pbx.id)
return _resp({"ok": True, "tunnel": tunnel, "received": payload.get("type")}, request)
@router.post("/connector/media/quality")
def connector_media_quality(
request: Request,
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
x_hesabix_business_id: Optional[str] = Header(default=None),
x_hesabix_pbx_id: Optional[str] = Header(default=None),
db: Session = Depends(get_db),
) -> dict:
token = _bearer_token(authorization)
business_id = int(x_hesabix_business_id or 0)
pbx_id = int(x_hesabix_pbx_id or 0)
svc.authenticate_connector(db, token=token, business_id=business_id, pbx_id=pbx_id)
from app.services.telephony import softphone_service as soft_svc
session_id = str(payload.get("session_id") or "")
if session_id:
soft_svc.mark_session_state(
db,
session_id,
str(payload.get("state") or "in_call"),
quality=payload.get("metrics") if isinstance(payload.get("metrics"), dict) else payload,
)
return _resp({"ok": True}, request)