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

1552 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""سرویس‌های اصلی افزونه تلفن: تنظیمات، PBX، داخلی، تماس، Click-to-Call."""
from __future__ import annotations
import secrets
import uuid
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, joinedload
from adapters.db.models.crm import CrmActivity, Lead
from adapters.db.models.person import Person
from adapters.db.models.telephony import (
TelephonyCall,
TelephonyCallEvent,
TelephonyCommand,
TelephonyExtension,
TelephonyNumberAlias,
TelephonyPbxConnection,
TelephonySettings,
TelephonyUserExtension,
)
from adapters.db.models.user import User
from app.core.responses import ApiError
from app.core.security import generate_api_key, hash_api_key
from app.core.telephony_plugin_dependency import check_telephony_plugin_active
from app.services.telephony.phone_normalizer import extract_digits, normalize_iran_phone, numbers_match
from app.services.realtime import realtime_manager
DEFAULT_PHONE_FIELDS = ["mobile", "mobile_2", "mobile_3", "phone"]
def _iso(dt: Optional[datetime]) -> Optional[str]:
return dt.isoformat() if dt else None
def _now() -> datetime:
return datetime.utcnow()
# ─── Settings ───────────────────────────────────────────────────────────────
def get_or_create_settings(db: Session, business_id: int) -> TelephonySettings:
row = db.query(TelephonySettings).filter(TelephonySettings.business_id == business_id).first()
if row:
return row
row = TelephonySettings(
business_id=business_id,
primary_phone_fields=list(DEFAULT_PHONE_FIELDS),
created_at=_now(),
updated_at=_now(),
)
db.add(row)
db.commit()
db.refresh(row)
return row
def settings_to_dict(row: TelephonySettings) -> Dict[str, Any]:
return {
"id": row.id,
"business_id": row.business_id,
"primary_phone_fields": row.primary_phone_fields or list(DEFAULT_PHONE_FIELDS),
"number_normalization_rules": row.number_normalization_rules or {},
"screen_pop_enabled": bool(row.screen_pop_enabled),
"auto_create_activity": bool(row.auto_create_activity),
"post_call_form_required": bool(row.post_call_form_required),
"default_category": row.default_category,
"missed_call_create_task": bool(row.missed_call_create_task),
"missed_call_task_due_minutes": row.missed_call_task_due_minutes,
"recording_access_mode": row.recording_access_mode,
"multi_pbx_enabled": bool(row.multi_pbx_enabled),
"ui_preferences": row.ui_preferences or {},
"extra_settings": row.extra_settings or {},
"updated_at": _iso(row.updated_at),
}
def update_settings(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
row = get_or_create_settings(db, business_id)
for key in (
"primary_phone_fields",
"number_normalization_rules",
"screen_pop_enabled",
"auto_create_activity",
"post_call_form_required",
"default_category",
"missed_call_create_task",
"missed_call_task_due_minutes",
"recording_access_mode",
"multi_pbx_enabled",
"ui_preferences",
"extra_settings",
):
if key in payload:
setattr(row, key, payload[key])
row.updated_at = _now()
db.commit()
db.refresh(row)
return settings_to_dict(row)
# ─── PBX ────────────────────────────────────────────────────────────────────
def pbx_to_dict(row: TelephonyPbxConnection, *, include_token_hint: bool = True) -> Dict[str, Any]:
offline = False
if row.last_seen_at:
offline = (_now() - row.last_seen_at) > timedelta(minutes=3)
status = row.status
if status == "online" and offline:
status = "offline"
return {
"id": row.id,
"business_id": row.business_id,
"name": row.name,
"pbx_type": row.pbx_type,
"host_hint": row.host_hint,
"connector_token_prefix": row.connector_token_prefix if include_token_hint else None,
"connector_version": row.connector_version,
"last_seen_at": _iso(row.last_seen_at),
"status": status,
"last_error": row.last_error,
"settings": row.settings or {},
"is_active": bool(row.is_active),
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
def list_pbx(db: Session, business_id: int) -> List[Dict[str, Any]]:
rows = (
db.query(TelephonyPbxConnection)
.filter(TelephonyPbxConnection.business_id == business_id)
.order_by(TelephonyPbxConnection.id.asc())
.all()
)
return [pbx_to_dict(r) for r in rows]
def create_pbx(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
name = (payload.get("name") or "").strip()
if not name:
raise ApiError("VALIDATION_ERROR", "نام مرکز تلفن الزامی است.", http_status=400)
api_key, key_hash = generate_api_key(prefix="hsx_tel_", length=36)
row = TelephonyPbxConnection(
business_id=business_id,
name=name,
pbx_type=(payload.get("pbx_type") or "issabel").strip(),
host_hint=(payload.get("host_hint") or None),
connector_token_hash=key_hash,
connector_token_prefix=api_key[:12],
settings=payload.get("settings") or {
"originate_context": "from-internal",
"originate_timeout_ms": 30000,
"softphone": {
"relay_enabled": True,
"direct_enabled": False,
"media_codec_prefs": ["pcm_ws_v1"],
"max_concurrent_softphone_sessions": 50,
"audiosocket_host": "127.0.0.1",
"audiosocket_port": 9092,
},
},
status="pending",
is_active=True,
created_at=_now(),
updated_at=_now(),
)
db.add(row)
db.commit()
db.refresh(row)
data = pbx_to_dict(row)
data["connector_token"] = api_key # فقط یک‌بار
data["install_hint"] = (
"توکن را فقط یک‌بار کپی کنید و در فایل تنظیمات Connector روی سرور Issabel قرار دهید."
)
return data
def rotate_pbx_token(db: Session, business_id: int, pbx_id: int) -> Dict[str, Any]:
row = (
db.query(TelephonyPbxConnection)
.filter(TelephonyPbxConnection.id == pbx_id, TelephonyPbxConnection.business_id == business_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "مرکز تلفن یافت نشد.", http_status=404)
api_key, key_hash = generate_api_key(prefix="hsx_tel_", length=36)
row.connector_token_hash = key_hash
row.connector_token_prefix = api_key[:12]
row.status = "pending"
row.updated_at = _now()
db.commit()
db.refresh(row)
data = pbx_to_dict(row)
data["connector_token"] = api_key
return data
def update_pbx(db: Session, business_id: int, pbx_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
row = (
db.query(TelephonyPbxConnection)
.filter(TelephonyPbxConnection.id == pbx_id, TelephonyPbxConnection.business_id == business_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "مرکز تلفن یافت نشد.", http_status=404)
for key in ("name", "pbx_type", "host_hint", "settings", "is_active"):
if key in payload:
setattr(row, key, payload[key])
row.updated_at = _now()
db.commit()
db.refresh(row)
return pbx_to_dict(row)
def authenticate_connector(
db: Session, *, token: str, business_id: int, pbx_id: int
) -> TelephonyPbxConnection:
if not token or not check_telephony_plugin_active(db, business_id):
raise ApiError("UNAUTHORIZED", "Connector authentication failed.", http_status=401)
token_hash = hash_api_key(token)
row = (
db.query(TelephonyPbxConnection)
.filter(
TelephonyPbxConnection.id == pbx_id,
TelephonyPbxConnection.business_id == business_id,
TelephonyPbxConnection.connector_token_hash == token_hash,
TelephonyPbxConnection.is_active == True, # noqa: E712
)
.first()
)
if not row:
raise ApiError("UNAUTHORIZED", "Invalid connector token.", http_status=401)
return row
def connector_heartbeat(
db: Session,
pbx: TelephonyPbxConnection,
payload: Dict[str, Any],
) -> Dict[str, Any]:
pbx.last_seen_at = _now()
pbx.connector_version = payload.get("connector_version") or pbx.connector_version
pbx.status = "online" if payload.get("status", "ok") == "ok" else "error"
if payload.get("status") != "ok":
pbx.last_error = str(payload.get("error") or "connector error")
else:
pbx.last_error = None
pbx.updated_at = _now()
db.commit()
return {"ok": True, "pbx_id": pbx.id, "status": pbx.status}
# ─── Extensions & user mapping ──────────────────────────────────────────────
def extension_to_dict(row: TelephonyExtension) -> Dict[str, Any]:
return {
"id": row.id,
"business_id": row.business_id,
"pbx_id": row.pbx_id,
"extension": row.extension,
"display_name": row.display_name,
"queue_codes": row.queue_codes,
"is_active": bool(row.is_active),
"presence_status": row.presence_status,
"presence_updated_at": _iso(row.presence_updated_at),
}
def list_extensions(db: Session, business_id: int, pbx_id: Optional[int] = None) -> List[Dict[str, Any]]:
q = db.query(TelephonyExtension).filter(TelephonyExtension.business_id == business_id)
if pbx_id:
q = q.filter(TelephonyExtension.pbx_id == pbx_id)
rows = q.order_by(TelephonyExtension.extension.asc()).all()
return [extension_to_dict(r) for r in rows]
def upsert_extension(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
pbx_id = int(payload.get("pbx_id") or 0)
ext = str(payload.get("extension") or "").strip()
if not pbx_id or not ext:
raise ApiError("VALIDATION_ERROR", "pbx_id و extension الزامی است.", http_status=400)
pbx = (
db.query(TelephonyPbxConnection)
.filter(TelephonyPbxConnection.id == pbx_id, TelephonyPbxConnection.business_id == business_id)
.first()
)
if not pbx:
raise ApiError("NOT_FOUND", "مرکز تلفن یافت نشد.", http_status=404)
row = (
db.query(TelephonyExtension)
.filter(TelephonyExtension.pbx_id == pbx_id, TelephonyExtension.extension == ext)
.first()
)
if not row:
row = TelephonyExtension(
business_id=business_id,
pbx_id=pbx_id,
extension=ext,
created_at=_now(),
updated_at=_now(),
)
db.add(row)
row.display_name = payload.get("display_name", row.display_name)
if "queue_codes" in payload:
row.queue_codes = payload.get("queue_codes")
if "is_active" in payload:
row.is_active = bool(payload.get("is_active"))
row.updated_at = _now()
db.commit()
db.refresh(row)
return extension_to_dict(row)
def user_extension_to_dict(row: TelephonyUserExtension) -> Dict[str, Any]:
ext = row.extension_row
return {
"id": row.id,
"business_id": row.business_id,
"user_id": row.user_id,
"pbx_id": row.pbx_id,
"extension_id": row.extension_id,
"extension": ext.extension if ext else None,
"extension_display_name": ext.display_name if ext else None,
"is_primary": bool(row.is_primary),
"receive_screen_pop": bool(row.receive_screen_pop),
"can_click_to_call": bool(row.can_click_to_call),
"caller_id_override": row.caller_id_override,
"endpoint_mode": getattr(row, "endpoint_mode", None) or "desk",
"allow_mode_fallback": bool(getattr(row, "allow_mode_fallback", True)),
"direct_sip_user": getattr(row, "direct_sip_user", None),
}
def list_user_extensions(
db: Session, business_id: int, user_id: Optional[int] = None
) -> List[Dict[str, Any]]:
q = (
db.query(TelephonyUserExtension)
.options(joinedload(TelephonyUserExtension.extension_row))
.filter(TelephonyUserExtension.business_id == business_id)
)
if user_id:
q = q.filter(TelephonyUserExtension.user_id == user_id)
rows = q.order_by(TelephonyUserExtension.id.asc()).all()
return [user_extension_to_dict(r) for r in rows]
def upsert_user_extension(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
user_id = int(payload.get("user_id") or 0)
extension_id = int(payload.get("extension_id") or 0)
if not user_id or not extension_id:
raise ApiError("VALIDATION_ERROR", "user_id و extension_id الزامی است.", http_status=400)
ext = (
db.query(TelephonyExtension)
.filter(TelephonyExtension.id == extension_id, TelephonyExtension.business_id == business_id)
.first()
)
if not ext:
raise ApiError("NOT_FOUND", "داخلی یافت نشد.", http_status=404)
user = db.get(User, user_id)
if not user:
raise ApiError("NOT_FOUND", "کاربر یافت نشد.", http_status=404)
row = (
db.query(TelephonyUserExtension)
.filter(
TelephonyUserExtension.business_id == business_id,
TelephonyUserExtension.user_id == user_id,
TelephonyUserExtension.extension_id == extension_id,
)
.first()
)
if not row:
row = TelephonyUserExtension(
business_id=business_id,
user_id=user_id,
pbx_id=ext.pbx_id,
extension_id=extension_id,
created_at=_now(),
updated_at=_now(),
)
db.add(row)
is_primary = bool(payload.get("is_primary", True))
if is_primary:
db.query(TelephonyUserExtension).filter(
TelephonyUserExtension.business_id == business_id,
TelephonyUserExtension.user_id == user_id,
TelephonyUserExtension.id != (row.id or 0),
).update({"is_primary": False}, synchronize_session=False)
row.is_primary = is_primary
if "receive_screen_pop" in payload:
row.receive_screen_pop = bool(payload["receive_screen_pop"])
if "can_click_to_call" in payload:
row.can_click_to_call = bool(payload["can_click_to_call"])
if "caller_id_override" in payload:
row.caller_id_override = payload.get("caller_id_override")
if "endpoint_mode" in payload:
mode = str(payload.get("endpoint_mode") or "desk").strip().lower()
if mode not in ("relay", "direct", "desk"):
raise ApiError("VALIDATION_ERROR", "endpoint_mode باید relay، direct یا desk باشد.", http_status=400)
row.endpoint_mode = mode
if "allow_mode_fallback" in payload:
row.allow_mode_fallback = bool(payload.get("allow_mode_fallback"))
if "direct_sip_user" in payload:
row.direct_sip_user = payload.get("direct_sip_user")
row.pbx_id = ext.pbx_id
row.updated_at = _now()
db.commit()
db.refresh(row)
row = (
db.query(TelephonyUserExtension)
.options(joinedload(TelephonyUserExtension.extension_row))
.filter(TelephonyUserExtension.id == row.id)
.first()
)
return user_extension_to_dict(row)
def delete_user_extension(db: Session, business_id: int, link_id: int) -> None:
row = (
db.query(TelephonyUserExtension)
.filter(TelephonyUserExtension.id == link_id, TelephonyUserExtension.business_id == business_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "نگاشت داخلی یافت نشد.", http_status=404)
db.delete(row)
db.commit()
def get_my_extension_context(db: Session, business_id: int, user_id: int) -> Dict[str, Any]:
links = list_user_extensions(db, business_id, user_id=user_id)
primary = next((x for x in links if x.get("is_primary")), links[0] if links else None)
pbx_list = list_pbx(db, business_id)
settings = settings_to_dict(get_or_create_settings(db, business_id))
missed_today = (
db.query(func.count(TelephonyCall.id))
.filter(
TelephonyCall.business_id == business_id,
TelephonyCall.assigned_user_id == user_id,
TelephonyCall.status == "missed",
TelephonyCall.started_at >= datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0),
)
.scalar()
or 0
)
from app.services.telephony import softphone_service as softphone_svc
from app.services.telephony.media_hub import media_hub
softphone_ctx: Dict[str, Any] = {
"endpoint_mode": (primary or {}).get("endpoint_mode") or "desk",
"supported_profiles": ["pcm_ws_v1"],
}
try:
softphone_ctx = softphone_svc.softphone_health(db, business_id, user_id)
except Exception:
softphone_ctx["media_hub"] = media_hub.health()
softphone_ctx["tunnel"] = None
return {
"plugin_active": True,
"settings": settings,
"extensions": links,
"primary": primary,
"pbx_connections": pbx_list,
"missed_today": int(missed_today),
"has_extension": bool(links),
"softphone": softphone_ctx,
}
# ─── Caller ID match ────────────────────────────────────────────────────────
def match_caller(
db: Session,
business_id: int,
raw_number: Optional[str],
phone_fields: Optional[List[str]] = None,
) -> Dict[str, Any]:
norm = normalize_iran_phone(raw_number)
candidates = norm["candidates"]
if not candidates:
return {"person": None, "lead": None, "persons": [], "leads": [], "is_anonymous": True, "normalized": None}
# aliases
alias = (
db.query(TelephonyNumberAlias)
.filter(
TelephonyNumberAlias.business_id == business_id,
TelephonyNumberAlias.number_normalized.in_(candidates),
)
.first()
)
if alias:
person = db.get(Person, alias.person_id) if alias.person_id else None
lead = db.get(Lead, alias.lead_id) if alias.lead_id else None
return {
"person": _person_brief(person) if person and person.business_id == business_id else None,
"lead": _lead_brief(lead) if lead and lead.business_id == business_id else None,
"persons": [],
"leads": [],
"is_anonymous": person is None and lead is None,
"normalized": norm["canonical"],
"match_method": "alias",
}
fields = phone_fields or DEFAULT_PHONE_FIELDS
ors = []
for field in fields:
col = getattr(Person, field, None)
if col is not None:
ors.append(col.in_(candidates))
persons: List[Person] = []
if ors:
persons = (
db.query(Person)
.filter(Person.business_id == business_id, or_(*ors))
.limit(10)
.all()
)
# de-dupe keeping order
seen = set()
uniq_persons = []
for p in persons:
if p.id not in seen:
seen.add(p.id)
uniq_persons.append(p)
# soft suffix match فقط اگر exact چیزی پیدا نشد (حداقل ۱۰ رقم)
if not uniq_persons:
suffixes = [extract_digits(c)[-10:] for c in candidates if len(extract_digits(c)) >= 10]
suffixes = list({s for s in suffixes if len(s) == 10})
soft_ors = []
for field in fields:
col = getattr(Person, field, None)
if col is None:
continue
for s in suffixes:
soft_ors.append(col.like(f"%{s}"))
if soft_ors:
soft_persons = (
db.query(Person)
.filter(Person.business_id == business_id, or_(*soft_ors))
.limit(10)
.all()
)
for p in soft_persons:
# تأیید نهایی با numbers_match
vals = [getattr(p, f, None) for f in fields]
if any(numbers_match(raw_number, v) for v in vals if v):
if p.id not in seen:
seen.add(p.id)
uniq_persons.append(p)
leads = (
db.query(Lead)
.filter(Lead.business_id == business_id, Lead.mobile.in_(candidates))
.limit(10)
.all()
)
if not leads:
suffixes = [extract_digits(c)[-10:] for c in candidates if len(extract_digits(c)) >= 10]
soft = []
for s in {x for x in suffixes if len(x) == 10}:
soft.append(Lead.mobile.like(f"%{s}"))
if soft:
cand = db.query(Lead).filter(Lead.business_id == business_id, or_(*soft)).limit(10).all()
leads = [l for l in cand if numbers_match(raw_number, l.mobile)]
is_anon = not uniq_persons and not leads
return {
"person": _person_brief(uniq_persons[0]) if len(uniq_persons) == 1 else None,
"lead": _lead_brief(leads[0]) if len(leads) == 1 and not uniq_persons else None,
"persons": [_person_brief(p) for p in uniq_persons],
"leads": [_lead_brief(l) for l in leads],
"is_anonymous": is_anon,
"normalized": norm["canonical"],
"match_method": "auto" if not is_anon else None,
"ambiguous": len(uniq_persons) > 1 or (len(uniq_persons) == 0 and len(leads) > 1),
}
def _person_brief(p: Optional[Person]) -> Optional[Dict[str, Any]]:
if not p:
return None
name = (p.alias_name or "").strip()
if not name:
name = f"{p.first_name or ''} {p.last_name or ''}".strip() or p.company_name or f"#{p.id}"
return {
"id": p.id,
"name": name,
"company_name": p.company_name,
"mobile": p.mobile,
"phone": p.phone,
"mobile_2": p.mobile_2,
"mobile_3": p.mobile_3,
}
def _lead_brief(l: Optional[Lead]) -> Optional[Dict[str, Any]]:
if not l:
return None
return {
"id": l.id,
"name": l.name,
"company_name": l.company_name,
"mobile": l.mobile,
"code": l.code,
}
# ─── Calls ───────────────────────────────────────────────────────────────────
def call_to_dict(row: TelephonyCall) -> Dict[str, Any]:
return {
"id": row.id,
"business_id": row.business_id,
"pbx_id": row.pbx_id,
"asterisk_uniqueid": row.asterisk_uniqueid,
"asterisk_linkedid": row.asterisk_linkedid,
"direction": row.direction,
"status": row.status,
"from_number_raw": row.from_number_raw,
"from_number_normalized": row.from_number_normalized,
"to_number_raw": row.to_number_raw,
"to_number_normalized": row.to_number_normalized,
"extension": row.extension,
"queue_code": row.queue_code,
"transferred_from": row.transferred_from,
"transferred_to": row.transferred_to,
"started_at": _iso(row.started_at),
"answered_at": _iso(row.answered_at),
"ended_at": _iso(row.ended_at),
"duration_sec": row.duration_sec,
"talk_sec": row.talk_sec,
"hangup_cause": row.hangup_cause,
"recording_status": row.recording_status,
"recording_url": row.recording_url,
"person_id": row.person_id,
"lead_id": row.lead_id,
"deal_id": row.deal_id,
"document_id": row.document_id,
"crm_activity_id": row.crm_activity_id,
"assigned_user_id": row.assigned_user_id,
"category": row.category,
"outcome": row.outcome,
"note": row.note,
"match_method": row.match_method,
"is_anonymous": bool(row.is_anonymous),
"extra_info": row.extra_info or {},
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
def list_calls(
db: Session,
business_id: int,
*,
direction: Optional[str] = None,
status: Optional[str] = None,
assigned_user_id: Optional[int] = None,
person_id: Optional[int] = None,
q: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> Dict[str, Any]:
query = db.query(TelephonyCall).filter(TelephonyCall.business_id == business_id)
if direction:
query = query.filter(TelephonyCall.direction == direction)
if status:
query = query.filter(TelephonyCall.status == status)
if assigned_user_id:
query = query.filter(TelephonyCall.assigned_user_id == assigned_user_id)
if person_id:
query = query.filter(TelephonyCall.person_id == person_id)
if q:
like = f"%{q.strip()}%"
query = query.filter(
or_(
TelephonyCall.from_number_raw.ilike(like),
TelephonyCall.from_number_normalized.ilike(like),
TelephonyCall.to_number_raw.ilike(like),
TelephonyCall.to_number_normalized.ilike(like),
TelephonyCall.extension.ilike(like),
TelephonyCall.note.ilike(like),
)
)
total = query.count()
rows = query.order_by(TelephonyCall.started_at.desc()).offset(offset).limit(min(limit, 200)).all()
return {"items": [call_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
def get_call(db: Session, business_id: int, call_id: int) -> Dict[str, Any]:
row = (
db.query(TelephonyCall)
.filter(TelephonyCall.id == call_id, TelephonyCall.business_id == business_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "تماس یافت نشد.", http_status=404)
data = call_to_dict(row)
events = (
db.query(TelephonyCallEvent)
.filter(TelephonyCallEvent.call_id == call_id)
.order_by(TelephonyCallEvent.occurred_at.asc())
.all()
)
data["events"] = [
{
"id": e.id,
"event_id": e.event_id,
"event_type": e.event_type,
"payload": e.payload,
"occurred_at": _iso(e.occurred_at),
}
for e in events
]
return data
def patch_call(db: Session, business_id: int, call_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
row = (
db.query(TelephonyCall)
.filter(TelephonyCall.id == call_id, TelephonyCall.business_id == business_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "تماس یافت نشد.", http_status=404)
for key in (
"note",
"category",
"outcome",
"person_id",
"lead_id",
"deal_id",
"document_id",
"match_method",
):
if key in payload:
setattr(row, key, payload[key])
if payload.get("person_id"):
row.is_anonymous = False
row.updated_at = _now()
db.commit()
db.refresh(row)
return call_to_dict(row)
def _users_for_extension(db: Session, business_id: int, extension: Optional[str]) -> List[TelephonyUserExtension]:
if not extension:
return []
return (
db.query(TelephonyUserExtension)
.options(joinedload(TelephonyUserExtension.extension_row))
.join(TelephonyExtension, TelephonyExtension.id == TelephonyUserExtension.extension_id)
.filter(
TelephonyUserExtension.business_id == business_id,
TelephonyExtension.extension == extension,
TelephonyUserExtension.receive_screen_pop == True, # noqa: E712
)
.all()
)
async def _notify_users(user_ids: List[int], message: Dict[str, Any]) -> None:
for uid in set(user_ids):
try:
await realtime_manager.send_to_user(uid, message)
except Exception:
pass
def _notify_users_sync(user_ids: List[int], message: Dict[str, Any]) -> None:
import anyio
try:
anyio.from_thread.run(_notify_users, user_ids, message)
except Exception:
try:
import asyncio
asyncio.get_event_loop().create_task(_notify_users(user_ids, message))
except Exception:
pass
def _notify_softphone_incoming_sync(
*,
pbx_id: int,
extension: str,
call_id: int,
from_number: Optional[str] = None,
channel: Optional[str] = None,
) -> None:
import anyio
async def _run() -> None:
from app.services.telephony.media_hub import media_hub
await media_hub.notify_softphone_incoming(
pbx_id=pbx_id,
extension=extension,
call_id=call_id,
from_number=from_number,
channel=channel,
)
try:
anyio.from_thread.run(_run)
except Exception:
try:
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.run_coroutine_threadsafe(_run(), loop)
else:
loop.run_until_complete(_run())
except Exception:
pass
def process_connector_event(
db: Session,
pbx: TelephonyPbxConnection,
payload: Dict[str, Any],
) -> Dict[str, Any]:
event_id = str(payload.get("event_id") or secrets.token_hex(16))
existing = (
db.query(TelephonyCallEvent)
.filter(TelephonyCallEvent.pbx_id == pbx.id, TelephonyCallEvent.event_id == event_id)
.first()
)
if existing:
return {"ok": True, "duplicate": True, "call_id": existing.call_id}
event_type = str(payload.get("type") or payload.get("event_type") or "").strip()
uniqueid = str(payload.get("uniqueid") or "").strip()
if not event_type or not uniqueid:
raise ApiError("VALIDATION_ERROR", "type و uniqueid الزامی است.", http_status=400)
occurred_at = _now()
raw_at = payload.get("pbx_event_at")
if raw_at:
try:
occurred_at = datetime.fromisoformat(str(raw_at).replace("Z", "+00:00")).replace(tzinfo=None)
except Exception:
pass
call = (
db.query(TelephonyCall)
.filter(TelephonyCall.pbx_id == pbx.id, TelephonyCall.asterisk_uniqueid == uniqueid)
.first()
)
settings = get_or_create_settings(db, pbx.business_id)
direction = str(payload.get("direction") or (call.direction if call else "inbound")).lower()
extension = str(payload.get("extension") or (call.extension if call else "") or "").strip() or None
from_raw = payload.get("from") or payload.get("from_number") or (call.from_number_raw if call else None)
to_raw = payload.get("to") or payload.get("to_number") or (call.to_number_raw if call else None)
from_norm = normalize_iran_phone(from_raw)["canonical"]
to_norm = normalize_iran_phone(to_raw)["canonical"]
created = False
if not call:
match = match_caller(
db,
pbx.business_id,
from_raw if direction == "inbound" else to_raw,
phone_fields=settings.primary_phone_fields or DEFAULT_PHONE_FIELDS,
)
assigned_user_id = None
links = _users_for_extension(db, pbx.business_id, extension)
if links:
assigned_user_id = links[0].user_id
call = TelephonyCall(
business_id=pbx.business_id,
pbx_id=pbx.id,
asterisk_uniqueid=uniqueid,
asterisk_linkedid=payload.get("linkedid"),
direction=direction if direction in ("inbound", "outbound", "internal") else "inbound",
status="ringing",
from_number_raw=str(from_raw) if from_raw else None,
from_number_normalized=from_norm,
to_number_raw=str(to_raw) if to_raw else None,
to_number_normalized=to_norm,
extension=extension,
queue_code=payload.get("queue"),
started_at=occurred_at,
person_id=(match.get("person") or {}).get("id") if match.get("person") else None,
lead_id=(match.get("lead") or {}).get("id") if match.get("lead") else None,
assigned_user_id=assigned_user_id,
match_method=match.get("match_method"),
is_anonymous=bool(match.get("is_anonymous")),
extra_info={"match": match},
created_at=_now(),
updated_at=_now(),
)
db.add(call)
db.flush()
created = True
else:
if from_raw:
call.from_number_raw = str(from_raw)
call.from_number_normalized = from_norm
if to_raw:
call.to_number_raw = str(to_raw)
call.to_number_normalized = to_norm
if extension:
call.extension = extension
if payload.get("queue"):
call.queue_code = payload.get("queue")
if payload.get("linkedid"):
call.asterisk_linkedid = payload.get("linkedid")
# state transitions
notify_type = None
if event_type in ("call.ringing", "ringing"):
call.status = "ringing"
notify_type = "telephony.incoming_call" if call.direction == "inbound" else "telephony.outbound_ringing"
elif event_type in ("call.answered", "answered"):
call.status = "answered"
call.answered_at = call.answered_at or occurred_at
notify_type = "telephony.call_answered"
elif event_type in ("call.missed", "missed"):
call.status = "missed"
call.ended_at = occurred_at
notify_type = "telephony.call_missed"
elif event_type in ("call.busy", "busy"):
call.status = "busy"
call.ended_at = occurred_at
notify_type = "telephony.call_ended"
elif event_type in ("call.failed", "failed"):
call.status = "failed"
call.ended_at = occurred_at
notify_type = "telephony.call_ended"
elif event_type in ("call.ended", "ended", "hangup"):
if call.status == "answered":
call.status = "completed"
elif call.status == "ringing":
call.status = "missed"
notify_type = "telephony.call_missed"
call.ended_at = occurred_at
if call.started_at:
call.duration_sec = max(0, int((occurred_at - call.started_at).total_seconds()))
if call.answered_at:
call.talk_sec = max(0, int((occurred_at - call.answered_at).total_seconds()))
call.hangup_cause = payload.get("hangup_cause") or call.hangup_cause
notify_type = notify_type or "telephony.call_ended"
elif event_type in ("recording.ready",):
call.recording_status = "available"
call.recording_remote_path = payload.get("recording_path") or call.recording_remote_path
call.recording_url = payload.get("recording_url") or call.recording_url
call.updated_at = _now()
ev = TelephonyCallEvent(
business_id=pbx.business_id,
pbx_id=pbx.id,
call_id=call.id,
event_id=event_id,
event_type=event_type,
payload=payload,
occurred_at=occurred_at,
created_at=_now(),
)
db.add(ev)
# auto CRM activity on terminal states
if settings.auto_create_activity and call.status in ("completed", "missed", "busy", "failed") and not call.crm_activity_id:
try:
_ensure_crm_activity(db, call, user_id=call.assigned_user_id)
except Exception:
pass
# missed call follow-up task
if call.status == "missed" and settings.missed_call_create_task:
try:
_ensure_missed_call_task(db, call, settings)
except Exception:
pass
# presence updates
try:
from app.services.telephony.live_service import update_presence_from_event
if call.status == "ringing":
update_presence_from_event(db, pbx.business_id, call.extension, "ringing")
elif call.status == "answered":
update_presence_from_event(db, pbx.business_id, call.extension, "busy")
elif call.status in ("completed", "missed", "busy", "failed", "cancelled"):
update_presence_from_event(db, pbx.business_id, call.extension, "idle")
except Exception:
pass
try:
from app.services.telephony.recording_service import bump_metric, apply_recording_meta
bump_metric(db, pbx.business_id, f"events_{event_type.replace('.', '_')}")
bump_metric(db, pbx.business_id, "events_total")
if event_type in ("recording.ready",):
apply_recording_meta(db, call, payload)
except Exception:
pass
# store channel for later control
if payload.get("channel"):
extra = dict(call.extra_info or {})
extra["channel"] = payload.get("channel")
call.extra_info = extra
db.commit()
db.refresh(call)
# Softphone Relay: زنگ با call_id به کلاینت آنلاین همان داخلی
if (
payload.get("softphone_relay")
and event_type in ("call.ringing", "ringing")
and call.extension
and call.status == "ringing"
):
_notify_softphone_incoming_sync(
pbx_id=pbx.id,
extension=str(call.extension),
call_id=int(call.id),
from_number=call.from_number_normalized or call.from_number_raw,
channel=str(payload.get("channel") or "") or None,
)
# realtime
links = _users_for_extension(db, pbx.business_id, call.extension)
user_ids = [lnk.user_id for lnk in links if lnk.receive_screen_pop]
if call.assigned_user_id and call.assigned_user_id not in user_ids:
user_ids.append(call.assigned_user_id)
if notify_type and user_ids and (settings.screen_pop_enabled or notify_type != "telephony.incoming_call"):
person_brief = None
if call.person_id:
person_brief = _person_brief(db.get(Person, call.person_id))
msg = {
"type": notify_type,
"business_id": pbx.business_id,
"call_id": call.id,
"extension": call.extension,
"direction": call.direction,
"status": call.status,
"from_number": call.from_number_normalized or call.from_number_raw,
"to_number": call.to_number_normalized or call.to_number_raw,
"person": person_brief,
"is_anonymous": call.is_anonymous,
"deep_link": f"/business/{pbx.business_id}/telephony/calls/{call.id}",
}
_notify_users_sync(user_ids, msg)
return {"ok": True, "created": created, "call": call_to_dict(call)}
def _ensure_crm_activity(db: Session, call: TelephonyCall, user_id: Optional[int]) -> None:
uid = user_id or call.assigned_user_id
if not uid:
# fallback: business owner not resolved here — skip
return
code = f"TEL-{call.id}"
existing = (
db.query(CrmActivity)
.filter(CrmActivity.business_id == call.business_id, CrmActivity.code == code)
.first()
)
if existing:
call.crm_activity_id = existing.id
return
direction_fa = {"inbound": "ورودی", "outbound": "خروجی", "internal": "داخلی"}.get(call.direction, call.direction)
subject = f"تماس {direction_fa} {call.from_number_normalized or call.from_number_raw or ''}".strip()
act = CrmActivity(
business_id=call.business_id,
person_id=call.person_id,
lead_id=call.lead_id,
deal_id=call.deal_id,
code=code,
activity_type="call",
subject=subject[:255],
description=call.note,
activity_date=call.started_at or _now(),
created_by_user_id=uid,
assigned_to_user_id=uid,
status="done" if call.status in ("completed", "missed", "busy", "failed") else "open",
completed_at=call.ended_at,
outcome=call.outcome or call.status,
extra_info={"telephony_call_id": call.id, "direction": call.direction, "duration_sec": call.duration_sec},
telephony_call_id=call.id,
created_at=_now(),
updated_at=_now(),
)
db.add(act)
db.flush()
call.crm_activity_id = act.id
def _ensure_missed_call_task(db: Session, call: TelephonyCall, settings: TelephonySettings) -> None:
uid = call.assigned_user_id
if not uid:
return
code = f"TEL-MISS-{call.id}"
existing = (
db.query(CrmActivity)
.filter(CrmActivity.business_id == call.business_id, CrmActivity.code == code)
.first()
)
if existing:
return
due_min = int(settings.missed_call_task_due_minutes or 60)
due_at = (call.ended_at or _now()) + timedelta(minutes=due_min)
number = call.from_number_normalized or call.from_number_raw or ""
act = CrmActivity(
business_id=call.business_id,
person_id=call.person_id,
lead_id=call.lead_id,
code=code,
activity_type="call",
subject=f"پیگیری تماس از دست‌رفته {number}"[:255],
description="ایجاد خودکار پس از تماس از دست‌رفته",
activity_date=_now(),
created_by_user_id=uid,
assigned_to_user_id=uid,
is_task=True,
due_at=due_at,
status="open",
priority="high",
extra_info={"telephony_call_id": call.id, "auto_missed_followup": True},
telephony_call_id=call.id,
created_at=_now(),
updated_at=_now(),
)
db.add(act)
# ─── Screen pop context ─────────────────────────────────────────────────────
def screen_pop_context(db: Session, business_id: int, call_id: int) -> Dict[str, Any]:
from app.services.person_service import calculate_person_balance
from adapters.db.models.check import Check, CheckStatus
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)
match = (call.extra_info or {}).get("match") or match_caller(
db, business_id, call.from_number_raw if call.direction == "inbound" else call.to_number_raw
)
person_id = call.person_id or (match.get("person") or {}).get("id")
balance = None
recent_docs: List[Dict[str, Any]] = []
overdue_checks: List[Dict[str, Any]] = []
open_deals: List[Dict[str, Any]] = []
person = None
if person_id:
person = db.get(Person, person_id)
if person and person.business_id == business_id:
try:
amount, status = calculate_person_balance(db, person_id)
balance = {"amount": amount, "status": status}
except Exception:
balance = None
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
docs = (
db.query(Document)
.join(DocumentLine, DocumentLine.document_id == Document.id)
.filter(Document.business_id == business_id, DocumentLine.person_id == person_id)
.order_by(Document.document_date.desc())
.limit(5)
.all()
)
recent_docs = [
{
"id": d.id,
"code": d.code,
"document_type": d.document_type,
"document_date": _iso(d.document_date) if hasattr(d.document_date, "isoformat") else str(d.document_date),
"description": d.description,
}
for d in docs
]
now = _now()
checks = (
db.query(Check)
.filter(
Check.business_id == business_id,
Check.person_id == person_id,
Check.due_date <= now,
Check.status.in_(
[
CheckStatus.RECEIVED_ON_HAND,
CheckStatus.DEPOSITED,
CheckStatus.BOUNCED,
CheckStatus.RETURNED,
]
),
)
.order_by(Check.due_date.asc())
.limit(5)
.all()
)
overdue_checks = [
{
"id": ch.id,
"check_number": ch.check_number,
"amount": float(ch.amount or 0),
"due_date": _iso(ch.due_date),
"status": ch.status.value if ch.status else None,
}
for ch in checks
]
from adapters.db.models.crm import Deal
deals = (
db.query(Deal)
.filter(Deal.business_id == business_id, Deal.person_id == person_id, Deal.closed_at.is_(None))
.order_by(Deal.created_at.desc())
.limit(5)
.all()
)
open_deals = [
{"id": d.id, "title": d.title, "code": d.code, "amount": float(d.amount or 0)}
for d in deals
]
return {
"call": call_to_dict(call),
"person": _person_brief(person) if person else match.get("person"),
"lead": match.get("lead"),
"match": match,
"balance": balance,
"recent_documents": recent_docs,
"overdue_checks": overdue_checks,
"open_deals": open_deals,
"settings": {
"post_call_form_required": get_or_create_settings(db, business_id).post_call_form_required,
},
}
# ─── Click to call ───────────────────────────────────────────────────────────
def click_to_call(
db: Session,
business_id: int,
user_id: int,
payload: Dict[str, Any],
) -> Dict[str, Any]:
destination = str(payload.get("destination") or payload.get("number") or "").strip()
if not destination:
raise ApiError("VALIDATION_ERROR", "شماره مقصد الزامی است.", http_status=400)
norm = normalize_iran_phone(destination)
dest_canonical = norm["canonical"] or extract_digits(destination)
if not dest_canonical:
raise ApiError("VALIDATION_ERROR", "شماره مقصد نامعتبر است.", http_status=400)
links = (
db.query(TelephonyUserExtension)
.options(joinedload(TelephonyUserExtension.extension_row))
.filter(
TelephonyUserExtension.business_id == business_id,
TelephonyUserExtension.user_id == user_id,
TelephonyUserExtension.can_click_to_call == True, # noqa: E712
)
.all()
)
if not links:
raise ApiError("NO_EXTENSION", "برای شما داخلی Click-to-Call تعریف نشده است.", http_status=400)
link = None
wanted_ext_id = payload.get("extension_id")
if wanted_ext_id:
link = next((x for x in links if x.extension_id == int(wanted_ext_id)), None)
if not link:
link = next((x for x in links if x.is_primary), links[0])
ext = link.extension_row
pbx = db.get(TelephonyPbxConnection, link.pbx_id)
if not pbx or not pbx.is_active:
raise ApiError("PBX_OFFLINE", "مرکز تلفن فعال نیست.", http_status=400)
settings_pbx = pbx.settings or {}
command_id = str(uuid.uuid4())
cmd_payload = {
"extension": ext.extension,
"destination": dest_canonical,
"caller_id": link.caller_id_override or settings_pbx.get("default_caller_id"),
"timeout_ms": int(settings_pbx.get("originate_timeout_ms") or 30000),
"context": settings_pbx.get("originate_context") or "from-internal",
}
uniqueid = f"out-{command_id}"
match = match_caller(db, business_id, dest_canonical)
call = TelephonyCall(
business_id=business_id,
pbx_id=pbx.id,
asterisk_uniqueid=uniqueid,
direction="outbound",
status="ringing",
from_number_raw=ext.extension,
from_number_normalized=ext.extension,
to_number_raw=destination,
to_number_normalized=dest_canonical,
extension=ext.extension,
started_at=_now(),
person_id=payload.get("person_id") or (match.get("person") or {}).get("id"),
lead_id=payload.get("lead_id") or (match.get("lead") or {}).get("id"),
assigned_user_id=user_id,
match_method=match.get("match_method") or ("manual" if payload.get("person_id") else None),
is_anonymous=bool(match.get("is_anonymous")) and not payload.get("person_id"),
extra_info={"originate_command_id": command_id, "match": match},
created_at=_now(),
updated_at=_now(),
)
db.add(call)
db.flush()
cmd = TelephonyCommand(
business_id=business_id,
pbx_id=pbx.id,
command_id=command_id,
command_type="originate",
payload=cmd_payload,
status="pending",
created_by_user_id=user_id,
call_id=call.id,
created_at=_now(),
expires_at=_now() + timedelta(minutes=2),
)
db.add(cmd)
db.commit()
db.refresh(call)
return {
"ok": True,
"command_id": command_id,
"call": call_to_dict(call),
"message": "دستور شماره‌گیری برای Connector ارسال شد.",
}
def poll_commands(db: Session, pbx: TelephonyPbxConnection, limit: int = 10) -> List[Dict[str, Any]]:
now = _now()
rows = (
db.query(TelephonyCommand)
.filter(
TelephonyCommand.pbx_id == pbx.id,
TelephonyCommand.status == "pending",
or_(TelephonyCommand.expires_at.is_(None), TelephonyCommand.expires_at > now),
)
.order_by(TelephonyCommand.created_at.asc())
.limit(min(limit, 50))
.all()
)
out = []
for row in rows:
row.status = "sent"
out.append(
{
"command_id": row.command_id,
"type": row.command_type,
**row.payload,
"call_id": row.call_id,
}
)
db.commit()
return out
def ack_command(db: Session, pbx: TelephonyPbxConnection, command_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
row = (
db.query(TelephonyCommand)
.filter(TelephonyCommand.pbx_id == pbx.id, TelephonyCommand.command_id == command_id)
.first()
)
if not row:
raise ApiError("NOT_FOUND", "دستور یافت نشد.", http_status=404)
ok = bool(payload.get("accepted", True))
row.status = "acked" if ok else "failed"
row.result_payload = payload
row.acked_at = _now()
db.commit()
return {"ok": True, "command_id": command_id, "status": row.status}
# ─── Create person / lead from call ──────────────────────────────────────────
def create_person_from_call(
db: Session, business_id: int, call_id: int, payload: Dict[str, Any], user_id: int
) -> Dict[str, Any]:
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)
number = call.from_number_normalized if call.direction == "inbound" else call.to_number_normalized
number = number or call.from_number_raw or call.to_number_raw
digits = extract_digits(number or "")
mobile_val = None
phone_val = payload.get("phone")
if len(digits) >= 10 and digits[-10:].startswith("9"):
mobile_val = "0" + digits[-10:]
elif number:
phone_val = phone_val or number
# کد بعدی شخص
max_code = (
db.query(func.max(Person.code)).filter(Person.business_id == business_id).scalar()
)
next_code = int(max_code or 0) + 1
person = Person(
business_id=business_id,
code=next_code,
alias_name=(payload.get("alias_name") or payload.get("name") or number or "مخاطب تلفنی").strip(),
first_name=payload.get("first_name"),
last_name=payload.get("last_name"),
company_name=payload.get("company_name"),
mobile=mobile_val or payload.get("mobile"),
phone=phone_val,
person_types=json.dumps(["مشتری"], ensure_ascii=False),
created_at=_now(),
updated_at=_now(),
)
db.add(person)
db.flush()
call.person_id = person.id
call.is_anonymous = False
call.match_method = "created_person"
call.updated_at = _now()
db.commit()
db.refresh(call)
return {"call": call_to_dict(call), "person": _person_brief(person)}
def create_lead_from_call(
db: Session, business_id: int, call_id: int, payload: Dict[str, Any], user_id: int
) -> Dict[str, Any]:
from adapters.db.models.crm import CrmProcessDefinition, CrmProcessStage
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)
process = (
db.query(CrmProcessDefinition)
.filter(
CrmProcessDefinition.business_id == business_id,
CrmProcessDefinition.process_type == "lead_funnel",
CrmProcessDefinition.is_active == True, # noqa: E712
)
.order_by(CrmProcessDefinition.is_default.desc(), CrmProcessDefinition.id.asc())
.first()
)
if not process:
raise ApiError("NO_LEAD_FUNNEL", "فانل سرنخ برای این کسب‌وکار تعریف نشده است.", http_status=400)
stage = (
db.query(CrmProcessStage)
.filter(CrmProcessStage.process_definition_id == process.id)
.order_by(CrmProcessStage.order_index.asc())
.first()
)
if not stage:
raise ApiError("NO_LEAD_STAGE", "مرحله سرنخ یافت نشد.", http_status=400)
number = call.from_number_normalized if call.direction == "inbound" else call.to_number_normalized
number = number or call.from_number_raw or call.to_number_raw
code = f"TEL-L-{call.id}-{secrets.token_hex(2)}"
lead = Lead(
business_id=business_id,
process_definition_id=process.id,
stage_id=stage.id,
code=code,
name=(payload.get("name") or number or "سرنخ تلفنی").strip(),
company_name=payload.get("company_name"),
mobile=number,
source_code=payload.get("source_code") or "telephony",
description=payload.get("description") or call.note,
assigned_to_user_id=payload.get("assigned_to_user_id") or user_id,
created_by_user_id=user_id,
created_at=_now(),
updated_at=_now(),
)
db.add(lead)
db.flush()
call.lead_id = lead.id
call.is_anonymous = False
call.match_method = "created_lead"
call.updated_at = _now()
db.commit()
db.refresh(call)
return {"call": call_to_dict(call), "lead": _lead_brief(lead)}
def dashboard_summary(db: Session, business_id: int, user_id: Optional[int] = None) -> Dict[str, Any]:
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
base = db.query(TelephonyCall).filter(
TelephonyCall.business_id == business_id, TelephonyCall.started_at >= today_start
)
inbound = base.filter(TelephonyCall.direction == "inbound").count()
outbound = base.filter(TelephonyCall.direction == "outbound").count()
answered = base.filter(TelephonyCall.status.in_(["answered", "completed"])).count()
missed = base.filter(TelephonyCall.status == "missed").count()
active = (
db.query(TelephonyCall)
.filter(
TelephonyCall.business_id == business_id,
TelephonyCall.status.in_(["ringing", "answered"]),
TelephonyCall.ended_at.is_(None),
)
.count()
)
pbx_rows = list_pbx(db, business_id)
my_missed = 0
if user_id:
my_missed = (
base.filter(TelephonyCall.assigned_user_id == user_id, TelephonyCall.status == "missed").count()
)
return {
"today": {
"inbound": inbound,
"outbound": outbound,
"answered": answered,
"missed": missed,
"my_missed": my_missed,
},
"active_calls": active,
"pbx_connections": pbx_rows,
}