From 462c2b4892d1ee9f1e2e0562684a376393faf701 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 25 Sep 2026 16:16:43 +0200 Subject: [PATCH] =?UTF-8?q?feat(autocall):=20=D0=BE=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B9=20=D1=82=D1=80=D0=B0=D0=BD=D0=BA=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD=D0=B0=20=E2=80=94=20GSM-?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20(goip5),=20Miranda=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B0=D1=81=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канал call-файла строится из настройки autocall_trunk (sms_campaign_settings): 'gsm' — PJSIP/1<номер с 8>@goip5 (линия 1 модуля), 'mmedia' — прежний SIP-транк. По умолчанию gsm. У модуля одна SIM → один одновременный звонок: перед постановкой в спул сверяем занятость каналов транка на АТС и ставим не больше, чем есть свободных линий. Не успевшие звонки просто ждут следующего тика таймера — иначе они падали бы «занято» и уезжали в «не ответил». То же для ручных звонков из очереди. --- scripts/autocall_scheduler.py | 101 +++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py index c383373..836bba6 100644 --- a/scripts/autocall_scheduler.py +++ b/scripts/autocall_scheduler.py @@ -37,6 +37,17 @@ ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro") ATS_SPOOL = "/var/spool/asterisk/outgoing" 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 для приёма результата 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") @@ -415,6 +426,63 @@ def resolve_call_type(group): 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): """Формирует содержимое call-файла. @@ -466,7 +534,7 @@ def build_call_file(group, settings, attempt_no, is_test_call=False, manual=Fals ) lines = [ - f"Channel: PJSIP/{dial}@mmedia", + f"Channel: {trunk_channel(settings, dial)}", f"CallerID: {CALLER_ID}", "Context: SS-Autocall-Slot", "Extension: s", @@ -570,6 +638,9 @@ def process_queue(conn, settings): # Номера попыток для ручных звонков — с учётом уже сделанных автозвонков attempt_map = {} pushed = 0 + # Ручные звонки идут через тот же транк обзвона: если у модуля занята линия, + # заявку из очереди не выкидываем — она дождётся следующего тика. + free = trunk_free_slots(settings) for q in queued: group_id = q["order_group_id"] is_test = bool(q.get("is_test_call")) @@ -619,9 +690,17 @@ def process_queue(conn, settings): # Для теста не подставляем customer_phone_normalized (нет заказа) if is_test: 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): mark_queue_processed(conn, q["queue_id"]) pushed += 1 + if free is not None: + free -= 1 else: # Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем — # по правилу владельца заявка живёт, пока окно не откроется или @@ -767,6 +846,18 @@ def main(): groups = get_groups_to_call(conn, settings) 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 for group in groups: 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'}") continue 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): pushed += 1 + if free is not None: + free -= 1 time.sleep(1) # пауза между звонками log.info(f"Pushed {pushed} call files")