telephony: автоформат +380, тип линии SIP/АТС и звонок через Asterisk AMI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 14:52:09 +03:00
parent cc62324b69
commit 5d06d4d3ce
42 changed files with 1158 additions and 57 deletions
+106
View File
@@ -0,0 +1,106 @@
"""Бизнес-логика AMI: канал по типу линии, originate, ping."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from core.models import ConnectorConfig
from .ami import AmiClient, AmiError, DEFAULT_PORT
if TYPE_CHECKING:
from apps.employees.models import Employee
EXTEN_RE = re.compile(r'^\d{2,10}$')
CONNECTOR = 'asterisk'
DEFAULT_SIP_CHANNEL = 'PJSIP/{exten}'
DEFAULT_ANALOG_CHANNEL = 'DAHDI/{exten}'
DEFAULT_CONTEXT = 'from-internal'
class TelephonyError(Exception):
"""Ошибка звонка с понятным текстом для пользователя."""
def get_ami_config() -> ConnectorConfig | None:
return ConnectorConfig.get_for(CONNECTOR)
def ami_is_enabled() -> bool:
return get_ami_config() is not None
def sanitize_exten(raw: str) -> str:
digits = re.sub(r'\D', '', raw or '')
if not EXTEN_RE.fullmatch(digits):
raise TelephonyError('Некорректный внутренний номер.')
return digits
def channel_for_employee(employee, extra: dict | None = None) -> str:
extra = extra or {}
exten = sanitize_exten(employee.internal_number)
line = getattr(employee, 'internal_line_type', '') or 'analog'
if line == 'sip':
template = (extra.get('sip_channel') or DEFAULT_SIP_CHANNEL)
else:
template = (extra.get('analog_channel') or DEFAULT_ANALOG_CHANNEL)
return str(template).replace('{exten}', exten)
def _client_from_config(cfg: ConnectorConfig) -> AmiClient:
extra = cfg.extra_config or {}
timeout = extra.get('timeout', 8)
try:
timeout = float(timeout)
except (TypeError, ValueError):
timeout = 8
return AmiClient(
host=cfg.host,
port=cfg.port or DEFAULT_PORT,
username=cfg.username,
secret=cfg.password,
timeout=timeout,
use_ssl=bool(cfg.use_ssl),
)
def ping_ami(cfg: ConnectorConfig | None = None) -> dict:
cfg = cfg or get_ami_config()
if cfg is None:
raise TelephonyError('Коннектор Asterisk AMI выключен.')
if not cfg.host or not cfg.username or not cfg.password:
raise TelephonyError('В AMI не заданы хост, логин или секрет.')
with _client_from_config(cfg) as client:
return client.ping()
def originate_between(caller: Employee, callee: Employee) -> dict:
if caller.pk == callee.pk:
raise TelephonyError('Нельзя позвонить самому себе.')
if not caller.internal_number:
raise TelephonyError('У вас не указан внутренний номер.')
if not callee.internal_number:
raise TelephonyError('У сотрудника нет внутреннего номера.')
cfg = get_ami_config()
if cfg is None:
raise TelephonyError('Коннектор Asterisk AMI выключен.')
extra = cfg.extra_config or {}
context = extra.get('originate_context') or DEFAULT_CONTEXT
channel = channel_for_employee(caller, extra)
dest = sanitize_exten(callee.internal_number)
caller_id = f'{caller.short_name} <{sanitize_exten(caller.internal_number)}>'
try:
with _client_from_config(cfg) as client:
return client.originate(
channel=channel,
exten=dest,
context=context,
caller_id=caller_id,
)
except AmiError as exc:
raise TelephonyError(str(exc)) from exc