#!/usr/bin/env python3 """ SuperSam — Автодозвон: планировщик (Фаза 3) Генерирует call-файлы для АТС клиента (Asterisk) и кладёт их в spool через SSH. Архитектура: systemd timer (каждые N минут) + этот скрипт. Каждый запуск: 1. Читает настройки кампании autocall_slot из sms_campaign_settings. 2. Находит группы, которым пора звонить (тайминг от sms_sent_at + пауза). 3. Генерирует call-файл (Context: SS-Autocall-Slot) и кладёт на АТС. 4. В test_mode звонит только на test_phone. Безопасность: сценарий НЕ активируется сам — только если enabled=true в БД. """ import os import sys import time import logging import subprocess from datetime import datetime, timezone, timedelta import psycopg2 from psycopg2.extras import RealDictCursor # ─── Конфигурация ──────────────────────────────────────────────────────────── DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") DB_PORT = os.environ.get("DB_PORT", "5432") DB_NAME = os.environ.get("DB_NAME", "postgres") DB_USER = os.environ.get("DB_USER", "supabase_admin") DB_PASS = os.environ.get("DB_PASS", "4fe80bb21c7c3d17a8d8b226adf7a479") # АТС (клиентская, через OpenVPN) ATS_HOST = os.environ.get("ATS_HOST", "192.168.122.203") ATS_USER = os.environ.get("ATS_USER", "root") ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro") ATS_SPOOL = "/var/spool/asterisk/outgoing" CALLER_ID = "2565551100" # 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") LOG_FILE = "/var/log/supersam-autocall.log" # ─── Логирование ───────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout)], ) log = logging.getLogger("autocall") # ─── БД ────────────────────────────────────────────────────────────────────── def get_db_conn(): return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS) def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'autocall_slot'") row = cur.fetchone() if not row: return { "enabled": False, "test_mode": True, "test_phone": "9067029953", "work_hours_start": 9, "work_hours_end": 20, "work_days": "1,2,3,4,5,6", "delay_after_sms_minutes": 90, "retry_interval_minutes": 120, "attempts_per_day": 3, "max_attempts": 5, "no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00", "call_timeout_seconds": 30, "pickup_calls_enabled": False, } return dict(row) def resolve_scenario(created_at, now=None): """Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра), со следующего дня → B (день фиксирован = завтра, только половина дня).""" if not created_at: return "A" now = now or datetime.now(timezone.utc) msk_tz = timezone(timedelta(hours=3)) created = created_at.astimezone(msk_tz) current = now.astimezone(msk_tz) return "A" if created.date() == current.date() else "B" def get_groups_to_call(conn, settings): """Группы, которым пора звонить.""" delay_min = settings.get("delay_after_sms_minutes", 90) retry_min = settings.get("retry_interval_minutes", 120) max_attempts = settings.get("max_attempts", 5) no_input_escalate = settings.get("no_input_escalate_after", 2) pickup_enabled = bool(settings.get("pickup_calls_enabled", False)) with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized, og.notification_status, og.delivery_status, og.sms_sent_at, og.autocall_opt_out, og.created_at, og.delivery_type, og.town, (SELECT count(*) FROM autocall_log l WHERE l.order_group_id = og.id) as attempt_count, (SELECT count(*) FROM autocall_log l WHERE l.order_group_id = og.id AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count, (SELECT max(l.created_at) FROM autocall_log l WHERE l.order_group_id = og.id) as last_call_at FROM order_groups og WHERE og.delivery_status = 'pending_confirmation' AND og.autocall_opt_out = false AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out') AND og.sms_sent_at IS NOT NULL -- Пауза после SMS прошла AND og.sms_sent_at < NOW() - make_interval(mins => %s) -- Не звонили или прошёл интервал повтора AND ( NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id) OR (SELECT max(l.created_at) FROM autocall_log l WHERE l.order_group_id = og.id) < NOW() - make_interval(mins => %s) ) -- Не превышен лимит попыток AND (SELECT count(*) FROM autocall_log l WHERE l.order_group_id = og.id) < %s -- Гео-исключения: те же, что у SMS (город исключён целиком -- или канал для города выключен в профиле). AND NOT EXISTS ( SELECT 1 FROM sms_city_rules r WHERE r.city = og.town AND r.enabled = false AND r.campaign_type IN ('*', 'autocall_slot') AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) ) AND NOT EXISTS ( SELECT 1 FROM sms_city_profiles p WHERE p.city = og.town AND p.sms_enabled = false ) -- Локация выключена для звонков в админке автодозвона AND NOT EXISTS ( SELECT 1 FROM autocall_city_rules c WHERE c.city = og.town AND c.calls_enabled = false ) -- Самовывоз звоним только если разрешено настройкой AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s) ORDER BY og.sms_sent_at ASC """, (delay_min, retry_min, max_attempts, pickup_enabled)) return [dict(r) for r in cur.fetchall()] def is_work_time(settings, now=None): """Проверяет, входит ли сейчас в рабочее окно.""" now = now or datetime.now(timezone.utc) # Рабочие часы в МСК (UTC+3) msk = now.astimezone(timezone(timedelta(hours=3))) hour = msk.hour start = settings.get("work_hours_start", 9) end = settings.get("work_hours_end", 20) work_days = [int(d) for d in str(settings.get("work_days", "1,2,3,4,5,6")).split(",") if d.strip().isdigit()] # Python: Monday=0, Sunday=6. БД: 1=Пн ... 7=Вс weekday = msk.weekday() + 1 if weekday not in work_days: return False return start <= hour < end def normalize_phone(phone): """Нормализует телефон: только цифры, начинается с 7.""" clean = "".join(c for c in str(phone) if c.isdigit()) if clean.startswith("8"): clean = "7" + clean[1:] elif not clean.startswith("7"): clean = "7" + clean return clean def get_city_exclusion(conn, city): """Гео-исключение — общее для SMS и звонков. Источник тот же, что у SMS: правила города (sms_city_rules) и общий выключатель канала в профиле города (sms_city_profiles.sms_enabled). Правило с campaign_type='*' действует на все каналы, 'autocall_slot' — только на звонки. Исключения по номеру (например, «не звонить на МТС», autocall_exclusions) живут отдельно и на SMS не влияют — они про телефонию, а не про город. """ if not city: return None with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT city, campaign_type, note FROM sms_city_rules WHERE city = %s AND enabled = false AND campaign_type IN ('*', 'autocall_slot') AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) AND (valid_until IS NULL OR valid_until >= CURRENT_DATE) LIMIT 1 """, (city,)) rule = cur.fetchone() if rule: return { "source": "city_rule", "reason": (rule.get("note") or "").strip() or f"город {city} исключён из рассылок", } cur.execute("SELECT sms_enabled, note FROM sms_city_profiles WHERE city = %s LIMIT 1", (city,)) profile = cur.fetchone() if profile is not None and profile.get("sms_enabled") is False: return { "source": "city_profile", "reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен", } # Только звонки: переключатель локации в админке автодозвона # (на SMS не влияет — уведомления по городу продолжают уходить). cur.execute("SELECT calls_enabled, note FROM autocall_city_rules WHERE city = %s LIMIT 1", (city,)) calls_rule = cur.fetchone() if calls_rule is not None and calls_rule.get("calls_enabled") is False: return { "source": "city_calls_off", "reason": (calls_rule.get("note") or "").strip() or f"звонки по {city} выключены в админке", } return None def get_exclusion_match(conn, phone): """Возвращает правило исключения, если на этот номер звонить нельзя, иначе None.""" phone = normalize_phone(phone) with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT rule_type, value, reason FROM autocall_exclusions WHERE active = true AND ( (rule_type = 'phone' AND %s = value) OR (rule_type = 'prefix' AND %s LIKE value || '%%') ) LIMIT 1 """, (phone, phone)) return cur.fetchone() def load_work_days(conn, field="pickup_days"): """Рабочие дни по графику компании (1=пн … 7=вс). График правит админ в «График работы» (business_schedule_settings). Звонки обязаны предлагать только рабочие дни: в пятницу «послезавтра» — это воскресенье, когда самовывоз не работает. """ default = [1, 2, 3, 4, 5] try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT pickup_days FROM business_schedule_settings WHERE id = 1") row = cur.fetchone() or {} raw = row.get("pickup_days") or default days = sorted({int(str(d).strip()) for d in raw if str(d).strip().isdigit()}) return days or default except Exception as e: log.warning(f"Не удалось прочитать график работы, беру пн-пт: {e}") return default def load_work_hours(conn): """Рабочие часы склада и доставки из «Графика работы» (админка). Возвращает минуты от полуночи — сравнивать часы строками нельзя. Дефолты повторяют прежнее поведение кода: первая половина до 12:00, «сегодня» предлагалось весь день. """ defaults = { "pickup_half_end": 12 * 60, "pickup_same_day_cutoff": 24 * 60, "pickup_close": 18 * 60, "delivery_half_end": 15 * 60, "delivery_close": 21 * 60, } try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT pickup_half_end, pickup_same_day_cutoff, pickup_close, delivery_half_end, delivery_close FROM business_schedule_settings WHERE id = 1 """) row = cur.fetchone() or {} hours = {} for key, fallback in defaults.items(): value = row.get(key) if value is None: hours[key] = fallback continue hours[key] = value.hour * 60 + value.minute return hours except Exception as exc: # noqa: BLE001 — часы не критичны, звоним по дефолту log.warning("Не удалось прочитать рабочие часы, беру значения по умолчанию: %s", exc) return dict(defaults) def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): """Даты, на которые предлагаем самовывоз. Правило: забрать можно в день готовности (день попадания в базу — он же день отправки ссылки) и два следующих дня, то есть максимум три дня включая сегодняшний. Выходные и нерабочие дни пропускаем, отсчёт ведём от сегодня: • готов сегодня → сегодня, завтра, послезавтра (3 рабочих дня) • готов вчера → сегодня, завтра (2) • готов позавчера → сегодня (1) • готов раньше → окно закрыто, звонить не о чем (0) • пятница, готов сегодня → только пятница: сб и вс склад закрыт cutoff_minutes — время, после которого «сегодня» уже не предлагаем (склад не успеет собрать заказ к выдаче, настройка админки). """ now = now or datetime.now(timezone.utc) work_days = work_days or [1, 2, 3, 4, 5] tz = timezone(timedelta(hours=3)) local_now = now.astimezone(tz) today = local_now.date() ready = (sms_sent_at or now).astimezone(tz).date() last_allowed = ready + timedelta(days=2) if last_allowed < today: return [] first_day = today # Поздно для сегодняшнего забора — окно начинается со следующего дня. if cutoff_minutes is not None and local_now.hour * 60 + local_now.minute >= cutoff_minutes: first_day = today + timedelta(days=1) dates = [] day = first_day while day <= last_allowed and len(dates) < 3: if day.isoweekday() in work_days: dates.append(day) day += timedelta(days=1) return [d.isoformat() for d in dates] def is_first_half_available(now=None, half_end_minutes=None): """До границы половины дня (настройка склада, по умолчанию 12:00) первую половину дня ещё можно предлагать клиенту.""" now = now or datetime.now(timezone.utc) local = now.astimezone(timezone(timedelta(hours=3))) limit = 12 * 60 if half_end_minutes is None else half_end_minutes return local.hour * 60 + local.minute < limit def resolve_call_type(group): """Тип звонка: pkp (самовывоз) или dlv (доставка). Приоритет — явный call_type (ручной/тестовый запрос из очереди): его выбирает логист. Если не задан — определяем по типу получения заказа. От типа зависит озвучка (ru/ss_*_pkp vs ru/ss_*_dlv). """ ct = (group.get("call_type") or "").strip().lower() if ct in ("dlv", "pkp"): return ct return "pkp" if (group.get("delivery_type") or "").strip().lower() == "pickup" else "dlv" def build_call_file(group, settings, attempt_no, is_test_call=False): """Формирует содержимое call-файла.""" phone = group.get("customer_phone_normalized") or group.get("customer_phone", "") phone = normalize_phone(phone) # Формат набора: 0 + номер без ведущей 7 dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone # Тест-звонок из UI: звоним ровно на введённый из UI номер (settings.test_mode не важен), # но помечаем was_test_mode=1, чтобы в логе и в БД было видно, что это тест. test_mode = bool(settings.get("test_mode", True)) if is_test_call: test_mode = True elif test_mode: test_phone = normalize_phone(settings.get("test_phone", "9067029953")) dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone # Сценарий: в день добавления группы → A (завтра/послезавтра), # со следующего дня → B (только завтра + половина дня). scenario = group.get("scenario") or resolve_scenario(group.get("created_at")) call_type = resolve_call_type(group) # Самовывоз: конкретные даты передаём в диалог явно — робот называет их # голосом и возвращает выбранную дату, а не абстрактный «день 1/2». pickup_days = [] if call_type == "pkp": pickup_days = pickup_window( group.get("sms_sent_at") or group.get("created_at"), settings.get("pickup_work_days"), cutoff_minutes=(settings.get("work_hours") or {}).get("pickup_same_day_cutoff"), ) if not pickup_days: # Окно самовывоза закрыто (заказ готов более двух дней назад). return None lines = [ f"Channel: PJSIP/{dial}@mmedia", f"CallerID: {CALLER_ID}", "Context: SS-Autocall-Slot", "Extension: s", "Priority: 1", "MaxRetries: 0", "WaitTime: 45", "Archive: yes", f"Setvar: SCENARIO={scenario}", f"Setvar: CALLTYPE={call_type}", f"Setvar: GROUP_ID={group.get('id') or ''}", f"Setvar: ATTEMPT={attempt_no}", f"Setvar: CUSTOMER_PHONE={phone}", f"Setvar: PICKUP_DAY1={pickup_days[0] if len(pickup_days) > 0 else ''}", f"Setvar: PICKUP_DAY2={pickup_days[1] if len(pickup_days) > 1 else ''}", f"Setvar: PICKUP_DAY3={pickup_days[2] if len(pickup_days) > 2 else ''}", f"Setvar: PICKUP_DAY1_FIRST={1 if (pickup_days and is_first_half_available(now=None, half_end_minutes=settings.get('work_hours', {}).get('pickup_half_end'))) else 0}", f"Setvar: API_URL={API_URL}", f"Setvar: API_KEY={API_KEY}", f"Setvar: TEST_MODE={1 if test_mode else 0}", "", ] return "\n".join(lines) def push_call_file(group, settings, attempt_no, is_test_call=False): """Кладёт call-файл на АТС через SSH.""" content = build_call_file(group, settings, attempt_no, is_test_call) if content is None: # Звонить не о чем (например, окно самовывоза закрыто) — тихо пропускаем. log.info(f"Skip group {group.get('id')}: нет доступных вариантов для звонка") return False gid = str(group.get("id") or "test")[:8] filename = f"ss-{gid}-{int(time.time())}.call" # Пишем файл на АТС через sshpass + cat cmd = [ "sshpass", "-p", ATS_PASS, "ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=10", "-q", f"{ATS_USER}@{ATS_HOST}", f"cat > {ATS_SPOOL}/{filename}", ] try: proc = subprocess.run(cmd, input=content.encode(), capture_output=True, timeout=20) if proc.returncode != 0: log.error(f"Failed to push call file for {group['id']}: {proc.stderr.decode()[:200]}") return False log.info(f"Pushed call file {filename} for group {group['id']} (phone={group.get('customer_phone')})") return True except Exception as e: log.error(f"SSH push error for {group['id']}: {e}") return False def get_queued_calls(conn): """Ручные запросы «Позвонить сейчас» из autocall_queue.""" with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call, q.call_type FROM autocall_queue q WHERE q.processed_at IS NULL ORDER BY q.requested_at ASC """) rows = [dict(r) for r in cur.fetchall()] # Подтянем телефон из order_groups для звонков по конкретному заказу # (для test-call order_group_id может быть NULL — номер берём из q.phone) ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"]) if ids: cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized, og.created_at, og.delivery_type, og.town FROM order_groups og WHERE og.id IN %s """, (ids,)) og = {r["id"]: r for r in cur.fetchall()} for r in rows: g = og.get(r["order_group_id"]) if r["order_group_id"] else None if g: r["customer_phone_normalized"] = g.get("customer_phone_normalized") r.setdefault("customer_phone", g.get("customer_phone")) r.setdefault("created_at", g.get("created_at")) r.setdefault("delivery_type", g.get("delivery_type")) r.setdefault("town", g.get("town")) return rows def mark_queue_processed(conn, queue_id): with conn.cursor() as cur: cur.execute("UPDATE autocall_queue SET processed_at = now() WHERE id = %s", (queue_id,)) conn.commit() def process_queue(conn, settings): """Забирает ручные запросы и создаёт call-файлы. Возвращает число созданных.""" queued = get_queued_calls(conn) if not queued: return 0 # Номера попыток для ручных звонков — с учётом уже сделанных автозвонков attempt_map = {} pushed = 0 for q in queued: group_id = q["order_group_id"] is_test = bool(q.get("is_test_call")) # Номер: для теста — из q.phone; для заказа — подтянут в customer_phone через get_queued_calls phone = q.get("phone") or q.get("customer_phone") or "" if not phone: log.warning(f"Queue item {q['queue_id']} has no phone, skip") continue # Гео-исключение (как для SMS): город исключён целиком city_excl = get_city_exclusion(conn, q.get("town")) if city_excl: log.info(f"Queue item {q['queue_id']} skipped by city rule ({q.get('town')}): {city_excl['reason']}") mark_queue_processed(conn, q["queue_id"]) continue # Исключение: на этот номер не звоним (например, МТС-диапазон) excl = get_exclusion_match(conn, phone) if excl: log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}") mark_queue_processed(conn, q["queue_id"]) continue if group_id not in attempt_map: if group_id: with conn.cursor() as cur: cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,)) attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1 else: attempt_map[group_id] = 1 attempt_no = attempt_map[group_id] g = dict(q) g["id"] = group_id g["customer_phone"] = phone # Для теста не подставляем customer_phone_normalized (нет заказа) if is_test: g["customer_phone_normalized"] = None if push_call_file(g, settings, attempt_no, is_test_call=is_test): mark_queue_processed(conn, q["queue_id"]) pushed += 1 attempt_map[group_id] = attempt_no + 1 return pushed def has_pending_items(conn): """Дёшево: есть ли непрочитанные ручные запросы. Для fast-path, чтобы не шуметь в логе.""" with conn.cursor() as cur: cur.execute("SELECT 1 FROM autocall_queue WHERE processed_at IS NULL LIMIT 1") return cur.fetchone() is not None def backfill_no_answer(conn, settings): """Дописывает no_answer звонкам, которые не получили ответа сети. Call-файл с истёкшим WaitTime не запускает AGI — в autocall_log ничего не пишется, и «не дозвонились» не появляется никогда. Ищем обработанные заявки старше 10 минут без строки в логе и дописываем no_answer. Самозакрывающаяся: вставленная строка сама удовлетворяет проверке существования, повторов не будет. """ with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT q.id, q.order_group_id, q.phone, q.is_test_call, q.requested_at, q.processed_at, q.call_type FROM autocall_queue q WHERE q.processed_at IS NOT NULL AND q.processed_at < NOW() - make_interval(mins => 10) ORDER BY q.processed_at ASC LIMIT 20 """) rows = [dict(r) for r in cur.fetchall()] backfilled = 0 for q in rows: gid = q["order_group_id"] with conn.cursor(cursor_factory=RealDictCursor) as cur: if gid: cur.execute(""" SELECT 1 FROM autocall_log l WHERE l.order_group_id = %s AND l.created_at >= %s LIMIT 1 """, (gid, q["requested_at"])) else: cur.execute(""" SELECT 1 FROM autocall_log l WHERE l.order_group_id IS NULL AND l.customer_phone = %s AND l.created_at >= %s LIMIT 1 """, (normalize_phone(q["phone"] or ""), q["requested_at"])) if cur.fetchone(): continue # строка есть (AGI отработал) — дописывать нечего # Телефон из заказа (или нормализованный из заявки для pure-теста) phone = normalize_phone(q["phone"] or "") group_created = None group_dtype = None if gid: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT customer_phone_normalized, customer_phone, created_at, delivery_type, town FROM order_groups WHERE id = %s """, (gid,)) grow = cur.fetchone() or {} phone = (grow.get("customer_phone_normalized") or normalize_phone(grow.get("customer_phone") or "") or phone) group_created = grow.get("created_at") group_dtype = grow.get("delivery_type") # По исключённым номерам звонка не было — строку не пишем # (статус «исключение» и так показывается из настроек). if get_exclusion_match(conn, phone): continue # Гео-исключение: город, по которому звонки не делаем вовсе if get_city_exclusion(conn, q.get("town")): continue scenario = resolve_scenario(group_created) if gid else "A" with conn.cursor() as cur: if gid: cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (gid,)) attempt_no = (cur.fetchone()[0] or 0) + 1 else: attempt_no = 1 test_mode = bool(q["is_test_call"] or settings.get("test_mode", True)) call_type = resolve_call_type({"call_type": q.get("call_type"), "delivery_type": group_dtype}) with conn.cursor() as cur: cur.execute(""" INSERT INTO autocall_log (order_group_id, customer_phone, attempt_no, scenario, call_started_at, outcome, was_test_mode, channel_state, call_type) VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer', %s) """, (gid, phone, attempt_no, scenario, q["processed_at"] or q["requested_at"], test_mode, call_type)) conn.commit() backfilled += 1 log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})") return backfilled def main(): conn = get_db_conn() try: settings = load_settings(conn) # Рабочие дни и часы для окна самовывоза (выходные/закрытие склада робот не предлагает) settings["pickup_work_days"] = load_work_days(conn) settings["work_hours"] = load_work_hours(conn) # Добор «не взял трубку»: заявки, по которым сеть так и не ответила # (истёкший call-файл, AGI не запускался). Только работа с БД. try: nb = backfill_no_answer(conn, settings) if nb: log.info(f"Backfilled {nb} no_answer rows") except Exception as e: log.warning(f"backfill_no_answer failed: {e}") # Fast-path: если кампания выключена и очередь пуста — выходим молча. # Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе. if not settings.get("enabled", False) and not has_pending_items(conn): return # 0) Ручные звонки «Позвонить сейчас» — даже если кампания выключена, # но в рабочее время и enabled_or_queue. Ручной запрос — явный сигнал. if is_work_time(settings): nq = process_queue(conn, settings) if nq: log.info(f"Processed {nq} queued manual calls") else: log.info("Outside work hours, manual queue not processed") if not settings.get("enabled", False): log.info("Autocall campaign disabled (enabled=false), skipping auto") return groups = get_groups_to_call(conn, settings) log.info(f"Found {len(groups)} groups to call") pushed = 0 for group in groups: phone = group.get("customer_phone_normalized") or group.get("customer_phone") or "" # Гео-исключение (то же, что для SMS): город исключён целиком city_excl = get_city_exclusion(conn, group.get("town")) if city_excl: log.info(f"Group {group['id']} skipped by city rule ({group.get('town')}): {city_excl['reason']}") continue # Исключение: на этот номер не звоним (например, МТС-диапазон) excl = get_exclusion_match(conn, phone) if excl: 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 push_call_file(group, settings, attempt_no): pushed += 1 time.sleep(1) # пауза между звонками log.info(f"Pushed {pushed} call files") finally: conn.close() if __name__ == "__main__": main()