#!/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, } return dict(row) 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) 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, (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 ORDER BY og.sms_sent_at ASC """, (delay_min, retry_min, max_attempts)) 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 build_call_file(group, settings, attempt_no): """Формирует содержимое 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 test_mode = settings.get("test_mode", True) if 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 scenario = group.get("scenario", "A") # выбор дня + времени call_type = "dlv" 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['id']}", f"Setvar: ATTEMPT={attempt_no}", f"Setvar: CUSTOMER_PHONE={phone}", 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): """Кладёт call-файл на АТС через SSH.""" content = build_call_file(group, settings, attempt_no) filename = f"ss-{group['id'][:8]}-{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 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 для call-файла if rows: ids = tuple(r["order_group_id"] for r in rows) cur.execute(""" SELECT og.id, og.customer_phone, og.customer_phone_normalized 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 g: r["customer_phone_normalized"] = g.get("customer_phone_normalized") r.setdefault("customer_phone", g.get("customer_phone")) 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"] if group_id not in attempt_map: 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 attempt_no = attempt_map[group_id] g = dict(q) g["id"] = group_id if push_call_file(g, settings, attempt_no): mark_queue_processed(conn, q["queue_id"]) pushed += 1 attempt_map[group_id] = attempt_no + 1 return pushed def main(): conn = get_db_conn() try: settings = load_settings(conn) # 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: 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()