diff --git a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc b/scripts/__pycache__/sms_first_campaign.cpython-311.pyc deleted file mode 100644 index 7950aa6..0000000 Binary files a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc and /dev/null differ diff --git a/scripts/autocall_scheduler.py b/scripts/autocall_scheduler.py new file mode 100755 index 0000000..f452208 --- /dev/null +++ b/scripts/autocall_scheduler.py @@ -0,0 +1,934 @@ +#!/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" + +# --- Транк обзвона ----------------------------------------------------------- +# '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") + +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" + + +GROUPS_TO_CALL_SQL = """ + 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, + -- От чего считаем паузу перед звонком: + -- * SMS ушло — от времени отправки; + -- * SMS не ушло/не доставлено — от последней попытки отправки + -- (владелец: «если SMS не отправлено — звоним через 90 минут + -- после последней попытки»). + COALESCE( + CASE WHEN t.try_at IS NOT NULL + AND t.try_status NOT IN ('sent', 'delivered') + THEN t.try_at END, + og.sms_sent_at + ) as call_after + FROM order_groups og + LEFT JOIN LATERAL ( + SELECT COALESCE(l.updated_at, l.created_at) AS try_at, l.status AS try_status + FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND l.campaign_type IN ('first_sms', 'second_sms') + ORDER BY COALESCE(l.updated_at, l.created_at) DESC + LIMIT 1 + ) t ON true + WHERE og.delivery_status = 'pending_confirmation' + AND og.autocall_opt_out = false + AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out', 'closed_without_confirmation') + -- Пауза после SMS (или после неудачной попытки) прошла + AND COALESCE( + CASE WHEN t.try_at IS NOT NULL + AND t.try_status NOT IN ('sent', 'delivered') + THEN t.try_at END, + og.sms_sent_at + ) IS NOT NULL + AND COALESCE( + CASE WHEN t.try_at IS NOT NULL + AND t.try_status NOT IN ('sent', 'delivered') + THEN t.try_at END, + 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 call_after ASC +""" + + +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) + pickup_enabled = bool(settings.get("pickup_calls_enabled", False)) + + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(GROUPS_TO_CALL_SQL, + (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 autocall_window_text(settings): + """Окно обзвона словами — для лога (владелец читает логи сам).""" + start = settings.get("work_hours_start", 9) + end = settings.get("work_hours_end", 20) + day_names = {1: "пн", 2: "вт", 3: "ср", 4: "чт", 5: "пт", 6: "сб", 7: "вс"} + days = [day_names[d] for d in sorted( + int(x) for x in str(settings.get("work_days", "")).split(",") if x.strip().isdigit() + ) if d in day_names] + return f"{int(start):02d}:00-{int(end):02d}:00 МСК ({', '.join(days) or 'дни не заданы'})" + + +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): + `pickup_days` — дни склада, `delivery_days` — дни доставки. Звонки обязаны + опираться именно на эти данные: в пятницу «послезавтра» — это воскресенье, + когда ни самовывоз, ни доставка не работают. + """ + columns = {"pickup_days", "delivery_days"} # белый список: имя колонки идёт в SQL + column = field if field in columns else "pickup_days" + default = [1, 2, 3, 4, 5] + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(f"SELECT {column} AS days FROM business_schedule_settings WHERE id = 1") + row = cur.fetchone() or {} + raw = row.get("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"Не удалось прочитать график работы ({column}), беру пн-пт: {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 work_day_window(start_date, work_days, count): + """`count` ближайших рабочих дней начиная со `start_date` включительно. + + Выходные и нерабочие дни не сокращают окно, а пропускаются. + """ + days = [] + day = start_date + guard = 0 + while len(days) < count and guard < 30: + if day.isoweekday() in work_days: + days.append(day) + day += timedelta(days=1) + guard += 1 + return days + + +def pickup_window(sms_sent_at, work_days=None, now=None, cutoff_minutes=None): + """Даты, на которые предлагаем самовывоз. + + Правило владельца: окно считается **рабочими днями** — день готовности + плюс два следующих рабочих дня (выходные и нерабочие дни не сокращают + окно, а пропускаются): + • готов в понедельник → пн, вт, ср + • готов в пятницу → пт, пн, вт (сб и вс склад закрыт) + • готов в субботу → пн, вт (сам выходной не предлагаем) + • готов вчера → остаток окна начиная с сегодня + • окно прошло → пусто, звонить не о чем + + 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() + + window = work_day_window(ready, work_days, 3) + + 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) + return [d.isoformat() for d in window if d >= first_day] + + +def delivery_window(sms_sent_at, work_days=None, now=None): + """Даты, на которые предлагаем доставку. + + Правило владельца: доставляем **только рабочими днями** и никогда в день + готовности — нужно время собрать маршрут. Значит, окно — два ближайших + рабочих дня после готовности: + • готов в понедельник → вт, ср + • готов в пятницу → пн, вт (в субботу и воскресенье не доставляем) + • звонок в среду по заказу, готовому в понедельник → ср, чт + """ + now = now or datetime.now(timezone.utc) + work_days = work_days or [1, 2, 3, 4, 5] + tz = timezone(timedelta(hours=3)) + today = now.astimezone(tz).date() + ready = (sms_sent_at or now).astimezone(tz).date() + + start = max(today, ready + timedelta(days=1)) + return [d.isoformat() for d in work_day_window(start, work_days, 2)] + + +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 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-файла. + + manual=True — звонок запущен руками из карточки заказа. Логист выбрал + конкретного клиента, поэтому звоним ему, даже если кампания в тестовом + режиме: тестовый режим страхует массовый обзвон, а не ручной запрос. + """ + 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 manual and group.get("id"): + test_mode = False + 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) + + # График работы (админка) — источник рабочих дней. Если нужный список ПУСТ + # (все дни сняты или настройка не заполнена), окно считать нечем: сами функции + # окна подменяют пустой список дефолтом «Пн–Пт», и робот начал бы предлагать + # дни, когда компания не работает. Такой звонок лучше пропустить и сказать в логе. + if call_type == "pkp" and not settings.get("pickup_work_days"): + log.warning( + "Skip group %s: в «Графике работы» не заполнены дни самовывоза — окно не посчитать", + group.get("id"), + ) + return None + if call_type == "dlv" and not settings.get("delivery_work_days"): + log.warning( + "Skip group %s: в «Графике работы» не заполнены дни доставки — окно не посчитать", + group.get("id"), + ) + return None + + # Самовывоз: конкретные даты передаём в диалог явно — робот называет их + # голосом и возвращает выбранную дату, а не абстрактный «день 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 + + # Доставка: только рабочие дни графика и не в день готовности. Суббота — + # выходной, поэтому «готов в пятницу» даёт понедельник и вторник. + delivery_days = [] + if call_type == "dlv": + delivery_days = delivery_window( + group.get("sms_sent_at") or group.get("created_at"), + settings.get("delivery_work_days"), + ) + if not delivery_days: + log.warning( + "Skip group %s: пустой график доставки — окно не посчитать", group.get("id") + ) + return None + + lines = [ + f"Channel: {trunk_channel(settings, dial)}", + 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: DELIVERY_DAY1={delivery_days[0] if len(delivery_days) > 0 else ''}", + f"Setvar: DELIVERY_DAY2={delivery_days[1] if len(delivery_days) > 1 else ''}", + 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, manual=False): + """Кладёт call-файл на АТС через SSH.""" + content = build_call_file(group, settings, attempt_no, is_test_call, manual) + 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, + og.notification_status, og.delivery_date, og.autocall_opt_out + 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")) + r["notification_status"] = g.get("notification_status") + r["delivery_date"] = g.get("delivery_date") + r["autocall_opt_out"] = g.get("autocall_opt_out") + 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 + # Ручные звонки идут через тот же транк обзвона: если у модуля занята линия, + # заявку из очереди не выкидываем — она дождётся следующего тика. + free = trunk_free_slots(settings) + 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 + # Заказ уже согласован (клиент выбрал дату или менеджер поставил её сам) — + # роботу звонить не о чем, иначе он предложит дни по заказу, где всё решено. + # Заявку НЕ закрываем (правило владельца) — просто не обрабатываем. + # ВАЖНО: 'manual_required' («нужен менеджер») сюда не относится — это как раз + # заказ, где клиент дату НЕ выбрал, звонок робота по нему допустим. + agreed = (str(q.get("notification_status") or "") == "confirmed" + or q.get("delivery_date") is not None + or q.get("autocall_opt_out") is True) + if agreed and not is_test: + log.info( + f"Queue item {q['queue_id']} (group {group_id}): заказ уже согласован " + f"(статус {q.get('notification_status')}, дата {q.get('delivery_date')}) — " + "звонок не нужен, заявка остаётся в очереди" + ) + 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 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: + # Звонить не о чем (окно звонка закрыто). Заявку НЕ закрываем — + # по правилу владельца заявка живёт, пока окно не откроется или + # пока её не обработают вручную. Интерфейс в этот момент блокирует + # кнопку автодозвона и объясняет причину (autocallAvailability.js). + log.info( + f"Queue item {q['queue_id']} (group {group_id}): окно звонка закрыто — " + "заявка остаётся в очереди до открытия окна" + ) + 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, "pickup_days") + settings["delivery_work_days"] = load_work_days(conn, "delivery_days") + 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 + + # Окно обзвона: вне рабочих часов и дней авто-кампания не звонит вообще + # (владелец: «звонить вне окна разрешённого нельзя»). Раньше окно проверялось + # только для ручной очереди, и автоматика дозванивалась в 21:00 и позже: повтор, + # чей интервал истёк вечером, теперь просто ждёт ближайшего рабочего утра. + if not is_work_time(settings): + log.info(f"Outside work hours ({autocall_window_text(settings)}), auto calls skipped") + return + + 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 "" + # Гео-исключение (то же, что для 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 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") + finally: + conn.close() + +if __name__ == "__main__": + main() diff --git a/scripts/backup-code-s3.sh b/scripts/backup-code-s3.sh old mode 100644 new mode 100755 index 4748cc0..d458d8f --- a/scripts/backup-code-s3.sh +++ b/scripts/backup-code-s3.sh @@ -15,6 +15,7 @@ S3_BUCKET="02f162ff4a18-supersam-s3" S3_PREFIX="backups" SOURCE_DIR="/opt/supersam" +SOURCE_DIR_DEV="/opt/supersam-dev" RETENTION_DAYS=30 # ── Runtime ─────────────────────────────────────────────────────────────── @@ -27,14 +28,15 @@ echo "[$(date)] Starting code backup..." # ── Archive ────────────────────────────────────────────────────────────── echo "[$(date)] Archiving ${SOURCE_DIR}..." +# Рантайм-чекаут + актуальный dev-чекаут (источник прод-фронта) tar -czf "${ARCHIVE_FILE}" \ - --directory="${SOURCE_DIR}" \ + --directory="/" \ --exclude="node_modules" \ --exclude=".git" \ --exclude="dist" \ --exclude="volumes" \ --exclude="__pycache__" \ - . 2>/dev/null + "${SOURCE_DIR#/}" "${SOURCE_DIR_DEV#/}" 2>/dev/null ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1) echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})" diff --git a/scripts/check_autocall_window.py b/scripts/check_autocall_window.py new file mode 100755 index 0000000..5288b84 --- /dev/null +++ b/scripts/check_autocall_window.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Детерминированная проверка окна обзвона автодозвона. + +Проверяет, что автоматическая кампания НЕ ставит call-файлы вне рабочего окна +(часы и дни берутся из настроек кампании `autocall_slot`), не звонит в выходные +и звонит внутри окна. Ничего не звонит: push_call_file, process_queue, backfill +и выборка кандидатов подменены, время — фейковое (модульная datetime). + +Запускать на .8 через обёртку (она подставит DB_*): + + bash /opt/supersam/scripts/sms_run_with_db.sh \\ + /opt/supersam/scripts/check_autocall_window.py [путь_к_проверяемому_файлу] + +Без аргумента проверяется установленный /opt/supersam/scripts/autocall_scheduler.py +(можно проверять и файл-кандидат до установки). Ожидания считаются ИЗ НАСТРОЕК +(work_hours_start/end, work_days), поэтому проверка не устаревает при смене графика. +Зачем: инцидент 28.09.2026 — окно проверялось только для ручной очереди, и робот +позвонил клиенту в 21:03 МСК. +""" +import importlib.util, datetime as _dt, sys +from importlib.machinery import SourceFileLoader + +PATH = sys.argv[1] if len(sys.argv) > 1 else "/opt/supersam/scripts/autocall_scheduler.py" +_loader = SourceFileLoader("acs", PATH) +spec = importlib.util.spec_from_loader("acs", _loader) +m = importlib.util.module_from_spec(spec) +_loader.exec_module(m) + +MSK = _dt.timezone(_dt.timedelta(hours=3)) + + +class FakeDT(_dt.datetime): + fixed = None + + @classmethod + def now(cls, tz=None): + if tz is None: + return cls.fixed.astimezone(MSK).replace(tzinfo=None) + return cls.fixed.astimezone(tz) + + +m.datetime = FakeDT + +# ── настройки, из которых считаем ожидания ─────────────────────────────────── +_conn = m.get_db_conn() +_settings = m.load_settings(_conn) +_conn.close() +START = int(_settings.get("work_hours_start", 9)) +END = int(_settings.get("work_hours_end", 20)) +WORK_DAYS = sorted(int(d) for d in str(_settings.get("work_days", "")).split(",") if d.strip().isdigit()) +print(f"настройки кампании: окно {START:02d}:00-{END:02d}:00 МСК, дни {WORK_DAYS}") + +FAKE_GROUP = { + "id": "00000000-0000-0000-0000-000000000000", + "customer_phone": "79000000000", + "customer_phone_normalized": "79000000000", + "town": "Симферополь", + "created_at": _dt.datetime(2026, 9, 28, 6, 0, tzinfo=_dt.timezone.utc), + "delivery_type": "pickup", + "attempt_count": 0, + "no_input_count": 0, + "sms_sent_at": _dt.datetime(2026, 9, 28, 5, 0, tzinfo=_dt.timezone.utc), + "notification_status": "first_sms_sent", + "delivery_status": "pending_confirmation", + "autocall_opt_out": False, +} + +pushes = [] + + +def _fake_push(group, settings, attempt_no, is_test_call=False, **kw): + pushes.append(group["customer_phone"]) + return True + + +def run_case(label, when_msk, expect_push): + FakeDT.fixed = when_msk.astimezone(MSK) + pushes.clear() + m.push_call_file = _fake_push + m.process_queue = lambda conn, settings: 0 + m.backfill_no_answer = lambda conn, settings: 0 + m.get_groups_to_call = lambda conn, settings: [FAKE_GROUP] + m.trunk_free_slots = lambda settings: 1 + m.trunk_capacity = lambda settings: 1 + m.trunk_name = lambda settings: "goip5" + m.get_city_exclusion = lambda conn, town: None + m.get_exclusion_match = lambda conn, phone: None + m.main() + ok = (len(pushes) > 0) == expect_push + print(f"{'PASS' if ok else 'FAIL'}: {label} → звонков {len(pushes)} (ожидали {'есть' if expect_push else 'нет'})") + return ok + + +DAY_NAMES = {1: "пн", 2: "вт", 3: "ср", 4: "чт", 5: "пт", 6: "сб", 7: "вс"} +MONDAY = _dt.datetime(2026, 9, 28, tzinfo=MSK) # 28.09.2026 — понедельник +results = [] + +# Каждый день недели в середине окна: звонок только если день рабочий. +for num in range(1, 8): + day = MONDAY + _dt.timedelta(days=num - 1) + when = day.replace(hour=min(START + 1, 23)) + results.append(run_case(f"{DAY_NAMES[num]} {when:%d.%m %H:%M} МСК (середина окна)", when, num in WORK_DAYS)) + +# Границы окна — на первом рабочем дне недели. +work_day = MONDAY + _dt.timedelta(days=WORK_DAYS[0] - 1) if WORK_DAYS else MONDAY +if WORK_DAYS: + results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START - 1 if START else 0:02d}:00 МСК (час до окна)", work_day.replace(hour=max(START - 1, 0)), False)) + results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {START:02d}:00 МСК (начало окна)", work_day.replace(hour=START), True)) + results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END - 1:02d}:59 МСК (конец окна)", work_day.replace(hour=END - 1), True)) + results.append(run_case(f"{DAY_NAMES[WORK_DAYS[0]]} {END:02d}:00 МСК (час после окна)", work_day.replace(hour=END), False)) + +# Инцидент 28.09.2026: поздние звонки — звонков быть не должно. +results.append(run_case("пн 19:58 МСК (инцидент)", _dt.datetime(2026, 9, 28, 19, 58, tzinfo=MSK), False)) +results.append(run_case("пн 21:03 МСК (инцидент)", _dt.datetime(2026, 9, 28, 21, 3, tzinfo=MSK), False)) + +print("\nИТОГ:", "ВСЕ PASS" if all(results) else "ЕСТЬ FAIL") +sys.exit(0 if all(results) else 1) diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index 13cd80f..192701b 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -34,6 +34,12 @@ import requests import psycopg2 from psycopg2.extras import RealDictCursor +try: # лимитер sms.ru (модуль лежит рядом со скриптом) + from sms_rate_limit import check_send_allowed, alert_due +except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше + check_send_allowed = None + alert_due = None + # ─── Конфигурация ──────────────────────────────────────────────────────────── DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") @@ -64,6 +70,84 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} # Коды превышения лимитов (нельзя повторять отправку — заблокируют) LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} +# ─── Тексты SMS ────────────────────────────────────────────────────────────── +# Шаблоны живут в админке (sms_campaign_settings): +# sms_text_template — текст для ДОСТАВКИ +# sms_text_template_pickup — текст для САМОВЫВОЗА +# Подстановки: {link}/{ссылка} — ссылка согласования, +# {order}/{номер}/{номер_доставки} — номер доставки (010796), +# {code}/{код}/{код_выдачи} — код выдачи (7598). +# Пустой шаблон → DEFAULT_TEXT_*. + +DEFAULT_TEXT_DELIVERY = "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}" +DEFAULT_TEXT_PICKUP = ( + "Ваш заказ готов. Номер доставки {order}, код выдачи {code}. " + "Согласуйте дату самовывоза по ссылке: {link}" +) +DEFAULT_TEXT_NOTIFICATION = "Ваш заказ готов." + + +def is_pickup_group(group): + return (group.get("delivery_type") or "delivery").strip().lower() == "pickup" + + +def first_order_digits(value): + """Номер доставки из order_numbers («ТП\\-010722» → «010722»).""" + items = value if isinstance(value, (list, tuple)) else [value] + for item in items: + digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit()) + if digits: + return digits + return "" + + +def apply_sms_placeholders(text, group, link): + """Подстановка переменных шаблона в текст SMS.""" + if not text: + return text + + order = first_order_digits(group.get("order_numbers")) + code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit()) + replacements = ( + ("{link}", link), ("{ссылка}", link), + ("{номер_доставки}", order), ("{номер}", order), ("{order}", order), + ("{код_выдачи}", code), ("{код}", code), ("{code}", code), + ) + + result = text + for token, value in replacements: + result = result.replace(token, value) + return result.strip() + + +def build_sms_text(group, settings): + """Текст SMS по типу отгрузки: самовывоз → pickup-шаблон, доставка → обычный. + + Приоритет: + 1. текст профиля города (режим «Уведомление», sms_city_profiles) — как было; + 2. шаблон кампании из админки для этого типа отгрузки; + 3. дефолт по типу отгрузки. + """ + link = group.get("delivery_link") or "" + mode = (group.get("sms_mode") or "link").strip().lower() + profile_text = (group.get("sms_text_custom") or "").strip() + pickup = is_pickup_group(group) + template = (settings.get( + "sms_text_template_pickup" if pickup else "sms_text_template" + ) or "").strip() + if not template: + template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY + + if mode == "notification": + if profile_text: + return apply_sms_placeholders(profile_text, group, link) + # уведомление без ссылки: шаблон годится, только если в нём нет {link} + if template and "{link}" not in template: + return apply_sms_placeholders(template, group, link) + return apply_sms_placeholders(DEFAULT_TEXT_NOTIFICATION, group, link) + + return apply_sms_placeholders(template, group, link) + # ─── Логирование ───────────────────────────────────────────────────────────── logging.basicConfig( @@ -172,7 +256,7 @@ def send_telegram(message, chat_id): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_send(conn): +def get_groups_to_send(conn, max_attempts): """Группы, которым ещё не отправили SMS. Режимы (из sms_city_profiles): @@ -183,7 +267,8 @@ def get_groups_to_send(conn): cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.customer_phone_normalized, og.delivery_link, og.notification_status, - og.delivery_type, og.town, + og.delivery_type, og.town, og.sms_attempts, + og.order_numbers, og.pickup_code, -- Профиль города (fallback на дефолтные значения если нет записи) COALESCE(scp.delivery_mode, 'link') as delivery_mode, COALESCE(scp.delivery_text, '') as delivery_text, @@ -210,6 +295,16 @@ def get_groups_to_send(conn): AND COALESCE(og.notification_status, '') = 'link_ready' -- Профиль sms_enabled (замена старого sms_allowed_for_city) AND COALESCE(scp.sms_enabled, true) = true + -- Гео-исключения — общие для SMS и звонков: правило города + -- (campaign_type '*' — все каналы) блокирует и отправку 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 ('*', 'first_sms') + AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) + AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) + ) -- Нет активной SMS в логе (sent/checking) за последние 2 часа AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl @@ -218,8 +313,15 @@ def get_groups_to_send(conn): AND scl.status IN ('sent', 'checking') AND scl.created_at > NOW() - INTERVAL '2 hours' ) + -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку, + -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны) + AND ( + SELECT count(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = og.id + AND scl2.campaign_type = 'first_sms' + ) < %s ORDER BY og.created_at ASC - """) + """, (max_attempts,)) return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): @@ -300,6 +402,22 @@ def update_order_group(conn, group_id, fields): cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values) conn.commit() +def count_sms_attempts(conn, group_id, campaign_type="first_sms"): + """Сколько раз эту SMS уже пытались отправить (строк в логе этой кампании). + + Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт + новую строку, поэтому в самой строке всегда было бы attempts=1, условие + «attempts < max_attempts» выполнялось бы вечно и кампания слала бы SMS + каждые 5 минут бесконечно (проверено на заказе ТП-010543: 457 отправок за + 3,5 суток, ни одна не доставлена, в ручную работу заказ не ушёл). + """ + with conn.cursor() as cur: + cur.execute(""" + SELECT COUNT(*) FROM sms_campaign_log + WHERE order_group_id = %s AND campaign_type = %s + """, (group_id, campaign_type)) + return cur.fetchone()[0] + # ─── Основная логика ───────────────────────────────────────────────────────── def step_send_new(conn, settings): @@ -309,10 +427,13 @@ def step_send_new(conn, settings): max_attempts = settings.get("max_attempts", 2) second_sms_delay = settings.get("second_sms_delay_hours", 3) - groups = get_groups_to_send(conn) + groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send SMS") sent_count = 0 + limited_count = 0 + limited_last = None + limited_info = None for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") @@ -336,15 +457,12 @@ def step_send_new(conn, settings): log.info(f"Group {group_id}: already has recent SMS in log, skipping") continue - # Формируем текст SMS по режиму + # Формируем текст SMS по типу отгрузки (доставка / самовывоз) и режиму + sms_text = build_sms_text(group, settings) if sms_mode == "notification": - # Уведомление без ссылки на согласование - sms_text = custom_text if custom_text else "Ваш заказ готов." - log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link") + log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}): {sms_text!r}") else: - # Стандартный флоу — ссылка на согласование - sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}" - log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})") + log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type}): {sms_text!r}") # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый send_phone = phone @@ -352,7 +470,19 @@ def step_send_new(conn, settings): send_phone = settings.get("test_phone", "79788382260") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") + # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) --- + if check_send_allowed is not None: + _ok, _reason, _info = check_send_allowed(conn, send_phone, settings) + if not _ok: + limited_count += 1 + limited_last = _reason + limited_info = _info + log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}") + continue # сообщение не потеряно: уйдёт в следующий запуск + log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})") + attempt_no = count_sms_attempts(conn, group_id) + 1 + log.info(f"Group {group_id}: attempt {attempt_no}/{max_attempts}") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) if sms_id: @@ -360,11 +490,13 @@ def step_send_new(conn, settings): campaign_type="first_sms", order_group_id=group_id, customer_phone=phone, + sent_to=send_phone, + was_test_mode=bool(settings.get("test_mode", True)), sms_id=sms_id, sms_text=sms_text, status="sent", sms_code=code, - attempts=1, + attempts=attempt_no, ) # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status if sms_mode == "notification": @@ -374,6 +506,7 @@ def step_send_new(conn, settings): "sms_sent_at": "NOW()", "first_sms_sent_at": "NOW()", "status": "first_sms_sent", + "sms_attempts": attempt_no, }) log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)") else: @@ -381,6 +514,7 @@ def step_send_new(conn, settings): update_order_group(conn, group_id, { "notification_status": "sms_sending", "sms_sent_at": "NOW()", + "sms_attempts": attempt_no, }) log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending") sent_count += 1 @@ -394,7 +528,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="send_failed", sms_code=code, - attempts=1, + attempts=attempt_no, error_message=error, ) log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") @@ -402,9 +536,26 @@ def step_send_new(conn, settings): update_order_group(conn, group_id, { "notification_status": "send_failed", "last_sms_error": error[:200], + "sms_attempts": attempt_no, }) send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) + if limited_count: + _win = (limited_info or {}).get("window") + _cap = (limited_info or {}).get("per_number") + _acct = (limited_info or {}).get("account") + msg = ( + f"SMS придержаны лимитом sms.ru: {limited_count} шт. " + f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"причина: {limited_last}; " + f"сообщения не потеряны — уйдут в следующий запуск." + ) + log.warning("Rate limit held %s SMS: %s", limited_count, limited_last) + if alert_due is not None and alert_due(conn, settings): + send_telegram(msg, tg_chat) + else: + log.info("Rate limit Telegram alert suppressed (throttled)") + return sent_count def step_check_status(conn, settings): @@ -425,7 +576,7 @@ def step_check_status(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 was_needs_check = item.get("needs_check", False) code, raw, api_code = check_sms_status(sms_id, api_id) @@ -471,9 +622,11 @@ def step_check_status(conn, settings): }) else: update_order_group(conn, group_id, { - "notification_status": "send_failed", - "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})", + "notification_status": "manual_required", + "last_sms_error": f"SMS не доставлена после {max_attempts} попыток (код {code})", }) + log.error(f"Group {group_id}: attempts exhausted ({attempts}/{max_attempts}), " + f"notification_status→manual_required") send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat) elif code in LIMIT_ERROR_CODES: @@ -509,7 +662,7 @@ def step_handle_expired(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 delivery_link = item.get("delivery_link", "") log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") @@ -534,6 +687,63 @@ def step_handle_expired(conn, settings): tg_chat, ) +def step_release_exhausted(conn, settings): + """Шаг 4: группы, у которых попытки исчерпаны, а статус остался 'link_ready'. + + Такое состояние возникает как следствие сбоя счётчика: группа выпадает из + выборки по лимиту попыток, но из очереди не уходит — висит в 'link_ready' + вечно, клиент не получает ссылку, в ручную работу заказ не переводится. + Шаг закрывает эту дыру: попытки кончились → «нужен менеджер». + """ + max_attempts = settings.get("max_attempts", 2) + tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT og.id, og.group_key, og.customer_name, og.customer_phone, + (SELECT count(*) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND l.campaign_type = 'first_sms') AS attempts + FROM order_groups og + WHERE og.delivery_status = 'pending_confirmation' + AND COALESCE(og.notification_status, '') = 'link_ready' + AND ( + SELECT count(*) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND l.campaign_type = 'first_sms' + ) >= %s + -- Активная отправка в работе — не мешаем ей + AND NOT EXISTS ( + SELECT 1 FROM sms_campaign_log l2 + WHERE l2.order_group_id = og.id + AND l2.campaign_type = 'first_sms' + AND l2.status IN ('sent', 'checking') + ) + """, (max_attempts,)) + groups = [dict(r) for r in cur.fetchall()] + + for group in groups: + group_id = str(group["id"]) + name = group.get("customer_name") or group.get("group_key", "—") + phone = group.get("customer_phone", "") + attempts = group.get("attempts") or max_attempts + + log.warning(f"Group {group_id}: попыток {attempts}/{max_attempts}, статус остался link_ready " + f"→ manual_required (клиент SMS не получил)") + update_order_group(conn, group_id, { + "notification_status": "manual_required", + "last_sms_error": f"SMS не доставлена после {attempts} попыток — нужен менеджер", + }) + send_telegram( + f"🔧 Нужен менеджер\n{name} ({phone})\n" + f"SMS не доставлена после {attempts} попыток — клиенту не ушла ссылка", + tg_chat, + ) + + if groups: + log.info(f"Step 4: {len(groups)} groups released from link_ready → manual_required") + return len(groups) + # ─── Main ──────────────────────────────────────────────────────────────────── def main(): @@ -555,8 +765,9 @@ def main(): sent = step_send_new(conn, settings) delivered = step_check_status(conn, settings) step_handle_expired(conn, settings) + released = step_release_exhausted(conn, settings) - log.info(f"Run summary: sent={sent}, delivered={delivered}") + log.info(f"Run summary: sent={sent}, delivered={delivered}, released_to_manual={released}") if sent > 0 or delivered > 0: send_telegram( diff --git a/scripts/sms_first_campaign_ref.py b/scripts/sms_first_campaign_ref.py index 473f6da..d2051a5 100644 --- a/scripts/sms_first_campaign_ref.py +++ b/scripts/sms_first_campaign_ref.py @@ -84,6 +84,22 @@ def get_db_conn(): user=DB_USER, password=DB_PASS, ) +def apply_schedule_work_days(conn, settings): + """Дни отправки — из «Графика работы» в ЛК (единый источник правды). + + Если график прочитать не удалось, остаются прежние дни кампании. + """ + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") + row = cur.fetchone() or {} + days = row.get("sms_days") or [] + if days: + settings["work_days"] = ",".join(str(int(d)) for d in days) + except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика + print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}") + return settings + def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = 'first_sms'") @@ -99,7 +115,8 @@ def load_settings(conn): "test_mode": True, "test_phone": "79788382260", } - return dict(row) + settings = dict(row) + return apply_schedule_work_days(conn, settings) # ─── SMS API ───────────────────────────────────────────────────────────────── diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py index 99df6e0..909b254 100644 --- a/scripts/sms_manual_campaign.py +++ b/scripts/sms_manual_campaign.py @@ -4,14 +4,21 @@ SuperSam — Manual Campaign (State Machine) Заменяет n8n workflow "Ручное управление" Логика: - Если после второй SMS (notification_status = 'second_sms_sent') - прошло auto_manual_after_hours и клиент не согласовал + Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч) + после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и (delivery_status = 'pending_confirmation'): → notification_status = 'manual_required' → delivery_status = 'manual_confirmation_required' - → next_notification_check_at = +3 мин → Telegram-уведомление + Ожидание считаем только по тем каналам, что реально включены в системе + (load_channels): + • второе приглашение включено → ждём, пока оно уйдёт; + • автодозвон включён и номеру звонить можно → ждём звонок, отсчёт + начнётся от него (позвонили позже — ожидание начинается заново); + • канал выключен / звонить нельзя (не звонить, гео, исключение) — + не ждём его и переводим по сроку ожидания. + Расписание: 9-21, Пн-Сб (настраивается из админки) Скрипт НЕ отправляет SMS — только перевод в ручное управление. """ @@ -61,13 +68,30 @@ def get_db_conn(): user=DB_USER, password=DB_PASS, ) +def apply_schedule_work_days(conn, settings): + """Дни отправки — из «Графика работы» в ЛК (единый источник правды). + + Если график прочитать не удалось, остаются прежние дни кампании. + """ + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") + row = cur.fetchone() or {} + days = row.get("sms_days") or [] + if days: + settings["work_days"] = ",".join(str(int(d)) for d in days) + except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика + print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}") + return settings + def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) row = cur.fetchone() if not row: return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID} - return dict(row) + settings = dict(row) + return apply_schedule_work_days(conn, settings) # ─── Telegram ──────────────────────────────────────────────────────────────── @@ -94,53 +118,129 @@ def is_within_work_hours(settings): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_manual(conn): - """Группы, где срок ожидания истёк и нужно перевести в ручное управление. +def get_groups_to_manual(conn, channels=None, manual_after_hours=3): + """Группы, где клиент не выбрал дату/время и пора в ручную работу. - Сценарии: - 1. second_sms_sent + next_check в прошлом → клиент не согласовал после 2х SMS - 2. first_sms_sent + next_check в прошлом + >3h + second_sms_sent_at IS NOT NULL → клиент не согласовал после 2й SMS - 3. link_ready + next_check в прошлом + нет активных SMS → зависшая группа - 4. sms_sending/second_sms_sending/checking + >3h без доставки → зависшая группа + Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого + приглашения: если клиенту позвонили позже, ожидание начинается заново. + + Каналы берём из настроек: ждём только те шаги, которые реально включены. + • второе приглашение включено → ждём, пока оно уйдёт; + • автодозвон включён и этому номеру звонить можно → ждём звонка; + • канал выключен (или звонить нельзя: не звонить/гео/исключение) — + не ждём его и переводим в ручную работу по сроку ожидания. + + Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие + заказы в системе ведёт только менеджер, автоматика их не трогает. """ + channels = channels or {} with conn.cursor(cursor_factory=RealDictCursor) as cur: - cur.execute(""" + cur.execute(r""" + WITH contact AS ( + SELECT og.id, + GREATEST( + COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'), + COALESCE(og.called_at, TIMESTAMP 'epoch'), + COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l + WHERE l.order_group_id = og.id + AND COALESCE(l.status, '') IN + ('sent','checking','delivered','send_failed','error','limit_exceeded')), + TIMESTAMP 'epoch'), + COALESCE((SELECT max(a.created_at) FROM autocall_log a + WHERE a.order_group_id = og.id), TIMESTAMP 'epoch') + ) AS last_contact_at, + (SELECT count(*) FROM autocall_log a + WHERE a.order_group_id = og.id) AS call_attempts + FROM order_groups og + ), + callable AS ( + SELECT og.id, + (og.autocall_opt_out = false + AND COALESCE(og.customer_phone, '') <> '' + AND NOT EXISTS ( + SELECT 1 FROM autocall_city_rules cr + WHERE cr.city = og.town AND cr.calls_enabled = false) + AND NOT EXISTS ( + SELECT 1 FROM sms_city_rules cr2 + WHERE cr2.city = og.town AND cr2.enabled = false + AND cr2.campaign_type IN ('*', 'autocall_slot') + AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE) + AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE)) + AND NOT EXISTS ( + SELECT 1 FROM autocall_exclusions e + WHERE e.active = true + AND ((e.rule_type = 'phone' + AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) + OR (e.rule_type = 'prefix' + AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) + LIKE e.value || '%%'))) + ) AS can_call + FROM order_groups og + ) SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.notification_status, og.delivery_link, og.first_sms_sent_at, og.second_sms_sent_at, og.sms_sent_at, og.next_notification_check_at, - og.sms_attempts + og.sms_attempts, + c.last_contact_at, c.call_attempts, cl.can_call FROM order_groups og + JOIN contact c ON c.id = og.id + JOIN callable cl ON cl.id = og.id WHERE og.delivery_status = 'pending_confirmation' - AND ( - -- 1. second_sms_sent + срок истёк - (COALESCE(og.notification_status, '') = 'second_sms_sent' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())) - -- 2. first_sms_sent + срок истёк (>3h после отправки) - -- ВАЖНО: вторая SMS должна быть уже отправлена (second_sms_sent_at IS NOT NULL) - -- Иначе second_campaign не успеет отправить вторую SMS - OR (COALESCE(og.notification_status, '') = 'first_sms_sent' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) - AND og.first_sms_sent_at < NOW() - INTERVAL '3 hours' - AND og.second_sms_sent_at IS NOT NULL) - -- 3. link_ready + next_check в прошлом + нет активных SMS в логе - -- FIX-4: grace period 30 мин — manual не крадёт свежие link_ready до first_campaign - OR (COALESCE(og.notification_status, '') = 'link_ready' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW() - INTERVAL '30 minutes') - AND NOT EXISTS ( - SELECT 1 FROM sms_campaign_log scl - WHERE scl.order_group_id = og.id - AND scl.status IN ('sent', 'checking') - AND scl.created_at > NOW() - INTERVAL '2 hours' - )) - -- 4. sms_sending/checking + >3h без доставки (зависло) - OR (COALESCE(og.notification_status, '') IN ('sms_sending', 'second_sms_sending', 'checking') - AND COALESCE(og.sms_sent_at, og.created_at) < NOW() - INTERVAL '3 hours') + -- Клиент уже выбрал дату/время — в ручную не переводим + AND og.delivery_date IS NULL + -- Контакта не было — отсчитывать не от чего + AND c.last_contact_at > TIMESTAMP 'epoch' + -- Срок ожидания истёк от последнего контакта + AND NOW() - c.last_contact_at >= make_interval(hours => %s) + -- Второе приглашение ждём, только если канал включён + AND (NOT %s OR og.second_sms_sent_at IS NOT NULL) + -- Звонок ждём, только если автодозвон включён и звонить можно + AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call) + -- Гео-исключения — общие для 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 ('*', 'manual') + AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) + AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) ) - ORDER BY og.created_at ASC - """) + ORDER BY c.last_contact_at ASC + """, ( + int(manual_after_hours or 3), + bool(channels.get("second_sms")), + bool(channels.get("autocall")), + )) return [dict(r) for r in cur.fetchall()] + +def load_channels(conn): + """Какие каналы связи сейчас включены в системе. + + `first_sms` — первое приглашение, `second_sms` — повторное SMS, + `autocall` — робот-автодозвон (настройки кампании 'autocall_slot'). + От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги. + """ + channels = {"first_sms": True, "second_sms": False, "autocall": False} + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT campaign_type, enabled + FROM sms_campaign_settings + WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot') + """) + for campaign_type, enabled in cur.fetchall(): + if campaign_type == "autocall_slot": + channels["autocall"] = bool(enabled) + else: + channels[campaign_type] = bool(enabled) + except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS + pass + return channels + def update_order_group(conn, group_id, fields): with conn.cursor() as cur: set_parts = [] @@ -157,35 +257,55 @@ def update_order_group(conn, group_id, fields): # ─── Основная логика ───────────────────────────────────────────────────────── -def step_move_to_manual(conn, settings): +def step_move_to_manual(conn, settings, channels=None): """Перевод групп в ручное управление""" tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) - delay_minutes = settings.get("auto_manual_after_hours", 3) + wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3) + channels = channels or load_channels(conn) - groups = get_groups_to_manual(conn) - log.info(f"Step 1: {len(groups)} groups to move to manual") + groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours) + log.info( + f"Step 1: {len(groups)} groups to move to manual " + f"(ожидание {wait_hours} ч от последнего контакта; каналы: " + f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, " + f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, " + f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})" + ) moved = 0 + dry_run = os.environ.get("DRY_RUN") == "1" for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") phone = group.get("customer_phone", "") delivery_link = group.get("delivery_link", "") + attempts = group.get("sms_attempts") or 1 - log.info(f"Group {group_id}: moving to manual_required ({name})") + if dry_run: + log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})") + moved += 1 + continue + + log.info( + f"Group {group_id}: moving to manual_required ({name}); " + f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}" + ) update_order_group(conn, group_id, { "notification_status": "manual_required", "delivery_status": "manual_confirmation_required", - "sms_attempts": 2, + # Попытки НЕ подменяем: показываем столько, сколько реально отправлено + "sms_attempts": attempts, "last_sms_error": None, - "next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'", + # Дальше ждать нечего — проверки по этому заказу закончились + "next_notification_check_at": None, "status": "manual_required", }) send_telegram( f"🔧 Ручное управление\n{name} ({phone})\n" - f"Клиент не согласовал доставку после двух SMS\n" + f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта " + f"({'звонки' if channels.get('autocall') else 'SMS'})\n" f"Ссылка: {delivery_link}", tg_chat, ) @@ -247,8 +367,9 @@ def main(): cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'manual'") conn.commit() log.info("RESTART: manual campaign — will process all eligible groups now") - else: - moved = step_move_to_manual(conn, settings) + + channels = load_channels(conn) + moved = step_move_to_manual(conn, settings, channels=channels) log.info(f"Run summary: moved_to_manual={moved}") diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index d15e15b..1b8f7c1 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -27,6 +27,12 @@ import requests import psycopg2 from psycopg2.extras import RealDictCursor +try: # лимитер sms.ru (модуль лежит рядом со скриптом) + from sms_rate_limit import check_send_allowed, alert_due +except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше + check_send_allowed = None + alert_due = None + # ─── Конфигурация ──────────────────────────────────────────────────────────── DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") @@ -51,6 +57,65 @@ DELIVERED_CODE = "103" DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} +# ─── Тексты SMS ────────────────────────────────────────────────────────────── +# Шаблоны живут в админке (sms_campaign_settings): +# sms_text_template — текст для ДОСТАВКИ +# sms_text_template_pickup — текст для САМОВЫВОЗА +# Подстановки: {link}/{ссылка} — ссылка согласования, +# {order}/{номер}/{номер_доставки} — номер доставки (010796), +# {code}/{код}/{код_выдачи} — код выдачи (7598). +# Пустой шаблон → DEFAULT_TEXT_*. + +DEFAULT_TEXT_DELIVERY = ( + "Ваш заказ переведён на платное хранение. Стоимость: 300 ₽/день. " + "Заберите заказ или согласуйте доставку: {link}" +) +DEFAULT_TEXT_PICKUP = ( + "Заказ переведён на платное хранение. Номер доставки {order}, код выдачи {code}. " + "Заберите со склада или согласуйте дату самовывоза: {link}" +) + + +def first_order_digits(value): + """Номер доставки из order_numbers («ТП\\-010722» → «010722»).""" + items = value if isinstance(value, (list, tuple)) else [value] + for item in items: + digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit()) + if digits: + return digits + return "" + + +def apply_sms_placeholders(text, group, link): + """Подстановка переменных шаблона в текст SMS.""" + if not text: + return text + + order = first_order_digits(group.get("order_numbers")) + code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit()) + replacements = ( + ("{link}", link), ("{ссылка}", link), + ("{номер_доставки}", order), ("{номер}", order), ("{order}", order), + ("{код_выдачи}", code), ("{код}", code), ("{code}", code), + ) + + result = text + for token, value in replacements: + result = result.replace(token, value) + return result.strip() + + +def build_sms_text(group, settings): + """Текст SMS о платном хранении по типу отгрузки.""" + link = group.get("delivery_link") or "" + pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup" + template = (settings.get( + "sms_text_template_pickup" if pickup else "sms_text_template" + ) or "").strip() + if not template: + template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY + return apply_sms_placeholders(template, group, link) + # ─── Логирование ───────────────────────────────────────────────────────────── logging.basicConfig( @@ -71,6 +136,22 @@ def get_db_conn(): user=DB_USER, password=DB_PASS, ) +def apply_schedule_work_days(conn, settings): + """Дни отправки — из «Графика работы» в ЛК (единый источник правды). + + Если график прочитать не удалось, остаются прежние дни кампании. + """ + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") + row = cur.fetchone() or {} + days = row.get("sms_days") or [] + if days: + settings["work_days"] = ",".join(str(int(d)) for d in days) + except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика + print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}") + return settings + def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) @@ -82,7 +163,8 @@ def load_settings(conn): "telegram_chat_id": TELEGRAM_CHAT_ID, "sms_api_id": SMS_API_ID, "work_hours_start": 8, "work_hours_end": 21, "work_days": "1,2,3,4,5", } - return dict(row) + settings = dict(row) + return apply_schedule_work_days(conn, settings) # ─── SMS API ───────────────────────────────────────────────────────────────── @@ -178,11 +260,21 @@ def get_groups_to_send(conn): cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.customer_phone_normalized, og.delivery_link, og.notification_status, - og.paid_storage_at + og.paid_storage_at, og.delivery_type, og.town, + og.order_numbers, og.pickup_code FROM order_groups og LEFT JOIN sms_city_profiles scp ON scp.city = og.town WHERE og.delivery_status = 'paid_storage' AND og.paid_storage_at IS NOT NULL + -- Гео-исключения — общие для 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 ('*', 'paid_storage') + 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 COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent') AND og.delivery_link IS NOT NULL AND og.delivery_link != '' @@ -278,6 +370,9 @@ def step_send_new(conn, settings): log.info(f"Step 1: {len(groups)} groups to send paid_storage SMS") sent_count = 0 + limited_count = 0 + limited_last = None + limited_info = None for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") @@ -295,17 +390,24 @@ def step_send_new(conn, settings): log.info(f"Group {group_id}: already has recent paid_storage SMS, skipping") continue - sms_text = ( - f"Ваш заказ переведён на платное хранение. " - f"Стоимость: 300 ₽/день. " - f"Заберите заказ или согласуйте доставку: {delivery_link}" - ) + sms_text = build_sms_text(group, settings) + log.info(f"Group {group_id}: paid_storage SMS ({group.get('delivery_type') or 'delivery'}): {sms_text!r}") send_phone = phone if settings.get("test_mode", True): send_phone = settings.get("test_phone", "79788382260") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") + # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) --- + if check_send_allowed is not None: + _ok, _reason, _info = check_send_allowed(conn, send_phone, settings) + if not _ok: + limited_count += 1 + limited_last = _reason + limited_info = _info + log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}") + continue # сообщение не потеряно: уйдёт в следующий запуск + log.info(f"Sending paid_storage SMS to {name} (orig={phone}, send={send_phone})") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) @@ -314,6 +416,8 @@ def step_send_new(conn, settings): campaign_type=CAMPAIGN_TYPE, order_group_id=group_id, customer_phone=phone, + sent_to=send_phone, + was_test_mode=bool(settings.get("test_mode", True)), sms_id=sms_id, sms_text=sms_text, status="sent", @@ -342,6 +446,22 @@ def step_send_new(conn, settings): update_order_group(conn, group_id, {"last_sms_error": error[:200]}) send_telegram(f"❌ SMS платное хранение не отправлена: {name} ({phone})\nКод: {code}", tg_chat) + if limited_count: + _win = (limited_info or {}).get("window") + _cap = (limited_info or {}).get("per_number") + _acct = (limited_info or {}).get("account") + msg = ( + f"SMS придержаны лимитом sms.ru: {limited_count} шт. " + f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"причина: {limited_last}; " + f"сообщения не потеряны — уйдут в следующий запуск." + ) + log.warning("Rate limit held %s SMS: %s", limited_count, limited_last) + if alert_due is not None and alert_due(conn, settings): + send_telegram(msg, tg_chat) + else: + log.info("Rate limit Telegram alert suppressed (throttled)") + return sent_count def step_check_status(conn, settings): diff --git a/scripts/sms_rate_limit.py b/scripts/sms_rate_limit.py new file mode 100644 index 0000000..36d0451 --- /dev/null +++ b/scripts/sms_rate_limit.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Проактивный лимитер отправки SMS для SuperSam (правило sms.ru). + +sms.ru блокирует номер/аккаунт, если на ОДИН номер уходит слишком много SMS +(«бомбинг»): по умолчанию **не более 8 сообщений на номер за 10 минут**. +Сейчас кампании узнают об этом только постфактум — по коду ошибки 231/232, +после чего группа помечается send_failed, а часть сообщений уже потеряна. +Этот модуль проверяет лимит ДО отправки и придерживает сообщение: оно не +теряется, а уходит в следующий запуск кампании. + +Правило и окно настраиваются в sms_campaign_settings (админка/БД): + sms_limit_per_number_10min — сколько SMS на один номер за окно + (по умолчанию 8; 0 = проверка выключена) + sms_limit_account_10min — сколько SMS всего за окно + (по умолчанию 0 = выключено) + sms_rate_window_seconds — окно в секундах (по умолчанию 600) + +Что считается отправкой: строки sms_campaign_log, реально принятые sms.ru +(sms_id IS NOT NULL) в пределах окна. Получатель берётся как +COALESCE(NULLIF(sent_to,''), customer_phone) — то, кому SMS ушла на самом деле; +в тестовом режиме все сообщения идут на test_phone и считаются за него, как их +видит sms.ru. Номера сравниваются по последним 10 цифрам (8/7/+7 и т.п.). + +Поведение при сбое БД — fail-open: недоступный счётчик не должен останавливать +доставку, в лог пишется ERROR. Повторные Telegram-алерты подавляются +(alert_due: не чаще одного раза в час, отметка в sms_campaign_settings). +""" + +import logging + +log = logging.getLogger("sms_rate_limit") + +DEFAULT_PER_NUMBER = 8 # правило sms.ru: 8 сообщений на номер +DEFAULT_WINDOW_SECONDS = 600 # ... за 10 минут +DEFAULT_ACCOUNT = 0 # 0 = аккаунтный лимит не проверяем +ALERT_THROTTLE_SECONDS = 3600 # не чаще одного алерта в час + +COUNT_SQL = """ + SELECT COUNT(*) FROM sms_campaign_log + WHERE created_at > NOW() - (%(win)s || ' seconds')::interval + AND sms_id IS NOT NULL + {phone_filter} +""" + +PHONE_FILTER = ("AND right(regexp_replace(" + "COALESCE(NULLIF(sent_to, ''), customer_phone), '\\D', '', 'g'), 10) " + "= right(regexp_replace(%(phone)s, '\\D', '', 'g'), 10)") + + +def _int(value, default): + """Аккуратно приводит настройку к int (None, '' и мусор → default).""" + try: + if value is None or value == "": + return int(default) + return int(value) + except (TypeError, ValueError): + return int(default) + + +def rules(settings): + """Правила лимита из настроек кампании: (на номер, всего, окно).""" + s = settings or {} + return ( + _int(s.get("sms_limit_per_number_10min"), DEFAULT_PER_NUMBER), + _int(s.get("sms_limit_account_10min"), DEFAULT_ACCOUNT), + _int(s.get("sms_rate_window_seconds"), DEFAULT_WINDOW_SECONDS), + ) + + +def _count(conn, window_seconds, phone=None): + sql = COUNT_SQL.format(phone_filter=PHONE_FILTER if phone else "") + params = {"win": window_seconds} + if phone: + params["phone"] = phone + with conn.cursor() as cur: + cur.execute(sql, params) + return cur.fetchone()[0] + + +def check_send_allowed(conn, phone, settings): + """Можно ли отправить ещё одну SMS на этот номер. + + Возвращает (ok, reason, info): + ok — True (можно) / False (придержать) + reason — короткое объяснение для лога и Telegram + info — {'per_number': n, 'account': n, 'window': w} для алерта + """ + per_number, account, window = rules(settings) + info = {"per_number": per_number, "account": account, "window": window} + + if per_number <= 0 and account <= 0: + return True, "лимит выключен в настройках", info + if not phone: + return True, "пустой номер", info + + try: + if per_number > 0: + n = _count(conn, window, phone) + info["number_count"] = n + if n >= per_number: + return False, (f"номер {phone}: уже {n} SMS за {window}с " + f"(лимит {per_number})"), info + if account > 0: + total = _count(conn, window) + info["account_count"] = total + if total >= account: + return False, (f"аккаунт: уже {total} SMS за {window}с " + f"(лимит {account})"), info + except Exception as exc: # noqa: BLE001 — fail-open, доставку не стопим + log.error("sms rate limit check failed (fail-open): %s", exc) + info["db_error"] = str(exc) + return True, f"проверка лимита не удалась ({exc}) — отправляем", info + + return True, "лимит не выбран", info + + +def alert_due(conn, settings, throttle_seconds=ALERT_THROTTLE_SECONDS): + """Можно ли отправить Telegram-алерт про придержанные SMS (не чаще раза в час). + + Отметка пишется в sms_campaign_settings.last_limit_alert_at, таблица + кампаний — единственная «своя» запись под рукой. Ошибки БД → False, + чтобы не заваливать чат алертами. + """ + campaign_type = (settings or {}).get("campaign_type") + if not campaign_type: + return True # настроек нет — не рискуем потерять уведомление + try: + with conn.cursor() as cur: + cur.execute(""" + UPDATE sms_campaign_settings + SET last_limit_alert_at = NOW() + WHERE campaign_type = %s + AND (last_limit_alert_at IS NULL + OR last_limit_alert_at < NOW() - (%s || ' seconds')::interval) + RETURNING id + """, (campaign_type, throttle_seconds)) + ok = cur.fetchone() is not None + conn.commit() + return ok + except Exception as exc: # noqa: BLE001 + conn.rollback() + log.error("alert_due failed: %s", exc) + return False diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index da4bde4..e41163c 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -35,6 +35,12 @@ import requests import psycopg2 from psycopg2.extras import RealDictCursor +try: # лимитер sms.ru (модуль лежит рядом со скриптом) + from sms_rate_limit import check_send_allowed, alert_due +except ImportError: # noqa: BLE001 — без лимитера кампания работает как раньше + check_send_allowed = None + alert_due = None + # ─── Конфигурация ──────────────────────────────────────────────────────────── DB_HOST = os.environ.get("DB_HOST", "10.0.4.12") @@ -63,6 +69,62 @@ DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} # Коды превышения лимитов (нельзя повторять отправку — заблокируют) LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} +# ─── Тексты SMS ────────────────────────────────────────────────────────────── +# Шаблоны живут в админке (sms_campaign_settings): +# sms_text_template — текст для ДОСТАВКИ +# sms_text_template_pickup — текст для САМОВЫВОЗА +# Подстановки: {link}/{ссылка} — ссылка согласования, +# {order}/{номер}/{номер_доставки} — номер доставки (010796), +# {code}/{код}/{код_выдачи} — код выдачи (7598). +# Пустой шаблон → DEFAULT_TEXT_*. + +DEFAULT_TEXT_DELIVERY = "Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {link}" +DEFAULT_TEXT_PICKUP = ( + "Напоминаем: номер доставки {order}, код выдачи {code}. " + "Выберите дату самовывоза по ссылке: {link}" +) + + +def first_order_digits(value): + """Номер доставки из order_numbers («ТП\\-010722» → «010722»).""" + items = value if isinstance(value, (list, tuple)) else [value] + for item in items: + digits = "".join(ch for ch in str(item or "").replace("\\", "") if ch.isdigit()) + if digits: + return digits + return "" + + +def apply_sms_placeholders(text, group, link): + """Подстановка переменных шаблона в текст SMS.""" + if not text: + return text + + order = first_order_digits(group.get("order_numbers")) + code = "".join(ch for ch in str(group.get("pickup_code") or "") if ch.isdigit()) + replacements = ( + ("{link}", link), ("{ссылка}", link), + ("{номер_доставки}", order), ("{номер}", order), ("{order}", order), + ("{код_выдачи}", code), ("{код}", code), ("{code}", code), + ) + + result = text + for token, value in replacements: + result = result.replace(token, value) + return result.strip() + + +def build_sms_text(group, settings): + """Текст второй SMS по типу отгрузки: самовывоз → pickup-шаблон, иначе обычный.""" + link = group.get("delivery_link") or "" + pickup = (group.get("delivery_type") or "delivery").strip().lower() == "pickup" + template = (settings.get( + "sms_text_template_pickup" if pickup else "sms_text_template" + ) or "").strip() + if not template: + template = DEFAULT_TEXT_PICKUP if pickup else DEFAULT_TEXT_DELIVERY + return apply_sms_placeholders(template, group, link) + # ─── Логирование ───────────────────────────────────────────────────────────── logging.basicConfig( @@ -83,6 +145,22 @@ def get_db_conn(): user=DB_USER, password=DB_PASS, ) +def apply_schedule_work_days(conn, settings): + """Дни отправки — из «Графика работы» в ЛК (единый источник правды). + + Если график прочитать не удалось, остаются прежние дни кампании. + """ + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") + row = cur.fetchone() or {} + days = row.get("sms_days") or [] + if days: + settings["work_days"] = ",".join(str(int(d)) for d in days) + except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика + print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}") + return settings + def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) @@ -104,7 +182,8 @@ def load_settings(conn): "second_sms_delay_hours": 3, "auto_manual_after_hours": 3, } - return dict(row) + settings = dict(row) + return apply_schedule_work_days(conn, settings) # ─── SMS API ───────────────────────────────────────────────────────────────── @@ -190,7 +269,7 @@ def send_telegram(message, chat_id): # ─── State Machine ─────────────────────────────────────────────────────────── -def get_groups_to_send(conn): +def get_groups_to_send(conn, max_attempts): """Группы, где первая SMS доставлена, но клиент не согласовал, и пришло время для второй SMS (next_notification_check_at <= NOW()). second_sms_sent_at IS NULL — вторая SMS ещё не отправлена. @@ -198,7 +277,8 @@ def get_groups_to_send(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, - og.customer_phone_normalized, og.delivery_link, og.notification_status + og.customer_phone_normalized, og.delivery_link, og.notification_status, + og.delivery_type, og.town, og.order_numbers, og.pickup_code FROM order_groups og LEFT JOIN sms_city_profiles scp ON scp.city = og.town WHERE og.delivery_status = 'pending_confirmation' @@ -209,6 +289,15 @@ def get_groups_to_send(conn): AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) -- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется) AND COALESCE(scp.second_sms_enabled, true) = true + -- Гео-исключения — общие для 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 ('*', 'second_sms') + AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) + AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) + ) -- Нет активной второй SMS в логе AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl @@ -217,8 +306,15 @@ def get_groups_to_send(conn): AND scl.status IN ('sent', 'checking') AND scl.created_at > NOW() - INTERVAL '2 hours' ) + -- Лимит попыток: считаем ВСЕ строки кампании по группе, а не строку, + -- которую проверяем (иначе счётчик всегда 1 и повторы бесконечны) + AND ( + SELECT count(*) FROM sms_campaign_log scl2 + WHERE scl2.order_group_id = og.id + AND scl2.campaign_type = 'second_sms' + ) < %s ORDER BY og.created_at ASC - """) + """, (max_attempts,)) return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): @@ -301,6 +397,21 @@ def update_order_group(conn, group_id, fields): # ─── Проверка рабочего времени ──────────────────────────────────────────────── +def count_sms_attempts(conn, group_id, campaign_type=CAMPAIGN_TYPE): + """Сколько раз эту SMS уже пытались отправить (строк в логе кампании). + + Номер попытки — свойство ГРУППЫ, а не строки лога: каждая попытка создаёт + новую строку, поэтому в строке всегда было бы attempts=1, условие + «attempts < max_attempts» выполнялось бы вечно и вторая SMS уходила бы + каждые 5 минут бесконечно. + """ + with conn.cursor() as cur: + cur.execute(""" + SELECT COUNT(*) FROM sms_campaign_log + WHERE order_group_id = %s AND campaign_type = %s + """, (group_id, campaign_type)) + return cur.fetchone()[0] + def is_within_work_hours(settings): """Проверка: сейчас рабочие часы. settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') @@ -328,11 +439,15 @@ def step_send_new(conn, settings): """Шаг 1: Отправка второй SMS группам, где первая доставлена, но нет согласования""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) + max_attempts = settings.get("max_attempts", 2) - groups = get_groups_to_send(conn) + groups = get_groups_to_send(conn, max_attempts) log.info(f"Step 1: {len(groups)} groups to send second SMS") sent_count = 0 + limited_count = 0 + limited_last = None + limited_info = None for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") @@ -352,7 +467,8 @@ def step_send_new(conn, settings): log.info(f"Group {group_id}: already has recent second SMS in log, skipping") continue - sms_text = f"Ваш заказ готов к доставке. Выберите дату доставки по ссылке: {delivery_link}" + sms_text = build_sms_text(group, settings) + log.info(f"Group {group_id}: second SMS ({group.get('delivery_type') or 'delivery'}), town={group.get('town') or '—'}: {sms_text!r}") # ТЕСТОВЫЙ РЕЖИМ send_phone = phone @@ -360,7 +476,19 @@ def step_send_new(conn, settings): send_phone = settings.get("test_phone", "79788382260") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") + # --- Лимит sms.ru: не больше N SMS на один номер за окно (иначе блокировка) --- + if check_send_allowed is not None: + _ok, _reason, _info = check_send_allowed(conn, send_phone, settings) + if not _ok: + limited_count += 1 + limited_last = _reason + limited_info = _info + log.warning(f"Group {group_id}: SMS HELD by sms.ru rate limit - {_reason}") + continue # сообщение не потеряно: уйдёт в следующий запуск + log.info(f"Sending second SMS to {name} (orig={phone}, send={send_phone})") + attempt_no = count_sms_attempts(conn, group_id) + 1 + log.info(f"Group {group_id}: second SMS attempt {attempt_no}/{max_attempts}") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) if sms_id: @@ -368,11 +496,13 @@ def step_send_new(conn, settings): campaign_type=CAMPAIGN_TYPE, order_group_id=group_id, customer_phone=phone, + sent_to=send_phone, + was_test_mode=bool(settings.get("test_mode", True)), sms_id=sms_id, sms_text=sms_text, status="sent", sms_code=code, - attempts=1, + attempts=attempt_no, ) # ДВОЙНАЯ ЗАЩИТА: notification_status → second_sms_sending update_order_group(conn, group_id, { @@ -390,7 +520,7 @@ def step_send_new(conn, settings): sms_text=sms_text, status="send_failed", sms_code=code, - attempts=1, + attempts=attempt_no, error_message=error, ) log.error(f"Group {group_id}: second SMS send failed (code={code}): {error[:200]}") @@ -399,6 +529,22 @@ def step_send_new(conn, settings): }) send_telegram(f"❌ Вторая SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) + if limited_count: + _win = (limited_info or {}).get("window") + _cap = (limited_info or {}).get("per_number") + _acct = (limited_info or {}).get("account") + msg = ( + f"SMS придержаны лимитом sms.ru: {limited_count} шт. " + f"(окно {_win}с, лимит {_cap} на номер, аккаунт {_acct}); " + f"причина: {limited_last}; " + f"сообщения не потеряны — уйдут в следующий запуск." + ) + log.warning("Rate limit held %s SMS: %s", limited_count, limited_last) + if alert_due is not None and alert_due(conn, settings): + send_telegram(msg, tg_chat) + else: + log.info("Rate limit Telegram alert suppressed (throttled)") + return sent_count def step_check_status(conn, settings): @@ -419,7 +565,7 @@ def step_check_status(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 code, raw, api_code = check_sms_status(sms_id, api_id) @@ -501,7 +647,7 @@ def step_handle_expired(conn, settings): group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") - attempts = item.get("attempts", 1) + attempts = item.get("attempts") or 1 log.warning(f"Group {group_id}: second SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}") update_sms_log(conn, log_id, status="expired", diff --git a/scripts/sms_timer_manager.sh b/scripts/sms_timer_manager.sh index 5302c58..bfe613b 100755 --- a/scripts/sms_timer_manager.sh +++ b/scripts/sms_timer_manager.sh @@ -6,11 +6,19 @@ set -euo pipefail -DB_HOST="10.0.4.12" +# Адрес контейнера supabase-db меняется при пересоздании, поэтому берём его динамически +# (как в sms_run_with_db.sh). До 29.09.2026 здесь был зашит 10.0.4.12, к которому нет маршрута: +# скрипт каждую минуту писал «Failed to query DB, skipping» с 01.07.2026, из-за чего переключатели +# таймеров и кнопка «Запустить сейчас» в админке молча ничего не делали. +DB_HOST="$(docker inspect supabase-db --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null)" DB_PORT="5432" DB_NAME="postgres" DB_USER="supabase_admin" DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479" +if [ -z "$DB_HOST" ]; then + echo "$(date): ERROR: Cannot resolve supabase-db IP" + exit 1 +fi declare -A TIMERS=( ["first_sms"]="sms-first-campaign.timer" diff --git a/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246 b/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246 new file mode 100755 index 0000000..5302c58 --- /dev/null +++ b/scripts/sms_timer_manager.sh.bak-dbhost-20260929-080246 @@ -0,0 +1,84 @@ +#!/bin/bash +# sms_timer_manager.sh — reconciles DB flags with systemd timers +# Runs every 1 min via cron: +# 1. timer_active → enable/disable systemd timer +# 2. run_requested → run campaign script immediately (script resets flag itself) + +set -euo pipefail + +DB_HOST="10.0.4.12" +DB_PORT="5432" +DB_NAME="postgres" +DB_USER="supabase_admin" +DB_PASS="4fe80bb21c7c3d17a8d8b226adf7a479" + +declare -A TIMERS=( + ["first_sms"]="sms-first-campaign.timer" + ["second_sms"]="sms-second-campaign.timer" + ["manual"]="sms-manual-campaign.timer" + ["paid_storage"]="sms-paid-storage-campaign.timer" +) +declare -A SERVICES=( + ["first_sms"]="sms-first-campaign.service" + ["second_sms"]="sms-second-campaign.service" + ["manual"]="sms-manual-campaign.service" + ["paid_storage"]="sms-paid-storage-campaign.service" +) +declare -A SCRIPTS=( + ["first_sms"]="/opt/supersam/scripts/sms_first_campaign.py" + ["second_sms"]="/opt/supersam/scripts/sms_second_campaign.py" + ["manual"]="/opt/supersam/scripts/sms_manual_campaign.py" + ["paid_storage"]="/opt/supersam/scripts/sms_paid_storage_campaign.py" +) + +QUERY="SELECT campaign_type, timer_active, run_requested FROM sms_campaign_settings" +RESULTS=$(PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -A -F '|' -c "$QUERY" 2>/dev/null || echo "") + +if [ -z "$RESULTS" ]; then + echo "$(date): Failed to query DB, skipping" + exit 0 +fi + +while IFS='|' read -r campaign_type timer_active run_requested; do + [ -z "$campaign_type" ] && continue + timer_name="${TIMERS[$campaign_type]:-}" + service_name="${SERVICES[$campaign_type]:-}" + script_path="${SCRIPTS[$campaign_type]:-}" + [ -z "$timer_name" ] && continue + + if ! systemctl list-unit-files "$timer_name" 2>/dev/null | grep -q "$timer_name"; then + continue + fi + + # ── 1. Timer enable/disable ── + is_active=$(systemctl is-active "$timer_name" 2>/dev/null || echo "inactive") + + if [ "$timer_active" = "t" ] || [ "$timer_active" = "true" ]; then + if [ "$is_active" != "active" ]; then + systemctl enable --now "$timer_name" 2>/dev/null + echo "$(date): ENABLED $timer_name for $campaign_type" + fi + else + if [ "$is_active" = "active" ]; then + systemctl disable --now "$timer_name" 2>/dev/null + echo "$(date): DISABLED $timer_name for $campaign_type" + fi + fi + + # ── 2. Run requested → immediate execution ── + # Don't reset flag here — script reads it from DB and resets after processing + if [ "$run_requested" = "t" ] || [ "$run_requested" = "true" ]; then + is_running=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive") + if [ "$is_running" = "active" ]; then + echo "$(date): $campaign_type already running, skipping run_requested" + continue + fi + + echo "$(date): RUN REQUESTED — starting $campaign_type immediately" + systemctl start "$service_name" 2>/dev/null || { + nohup python3 "$script_path" >> "/var/log/supersam-sms-${campaign_type}.log" 2>&1 & + echo "$(date): Started $campaign_type via nohup fallback" + } + fi + +done <<< "$RESULTS" \ No newline at end of file