SMS: проактивный лимитер sms.ru (8 SMS на номер за 10 минут) перед отправкой

This commit is contained in:
root 2026-10-04 12:12:16 +00:00
parent eb1b39e035
commit f55b43e716
12 changed files with 1991 additions and 88 deletions

934
scripts/autocall_scheduler.py Executable file
View File

@ -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()

6
scripts/backup-code-s3.sh Normal file → Executable file
View File

@ -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})"

117
scripts/check_autocall_window.py Executable file
View File

@ -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)

View File

@ -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"🔧 <b>Нужен менеджер</b>\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(

View File

@ -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 ─────────────────────────────────────────────────────────────────

View File

@ -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"🔧 <b>Ручное управление</b>\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}")

View File

@ -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):

143
scripts/sms_rate_limit.py Normal file
View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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"