feat(autocall): основной транк обзвона — GSM-модуль клиента (goip5), Miranda запасной

Канал call-файла строится из настройки autocall_trunk (sms_campaign_settings):
'gsm' — PJSIP/1<номер с 8>@goip5 (линия 1 модуля), 'mmedia' — прежний SIP-транк.
По умолчанию gsm.

У модуля одна SIM → один одновременный звонок: перед постановкой в спул сверяем
занятость каналов транка на АТС и ставим не больше, чем есть свободных линий.
Не успевшие звонки просто ждут следующего тика таймера — иначе они падали бы
«занято» и уезжали в «не ответил». То же для ручных звонков из очереди.
This commit is contained in:
Hermes 2026-09-25 16:16:43 +02:00
parent 004050c0ff
commit 462c2b4892
1 changed files with 100 additions and 1 deletions

View File

@ -37,6 +37,17 @@ ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro")
ATS_SPOOL = "/var/spool/asterisk/outgoing" ATS_SPOOL = "/var/spool/asterisk/outgoing"
CALLER_ID = "2565551100" CALLER_ID = "2565551100"
# --- Транк обзвона -----------------------------------------------------------
# 'gsm' — GSM-модуль клиента (DBL GoIP-4 на LAN АТС, эндпоинт goip5), основной;
# 'mmedia' — SIP-транк Miranda (прежний путь).
# Переключается настройкой autocall_trunk в sms_campaign_settings, без правки кода.
AUTOCALL_TRUNK_DEFAULT = "gsm"
GSM_ENDPOINT = os.environ.get("AUTOCALL_GSM_ENDPOINT", "goip5")
# Префикс линии модуля в single-server mode: линия 1 → "1" (SIM стоит только в ней).
GSM_LINE_PREFIX = os.environ.get("AUTOCALL_GSM_PREFIX", "1")
# Сколько одновременных звонков держит модуль: одна SIM = один звонок.
GSM_TRUNK_LINES_DEFAULT = 1
# Edge function для приёма результата # Edge function для приёма результата
API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result") API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result")
API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2") API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2")
@ -415,6 +426,63 @@ def resolve_call_type(group):
return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv" return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv"
def trunk_name(settings):
"""Транк обзвона. Настройка autocall_trunk из sms_campaign_settings (админка).
Пустое/неизвестное значение трактуем как основной транк — GSM-модуль."""
return (settings.get("autocall_trunk") or AUTOCALL_TRUNK_DEFAULT).strip().lower()
def trunk_channel(settings, dial):
"""Канал для call-файла.
dial — «0» + 10 цифр номера (формат SIP-транка Miranda).
GoIP в single-server mode ждёт <префикс линии><номер в формате 8XXXXXXXXXX>:
для линии 1 и 10-значного dial это «18» + dial[1:] (тот же формат, что в
рабочем диалплане клиента: Goto(GSM-Out,1${EXTEN},1) для _89XXXXXXXXX).
"""
if trunk_name(settings) == "gsm":
return f"PJSIP/{GSM_LINE_PREFIX}8{dial[1:]}@{GSM_ENDPOINT}"
return f"PJSIP/{dial}@mmedia"
def trunk_capacity(settings):
"""Сколько одновременных звонков держит транк. None — не ограничено (SIP-транк)."""
if trunk_name(settings) != "gsm":
return None
try:
return max(1, int(settings.get("autocall_trunk_lines") or GSM_TRUNK_LINES_DEFAULT))
except (TypeError, ValueError):
return GSM_TRUNK_LINES_DEFAULT
def trunk_active_calls(settings):
"""Сколько каналов транка занято на АТС прямо сейчас (и исходящие, и входящие)."""
pattern = GSM_ENDPOINT if trunk_name(settings) == "gsm" else "mmedia"
cmd = [
"sshpass", "-p", ATS_PASS,
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=10", "-q",
f"{ATS_USER}@{ATS_HOST}",
f'asterisk -rx "core show channels concise" | grep -c "PJSIP/{pattern}-" || true',
]
try:
proc = subprocess.run(cmd, capture_output=True, timeout=25)
lines = [l for l in proc.stdout.decode().splitlines() if l.strip()]
return int(lines[-1]) if lines else 0
except Exception as e:
log.warning(f"Не смог узнать занятость транка {pattern}: {e}")
# Не роняем обзвон из-за разовой сетевой ошибки: считаем линию свободной.
return 0
def trunk_free_slots(settings):
"""Сколько звонков можно поставить в спул прямо сейчас. None — без ограничения."""
cap = trunk_capacity(settings)
if cap is None:
return None
return cap - trunk_active_calls(settings)
def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False): def build_call_file(group, settings, attempt_no, is_test_call=False, manual=False):
"""Формирует содержимое call-файла. """Формирует содержимое call-файла.
@ -466,7 +534,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False, manual=Fals
) )
lines = [ lines = [
f"Channel: PJSIP/{dial}@mmedia", f"Channel: {trunk_channel(settings, dial)}",
f"CallerID: {CALLER_ID}", f"CallerID: {CALLER_ID}",
"Context: SS-Autocall-Slot", "Context: SS-Autocall-Slot",
"Extension: s", "Extension: s",
@ -570,6 +638,9 @@ def process_queue(conn, settings):
# Номера попыток для ручных звонков — с учётом уже сделанных автозвонков # Номера попыток для ручных звонков — с учётом уже сделанных автозвонков
attempt_map = {} attempt_map = {}
pushed = 0 pushed = 0
# Ручные звонки идут через тот же транк обзвона: если у модуля занята линия,
# заявку из очереди не выкидываем — она дождётся следующего тика.
free = trunk_free_slots(settings)
for q in queued: for q in queued:
group_id = q["order_group_id"] group_id = q["order_group_id"]
is_test = bool(q.get("is_test_call")) is_test = bool(q.get("is_test_call"))
@ -619,9 +690,17 @@ def process_queue(conn, settings):
# Для теста не подставляем customer_phone_normalized (нет заказа) # Для теста не подставляем customer_phone_normalized (нет заказа)
if is_test: if is_test:
g["customer_phone_normalized"] = None g["customer_phone_normalized"] = None
if free is not None and free <= 0:
log.info(
f"Свободных линий транка {trunk_name(settings)} нет — "
"ручные звонки ждут следующего тика"
)
break
if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True): if push_call_file(g, settings, attempt_no, is_test_call=is_test, manual=True):
mark_queue_processed(conn, q["queue_id"]) mark_queue_processed(conn, q["queue_id"])
pushed += 1 pushed += 1
if free is not None:
free -= 1
else: else:
# Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем — # Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем —
# по правилу владельца заявка живёт, пока окно не откроется или # по правилу владельца заявка живёт, пока окно не откроется или
@ -767,6 +846,18 @@ def main():
groups = get_groups_to_call(conn, settings) groups = get_groups_to_call(conn, settings)
log.info(f"Found {len(groups)} groups to call") log.info(f"Found {len(groups)} groups to call")
# Транк с ограничением по линиям (GSM-модуль: одна SIM = один звонок в момент).
# Больше, чем есть свободных линий, в спул не ставим: иначе часть звонков
# отвалится «занято», AGI не запустится и заявка уедет в «не ответил».
# Не успевшие просто ждут следующего тика таймера.
free = trunk_free_slots(settings)
if free is not None and free <= 0:
log.info(
f"Транк {trunk_name(settings)} занят (лимит {trunk_capacity(settings)} звонков) — "
"переносим звонки на следующий тик"
)
return
pushed = 0 pushed = 0
for group in groups: for group in groups:
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or "" phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
@ -781,8 +872,16 @@ def main():
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}") log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
continue continue
attempt_no = (group.get("attempt_count") or 0) + 1 attempt_no = (group.get("attempt_count") or 0) + 1
if free is not None and free <= 0:
log.info(
f"Свободных линий транка {trunk_name(settings)} нет — "
"остальные звонки на следующий тик"
)
break
if push_call_file(group, settings, attempt_no): if push_call_file(group, settings, attempt_no):
pushed += 1 pushed += 1
if free is not None:
free -= 1
time.sleep(1) # пауза между звонками time.sleep(1) # пауза между звонками
log.info(f"Pushed {pushed} call files") log.info(f"Pushed {pushed} call files")