- Клиент Novofon DATA API (JSON-RPC, get.calls_report, чанки по 90 дней) - faster-whisper (модель turbo) на CUDA с разделением стереоканалов - Маркировка спикеров: левый канал = caller, правый = callee - Directus: коллекции calls, calls_74951284933, contacts - CLI: process, process-file, export, daemon (polling 5 мин) - Docker Compose для Directus + PostgreSQL - Архив email-парсинга в old/
185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
import datetime
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
|
|
@dataclass
|
|
class CallMetadata:
|
|
call_id: Optional[str] = None
|
|
caller_number: Optional[str] = None
|
|
callee_number: Optional[str] = None
|
|
employee: Optional[str] = None
|
|
direction: Optional[str] = None
|
|
duration_sec: Optional[int] = None
|
|
datetime: Optional[str] = None
|
|
audio_url: Optional[str] = None
|
|
|
|
|
|
class NovofonApiClient:
|
|
def __init__(self, access_token: str = None, base_url: str = None):
|
|
from config import config
|
|
|
|
self.access_token = access_token or config.NOVOFON_ACCESS_TOKEN
|
|
self.base_url = (base_url or config.NOVOFON_API_BASE_URL).rstrip("/")
|
|
self._client = httpx.Client(
|
|
headers={"Content-Type": "application/json; charset=UTF-8"},
|
|
timeout=60,
|
|
)
|
|
|
|
def _request(self, method: str, params: dict) -> list:
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
resp = self._client.post(self.base_url, json=payload)
|
|
resp.raise_for_status()
|
|
result = resp.json()
|
|
if "error" in result:
|
|
err = result["error"]
|
|
msg = err.get("message", "")
|
|
mnemonic = err.get("data", {}).get("mnemonic", "")
|
|
raise RuntimeError(f"Novofon API error [{mnemonic}]: {msg}")
|
|
return result.get("result", {}).get("data", [])
|
|
|
|
CALL_FIELDS = [
|
|
"communication_id", "direction", "contact_phone_number",
|
|
"virtual_phone_number", "start_time", "talk_duration",
|
|
"total_duration", "call_records", "wav_call_records",
|
|
"full_record_file_link", "employees",
|
|
]
|
|
|
|
def get_calls_report(
|
|
self,
|
|
date_from: str,
|
|
date_till: str,
|
|
offset: int = 0,
|
|
limit: int = 10000,
|
|
sort: Optional[list] = None,
|
|
) -> list[dict]:
|
|
params = {
|
|
"access_token": self.access_token,
|
|
"date_from": date_from,
|
|
"date_till": date_till,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"fields": self.CALL_FIELDS,
|
|
}
|
|
if sort:
|
|
params["sort"] = sort
|
|
return self._request("get.calls_report", params)
|
|
|
|
def get_all_calls(
|
|
self,
|
|
date_from: str,
|
|
date_till: Optional[str] = None,
|
|
) -> list[CallMetadata]:
|
|
if " " not in date_from:
|
|
date_from = f"{date_from} 00:00:00"
|
|
start = datetime.datetime.strptime(date_from, "%Y-%m-%d %H:%M:%S")
|
|
|
|
if date_till:
|
|
if " " not in date_till:
|
|
date_till = f"{date_till} 23:59:59"
|
|
end = datetime.datetime.strptime(date_till, "%Y-%m-%d %H:%M:%S")
|
|
else:
|
|
end = datetime.datetime.utcnow()
|
|
|
|
# Build chunks from oldest to newest
|
|
chunks: list[tuple[str, str]] = []
|
|
chunk_start = start
|
|
while chunk_start < end:
|
|
chunk_end = min(chunk_start + datetime.timedelta(days=90), end)
|
|
chunks.append((
|
|
chunk_start.strftime("%Y-%m-%d %H:%M:%S"),
|
|
chunk_end.strftime("%Y-%m-%d %H:%M:%S"),
|
|
))
|
|
chunk_start = chunk_end
|
|
|
|
# Iterate chunks newest-first so --limit gets recent calls
|
|
sort_desc = [{"field": "start_time", "order": "desc"}]
|
|
all_calls: list[CallMetadata] = []
|
|
|
|
for from_str, till_str in reversed(chunks):
|
|
offset = 0
|
|
page_size = 10000
|
|
while True:
|
|
batch = self.get_calls_report(
|
|
from_str, till_str,
|
|
offset=offset, limit=page_size,
|
|
sort=sort_desc,
|
|
)
|
|
if not batch:
|
|
break
|
|
for item in batch:
|
|
meta = self._to_metadata(item)
|
|
if meta:
|
|
all_calls.append(meta)
|
|
if len(batch) < page_size:
|
|
break
|
|
offset += page_size
|
|
|
|
return all_calls
|
|
|
|
@staticmethod
|
|
def extract_audio_url(data: dict) -> Optional[str]:
|
|
full = data.get("full_record_file_link")
|
|
if full:
|
|
return full
|
|
comm_id = data.get("communication_id")
|
|
records = data.get("call_records", [])
|
|
if records and comm_id:
|
|
return f"https://app.novofon.ru/system/media/talk/{comm_id}/{records[0]}/"
|
|
wav_records = data.get("wav_call_records", [])
|
|
if wav_records and comm_id:
|
|
return f"https://app.novofon.ru/system/media/wav/{comm_id}/{wav_records[0]}/"
|
|
return None
|
|
|
|
@staticmethod
|
|
def _to_metadata(data: dict) -> Optional[CallMetadata]:
|
|
comm_id = data.get("communication_id")
|
|
if not comm_id:
|
|
return None
|
|
|
|
direction = data.get("direction")
|
|
is_inbound = direction == "in"
|
|
contact = data.get("contact_phone_number", "") or ""
|
|
virtual = data.get("virtual_phone_number", "") or ""
|
|
|
|
if is_inbound:
|
|
caller_number = contact
|
|
callee_number = virtual
|
|
else:
|
|
caller_number = virtual
|
|
callee_number = contact
|
|
|
|
employees = data.get("employees") or []
|
|
employee = employees[0].get("employee_full_name") if employees else None
|
|
|
|
raw_time = data.get("start_time")
|
|
iso_time = None
|
|
if raw_time and " " in raw_time:
|
|
try:
|
|
dt = datetime.datetime.strptime(raw_time, "%Y-%m-%d %H:%M:%S")
|
|
iso_time = dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
except ValueError:
|
|
pass
|
|
|
|
duration = data.get("talk_duration") or data.get("total_duration")
|
|
|
|
return CallMetadata(
|
|
call_id=str(comm_id),
|
|
caller_number=caller_number if caller_number else None,
|
|
callee_number=callee_number if callee_number else None,
|
|
employee=employee,
|
|
direction="inbound" if is_inbound else "outbound",
|
|
duration_sec=duration,
|
|
datetime=iso_time,
|
|
audio_url=NovofonApiClient.extract_audio_url(data),
|
|
)
|
|
|
|
def close(self):
|
|
self._client.close()
|