687 lines
32 KiB
Python
687 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
SuperSam — SMS First Campaign (State Machine)
|
||
Заменяет n8n workflow "Первая отправка согласования"
|
||
|
||
Архитектура: state machine через cron (каждые 5 мин)
|
||
Каждый запуск:
|
||
1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS)
|
||
2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration)
|
||
3. Обновляет статусы в order_groups + sms_campaign_log
|
||
|
||
Защита от повторной отправки (ДВОЙНАЯ):
|
||
1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready')
|
||
→ get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready')
|
||
2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h)
|
||
3. Код 231/132 = лимит одинаковых → не повторять
|
||
|
||
needs_check=true — admin нажал "Проверить снова" в UI
|
||
→ скрипт проверяет даже если запись старше max_check_duration
|
||
|
||
Коды sms.ru:
|
||
100 = в очереди, 101 = оператору, 102 = в пути → ждём
|
||
103 = доставлено → цель
|
||
104-108, 130-132, 230-232 = ошибки доставки / лимиты
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import logging
|
||
import time
|
||
import fcntl
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
import requests
|
||
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")
|
||
|
||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "25164483")
|
||
|
||
SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486")
|
||
SMS_SEND_URL = "https://sms.ru/sms/send"
|
||
SMS_STATUS_URL = "https://sms.ru/sms/status"
|
||
|
||
LOG_FILE = "/var/log/supersam-sms-first.log"
|
||
|
||
# ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode)
|
||
# По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone
|
||
# Мегаадмин выключает через админку → SMS идут реальным клиентам
|
||
|
||
# Коды, которые означают "в процессе" (ждём дальше)
|
||
IN_TRANSIT_CODES = {"100", "101", "102"}
|
||
# Код доставки
|
||
DELIVERED_CODE = "103"
|
||
# Коды ошибок доставки (не временные, можно повторять отправку)
|
||
DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"}
|
||
# Коды превышения лимитов (нельзя повторять отправку — заблокируют)
|
||
LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"}
|
||
|
||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
handlers=[
|
||
logging.FileHandler(LOG_FILE),
|
||
logging.StreamHandler(sys.stdout),
|
||
],
|
||
)
|
||
log = logging.getLogger("sms_first")
|
||
|
||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||
|
||
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 = 'first_sms'")
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return {
|
||
"wait_between_checks_seconds": 25,
|
||
"max_check_duration_minutes": 90,
|
||
"max_attempts": 2,
|
||
"enabled": True,
|
||
"telegram_chat_id": "25164483",
|
||
"sms_api_id": SMS_API_ID,
|
||
"test_mode": True,
|
||
"test_phone": "79788382260",
|
||
}
|
||
return dict(row)
|
||
|
||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||
|
||
def normalize_phone(phone):
|
||
"""Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой.
|
||
|
||
Обрабатляет:
|
||
- +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX
|
||
- Текст/имена в поле (Екатерина +7978...) → извлекает только цифры
|
||
- Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр)
|
||
- Минимум 10 цифр (без 7), максимум 11 (с 7)
|
||
"""
|
||
clean = "".join(c for c in str(phone) if c.isdigit())
|
||
if len(clean) < 10:
|
||
return None
|
||
|
||
# If 8 prefix (Russian landline style) → replace with 7
|
||
if clean.startswith("8") and len(clean) == 11:
|
||
clean = "7" + clean[1:]
|
||
# If starts with 7 and is 11 digits → ok
|
||
elif clean.startswith("7") and len(clean) == 11:
|
||
pass
|
||
# If 10 digits (no country code) → prepend 7
|
||
elif len(clean) == 10:
|
||
clean = "7" + clean
|
||
# If too long (multiple numbers concatenated) → take first 10 digits + prepend 7
|
||
elif len(clean) > 11:
|
||
# Try to extract first number: look for 10-digit sequence starting with 9
|
||
# Common case: "9242377967 / 89783225219" → "9242377967" (10 digits)
|
||
clean10 = clean[:10]
|
||
clean = "7" + clean10
|
||
# If starts with 7 but wrong length → prepend 7 to first 10 digits
|
||
elif not clean.startswith("7"):
|
||
clean = "7" + clean
|
||
|
||
return clean
|
||
|
||
def send_sms(phone, message, api_id):
|
||
"""Отправляет SMS, возвращает (sms_id, raw_response)"""
|
||
try:
|
||
clean_phone = normalize_phone(phone)
|
||
resp = requests.post(SMS_SEND_URL, params={
|
||
"api_id": api_id,
|
||
"to": clean_phone,
|
||
}, data={"msg": message}, timeout=30)
|
||
text = resp.text
|
||
log.info(f"SMS send to {clean_phone}: {text[:200]}")
|
||
|
||
lines = text.strip().split("\n")
|
||
status_code = lines[0].strip() if lines else ""
|
||
|
||
if status_code == "100" and len(lines) >= 2:
|
||
sms_id = lines[1].strip()
|
||
return sms_id, text, "100"
|
||
else:
|
||
return None, text, status_code
|
||
except Exception as e:
|
||
log.error(f"SMS send error: {e}")
|
||
return None, str(e), "error"
|
||
|
||
def check_sms_status(sms_id, api_id):
|
||
"""Проверяет статус, возвращает (sms_status_code, raw_response, api_code)"""
|
||
try:
|
||
resp = requests.post(SMS_STATUS_URL, params={
|
||
"api_id": api_id,
|
||
"sms_id": sms_id,
|
||
}, timeout=30)
|
||
text = resp.text
|
||
lines = text.strip().split("\n")
|
||
status_code = lines[0].strip() if lines else ""
|
||
sms_status_code = lines[1].strip() if len(lines) >= 2 else None
|
||
|
||
log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}")
|
||
return sms_status_code, text, status_code
|
||
except Exception as e:
|
||
log.error(f"SMS status check error: {e}")
|
||
return None, str(e), "error"
|
||
|
||
def fetch_balance(api_id):
|
||
"""Получает баланс sms.ru, возвращает (balance_float, raw)"""
|
||
try:
|
||
resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15)
|
||
text = resp.text
|
||
lines = text.strip().split("\n")
|
||
if lines[0].strip() == "100" and len(lines) >= 2:
|
||
return float(lines[1].strip()), text
|
||
return None, text
|
||
except Exception as e:
|
||
log.error(f"Balance fetch error: {e}")
|
||
return None, str(e)
|
||
|
||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||
|
||
def send_telegram(message, chat_id):
|
||
pass # Telegram notifications moved to n8n+Supabase integration
|
||
|
||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||
|
||
def get_groups_to_send(conn):
|
||
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
||
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.sms_attempts
|
||
FROM order_groups og
|
||
WHERE og.status = 'ready_to_launch'
|
||
AND og.delivery_status = 'pending_confirmation'
|
||
AND og.delivery_link IS NOT NULL
|
||
AND og.delivery_link != ''
|
||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
||
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM sms_campaign_log scl
|
||
WHERE scl.order_group_id = og.id
|
||
AND scl.campaign_type = 'first_sms'
|
||
AND scl.status IN ('sent', 'checking')
|
||
AND scl.created_at > NOW() - INTERVAL '2 hours'
|
||
)
|
||
ORDER BY og.created_at ASC
|
||
""")
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
def get_sms_to_check(conn, max_duration_min):
|
||
"""SMS в логе со status='sent'/'checking', которые ещё не доставлены.
|
||
Включает:
|
||
- Записи младше max_duration_min (обычная автопроверка)
|
||
- Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста
|
||
"""
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||
scl.attempts, scl.created_at, scl.sms_code, scl.needs_check,
|
||
og.customer_name, og.group_key
|
||
FROM sms_campaign_log scl
|
||
JOIN order_groups og ON og.id = scl.order_group_id
|
||
WHERE scl.campaign_type = 'first_sms'
|
||
AND scl.status IN ('sent', 'checking')
|
||
AND scl.sms_id IS NOT NULL
|
||
AND (
|
||
scl.created_at > NOW() - INTERVAL '%s minutes'
|
||
OR scl.needs_check = true
|
||
)
|
||
ORDER BY scl.needs_check DESC, scl.created_at ASC
|
||
""" % max_duration_min)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
def get_sms_expired(conn, max_duration_min):
|
||
"""SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены).
|
||
Также считаем общее количество failed попыток для группы.
|
||
"""
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone,
|
||
scl.attempts, scl.created_at,
|
||
og.customer_name, og.group_key, og.delivery_link,
|
||
og.sms_attempts as group_sms_attempts,
|
||
(SELECT COUNT(*) FROM sms_campaign_log scl2
|
||
WHERE scl2.order_group_id = scl.order_group_id
|
||
AND scl2.campaign_type = 'first_sms'
|
||
AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed
|
||
FROM sms_campaign_log scl
|
||
JOIN order_groups og ON og.id = scl.order_group_id
|
||
WHERE scl.campaign_type = 'first_sms'
|
||
AND scl.status IN ('sent', 'checking')
|
||
AND scl.sms_id IS NOT NULL
|
||
AND scl.created_at < NOW() - INTERVAL '%s minutes'
|
||
ORDER BY scl.created_at ASC
|
||
""" % max_duration_min)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
def insert_sms_log(conn, **kwargs):
|
||
with conn.cursor() as cur:
|
||
cols = ", ".join(kwargs.keys())
|
||
placeholders = ", ".join(["%s"] * len(kwargs))
|
||
cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values()))
|
||
log_id = cur.fetchone()[0]
|
||
conn.commit()
|
||
return log_id
|
||
|
||
def update_sms_log(conn, log_id, **kwargs):
|
||
with conn.cursor() as cur:
|
||
set_parts = []
|
||
values = []
|
||
for k, v in kwargs.items():
|
||
if v == "NOW()":
|
||
set_parts.append(f"{k} = NOW()")
|
||
else:
|
||
set_parts.append(f"{k} = %s")
|
||
values.append(v)
|
||
values.append(log_id)
|
||
cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values)
|
||
conn.commit()
|
||
|
||
def update_order_group(conn, group_id, fields):
|
||
with conn.cursor() as cur:
|
||
set_parts = []
|
||
values = []
|
||
for k, v in fields.items():
|
||
if v == "NOW()":
|
||
set_parts.append(f"{k} = NOW()")
|
||
else:
|
||
set_parts.append(f"{k} = %s")
|
||
values.append(v)
|
||
values.append(group_id)
|
||
cur.execute(f"UPDATE order_groups SET {', '.join(set_parts)} WHERE id = %s", values)
|
||
conn.commit()
|
||
|
||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||
|
||
|
||
def get_sms_days(conn):
|
||
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||
try:
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||
row = cur.fetchone()
|
||
if row and row.get("sms_days"):
|
||
days = row["sms_days"]
|
||
if isinstance(days, list):
|
||
return ",".join(str(d) for d in days)
|
||
return str(days)
|
||
except Exception:
|
||
pass
|
||
return "1,2,3,4,5" # Default: Mon-Fri
|
||
|
||
def is_within_work_hours(settings):
|
||
"""Проверка: сейчас рабочие часы.
|
||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||
"""
|
||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||
today_num = now_msk.weekday() + 1
|
||
allowed_days = set()
|
||
work_days_str = settings.get("work_days", "1,2,3,4,5")
|
||
for part in str(work_days_str).split(","):
|
||
part = part.strip()
|
||
if part.isdigit():
|
||
allowed_days.add(int(part))
|
||
if today_num not in allowed_days:
|
||
return False
|
||
hour = now_msk.hour
|
||
start_h = settings.get("work_hours_start", 8)
|
||
end_h = settings.get("work_hours_end", 21)
|
||
return start_h <= hour < end_h
|
||
|
||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||
|
||
def step_send_new(conn, settings, test_send=False):
|
||
"""Шаг 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)
|
||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||
|
||
if test_send:
|
||
# Test mode: send ONE SMS to test phone, pick first available group
|
||
all_groups = get_groups_to_send(conn)
|
||
if not all_groups:
|
||
log.info("Test send: no groups available in queue")
|
||
return 0
|
||
groups = [all_groups[0]] # Only first group
|
||
log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)")
|
||
else:
|
||
groups = get_groups_to_send(conn)
|
||
log.info(f"Step 1: {len(groups)} groups to send SMS")
|
||
|
||
sent_count = 0
|
||
for group in groups:
|
||
group_id = str(group["id"])
|
||
name = group.get("customer_name") or group.get("group_key", "—")
|
||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||
delivery_link = group.get("delivery_link", "")
|
||
|
||
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
SELECT COUNT(*) as cnt FROM sms_campaign_log
|
||
WHERE order_group_id = %s AND campaign_type = 'first_sms'
|
||
AND created_at > NOW() - INTERVAL '24 hours'
|
||
AND status IN ('sent', 'checking', 'delivered')
|
||
""", (group_id,))
|
||
recent_count = cur.fetchone()[0]
|
||
if recent_count > 0:
|
||
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||
continue
|
||
|
||
sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}")
|
||
sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms")
|
||
|
||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||
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}")
|
||
|
||
log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})")
|
||
sms_id, raw, code = send_sms(send_phone, sms_text, api_id)
|
||
|
||
if sms_id:
|
||
# Use sms_attempts from order_groups (incremented on retry by step_handle_expired)
|
||
current_attempts = (group.get("sms_attempts") or 0) + 1
|
||
log_id = insert_sms_log(conn,
|
||
campaign_type="first_sms",
|
||
order_group_id=group_id,
|
||
customer_phone=phone,
|
||
sms_id=sms_id,
|
||
sms_text=sms_text,
|
||
sent_to=send_phone,
|
||
status="sent",
|
||
sms_code=code,
|
||
attempts=current_attempts,
|
||
was_test_mode=bool(settings.get("test_mode", True)),
|
||
)
|
||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
||
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "sms_sending",
|
||
"sms_sent_at": "NOW()",
|
||
"sms_attempts": current_attempts,
|
||
})
|
||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
||
sent_count += 1
|
||
time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit
|
||
else:
|
||
# Ошибка отправки
|
||
error = raw[:500] if raw else "Unknown error"
|
||
log_id = insert_sms_log(conn,
|
||
campaign_type="first_sms",
|
||
order_group_id=group_id,
|
||
customer_phone=phone,
|
||
sms_text=sms_text,
|
||
sent_to=send_phone if "send_phone" in dir() else phone,
|
||
status="send_failed",
|
||
sms_code=code,
|
||
attempts=1,
|
||
error_message=error,
|
||
was_test_mode=bool(settings.get("test_mode", True)),
|
||
)
|
||
log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}")
|
||
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "send_failed",
|
||
"last_sms_error": error[:200],
|
||
})
|
||
send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat)
|
||
|
||
return sent_count
|
||
|
||
def step_check_status(conn, settings):
|
||
"""Шаг 2: Проверка статуса ранее отправленных SMS"""
|
||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||
max_duration = settings.get("max_check_duration_minutes", 90)
|
||
max_attempts = settings.get("max_attempts", 2)
|
||
second_sms_delay = settings.get("second_sms_delay_hours", 3)
|
||
|
||
sms_list = get_sms_to_check(conn, max_duration)
|
||
log.info(f"Step 2: {len(sms_list)} SMS to check status")
|
||
|
||
delivered = 0
|
||
for item in sms_list:
|
||
log_id = item["log_id"]
|
||
sms_id = item["sms_id"]
|
||
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)
|
||
was_needs_check = item.get("needs_check", False)
|
||
|
||
code, raw, api_code = check_sms_status(sms_id, api_id)
|
||
|
||
# Сбрасываем needs_check + ставим checked_at
|
||
update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()")
|
||
|
||
if code == DELIVERED_CODE:
|
||
# Доставлено!
|
||
log.info(f"Group {group_id}: SMS delivered (103)!")
|
||
update_sms_log(conn, log_id, status="delivered", sms_code=code)
|
||
|
||
next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat()
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "first_sms_sent",
|
||
"sms_attempts": attempts,
|
||
"first_sms_sent_at": "NOW()",
|
||
"last_sms_error": None,
|
||
"sms_sent_at": "NOW()",
|
||
"next_notification_check_at": next_check,
|
||
"status": "first_sms_sent",
|
||
})
|
||
send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat)
|
||
delivered += 1
|
||
|
||
elif code in IN_TRANSIT_CODES:
|
||
# В пути / в очереди — продолжаем ждать
|
||
log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run")
|
||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||
|
||
elif code in DELIVERY_ERROR_CODES:
|
||
# Ошибка доставки — можно повторить отправку
|
||
log.error(f"Group {group_id}: SMS delivery error (code={code})")
|
||
update_sms_log(conn, log_id, status="error", sms_code=code,
|
||
error_message=f"Delivery error: {code}")
|
||
# Переход к следующей попытке отправки (если есть)
|
||
if attempts < max_attempts:
|
||
log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})")
|
||
update_sms_log(conn, log_id, status="expired")
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "link_ready",
|
||
"sms_attempts": attempts,
|
||
})
|
||
else:
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "send_failed",
|
||
"last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})",
|
||
})
|
||
send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat)
|
||
|
||
elif code in LIMIT_ERROR_CODES:
|
||
# Превышен лимит — НЕ повторять отправку!
|
||
log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying")
|
||
update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code,
|
||
error_message=f"Limit exceeded: code={code}")
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "send_failed",
|
||
"last_sms_error": f"Limit exceeded (code={code}), sending blocked",
|
||
})
|
||
send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat)
|
||
|
||
else:
|
||
# Неизвестный код — логируем, продолжаем проверять
|
||
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
||
update_sms_log(conn, log_id, status="checking", sms_code=code,
|
||
error_message=f"Unknown code: {code}")
|
||
|
||
return delivered
|
||
|
||
def step_handle_expired(conn, settings):
|
||
"""Шаг 3: Обработка SMS с истёкшим сроком проверки"""
|
||
max_duration = settings.get("max_check_duration_minutes", 90)
|
||
max_attempts = settings.get("max_attempts", 2)
|
||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||
|
||
expired = get_sms_expired(conn, max_duration)
|
||
log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)")
|
||
|
||
for item in expired:
|
||
log_id = item["log_id"]
|
||
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)
|
||
delivery_link = item.get("delivery_link", "")
|
||
total_failed = item.get("total_failed", 0)
|
||
|
||
# Use total_failed across ALL attempts, not just this one
|
||
effective_attempts = max(attempts, total_failed)
|
||
|
||
log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}")
|
||
update_sms_log(conn, log_id, status="expired",
|
||
error_message=f"Not delivered in {max_duration} minutes")
|
||
|
||
if effective_attempts < max_attempts:
|
||
# Повторная отправка ТЕКУЩЕЙ SMS (не откат назад)
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "link_ready",
|
||
"sms_attempts": effective_attempts,
|
||
})
|
||
log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready")
|
||
else:
|
||
# Все попытки исчерпаны
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "manual_required",
|
||
"delivery_status": "manual_confirmation_required",
|
||
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||
"status": "manual_required",
|
||
})
|
||
send_telegram(
|
||
f"🔧 Требуется ручное управление: {name} ({phone})\n"
|
||
f"SMS не доставлена после {max_attempts} попыток",
|
||
tg_chat,
|
||
)
|
||
|
||
# ─── Main ────────────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
# File lock — prevent parallel execution
|
||
lock_file = open("/tmp/" + __file__.split("/")[-1].replace(".py", ".lock"), "w")
|
||
try:
|
||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except (IOError, OSError):
|
||
log.info("Another instance is running, exiting")
|
||
lock_file.close()
|
||
return
|
||
|
||
log.info("=" * 60)
|
||
log.info("SMS First Campaign — START")
|
||
conn = get_db_conn()
|
||
|
||
try:
|
||
settings = load_settings(conn)
|
||
# Override work_days with business_schedule sms_days
|
||
settings["work_days"] = get_sms_days(conn)
|
||
|
||
# Update last_run_at timestamp
|
||
with conn.cursor() as cur:
|
||
cur.execute("UPDATE sms_campaign_settings SET last_run_at = NOW() WHERE campaign_type = 'first_sms'", )
|
||
conn.commit()
|
||
|
||
# Check test_send_requested — one-time test send
|
||
test_send_requested = bool(settings.get("test_send_requested", False))
|
||
if test_send_requested:
|
||
# Reset flag immediately
|
||
with conn.cursor() as cur:
|
||
cur.execute("UPDATE sms_campaign_settings SET test_send_requested = false WHERE campaign_type = 'first_sms'", )
|
||
conn.commit()
|
||
log.info("Test send requested — will send ONE SMS to test phone only")
|
||
|
||
# Check run_requested — restart scenario (reset all groups to beginning)
|
||
run_requested = bool(settings.get('run_requested', False))
|
||
if run_requested:
|
||
with conn.cursor() as cur:
|
||
cur.execute("UPDATE sms_campaign_settings SET run_requested = false WHERE campaign_type = 'first_sms'")
|
||
conn.commit()
|
||
with conn.cursor() as cur:
|
||
cur.execute("""UPDATE order_groups SET notification_status = 'link_ready', sms_sent_at = NULL, first_sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('first_sms_sent','sms_sending','send_failed','second_sms_sent','second_sms_sending','manual_required','not_started')""")
|
||
reset_count = cur.rowcount
|
||
conn.commit()
|
||
log.info(f'RESTART first_sms: reset {reset_count} groups to link_ready')
|
||
log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, "
|
||
f"max_duration={settings.get('max_check_duration_minutes')}min, "
|
||
f"max_attempts={settings.get('max_attempts')}")
|
||
|
||
if not settings.get("enabled", True):
|
||
log.info("Campaign disabled, exiting")
|
||
return
|
||
|
||
# State machine — каждый шаг быстрый, без blocking
|
||
# Отправка только в рабочие часы
|
||
work_hours = is_within_work_hours(settings)
|
||
sent = 0
|
||
if work_hours:
|
||
sent = step_send_new(conn, settings, test_send=test_send_requested)
|
||
else:
|
||
log.info("Outside work hours, skipping new SMS sends")
|
||
|
||
# Проверка статусов работает всегда
|
||
delivered = step_check_status(conn, settings)
|
||
step_handle_expired(conn, settings)
|
||
|
||
log.info(f"Run summary: sent={sent}, delivered={delivered}")
|
||
|
||
if sent > 0 or delivered > 0:
|
||
send_telegram(
|
||
f"📊 <b>Первая отправка</b>\nОтправлено: {sent}\nДоставлено: {delivered}",
|
||
settings.get("telegram_chat_id", TELEGRAM_CHAT_ID),
|
||
)
|
||
|
||
# Обновляем баланс sms.ru
|
||
if sent > 0:
|
||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||
balance, raw = fetch_balance(api_id)
|
||
if balance is not None:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'",
|
||
(balance,)
|
||
)
|
||
conn.commit()
|
||
log.info(f"Balance updated: {balance} ₽")
|
||
|
||
except Exception as e:
|
||
log.error(f"Fatal error: {e}", exc_info=True)
|
||
finally:
|
||
conn.close()
|
||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||
lock_file.close()
|
||
|
||
log.info("SMS First Campaign — END")
|
||
log.info("=" * 60)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main() |