SMS: проактивный лимитер sms.ru (8 SMS на номер за 10 минут) перед отправкой
This commit is contained in:
parent
eb1b39e035
commit
f55b43e716
Binary file not shown.
|
|
@ -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()
|
||||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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_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')
|
||||
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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue