144 lines
7.0 KiB
Python
144 lines
7.0 KiB
Python
#!/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
|