feat: Python script replacing n8n first SMS campaign
- sms_first_campaign.py: full replacement for n8n workflow JnfZwOnpjEazseW5 - sms_campaign_log table: full audit trail in Supabase (visible in admin) - sms_campaign_settings table: configurable by megaadmin (wait times, attempts, schedule) - systemd service + timer (NOT activated, ready for deployment) - Logic: send SMS → wait 25s → check status (102=in transit, 103=delivered) → retry check up to 90 min → retry SMS send up to 2 attempts → manual_required - Telegram notifications after each send/delivery/failure - Log file: /var/log/supersam-sms-first.log
This commit is contained in:
parent
f792ea6fb9
commit
05d4d86d37
Binary file not shown.
|
|
@ -0,0 +1,400 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
SuperSam — Первая отправка согласования доставки (SMS)
|
||||
Заменяет n8n workflow "Первая отправка согласования" (JnfZwOnpjEazseW5)
|
||||
|
||||
Логика:
|
||||
1. SELECT order_groups WHERE status='ready_to_launch' AND delivery_status='pending_confirmation'
|
||||
2. Для каждой группы с delivery_link и notification_status='link_ready':
|
||||
a. Отправка SMS через sms.ru/sms/send
|
||||
b. Ждёт wait_between_checks_seconds (25 сек по умолчанию)
|
||||
c. Проверка статуса через sms.ru/sms/status
|
||||
d. Если код 103 (доставлено) → UPDATE order_groups + лог
|
||||
e. Если код 102 (в пути) → повторная проверка каждые wait_between_checks
|
||||
f. Если в течение max_check_duration_minutes (90 мин) не доставлено → повторная отправка SMS
|
||||
g. Если повторная отправка тоже не доставлена за max_check_duration → manual_required
|
||||
3. Telegram-уведомление после каждой отправки
|
||||
4. Полный лог в sms_campaign_log (видно в админке)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
# ─── Конфигурация ────────────────────────────────────────────────────────────
|
||||
|
||||
DB_HOST = os.environ.get("DB_HOST", "10.0.1.3")
|
||||
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"
|
||||
|
||||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||||
|
||||
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):
|
||||
"""Загружает настройки из sms_campaign_settings (управляются мегаадмином)"""
|
||||
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",
|
||||
}
|
||||
return dict(row)
|
||||
|
||||
def get_pending_groups(conn):
|
||||
"""Получает группы для отправки первой SMS"""
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
SELECT id, group_key, customer_name, customer_phone, customer_phone_normalized,
|
||||
delivery_link, notification_status, status, delivery_status
|
||||
FROM order_groups
|
||||
WHERE status = 'ready_to_launch'
|
||||
AND delivery_status = 'pending_confirmation'
|
||||
ORDER BY created_at ASC
|
||||
""")
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
def update_order_group(conn, group_id, fields):
|
||||
"""Обновляет order_groups"""
|
||||
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 insert_sms_log(conn, **kwargs):
|
||||
"""Записывает лог в sms_campaign_log"""
|
||||
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})", list(kwargs.values()))
|
||||
conn.commit()
|
||||
|
||||
def update_sms_log(conn, log_id, **kwargs):
|
||||
"""Обновляет запись лога"""
|
||||
with conn.cursor() as cur:
|
||||
set_parts = []
|
||||
values = []
|
||||
for k, v in kwargs.items():
|
||||
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()
|
||||
|
||||
# ─── SMS API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def send_sms(phone, message, api_id):
|
||||
"""Отправляет SMS через sms.ru, возвращает (sms_id, raw_response)"""
|
||||
try:
|
||||
# Нормализуем телефон: убираем всё кроме цифр, добавляем 7
|
||||
clean_phone = "".join(c for c in phone if c.isdigit())
|
||||
if clean_phone.startswith("8"):
|
||||
clean_phone = "7" + clean_phone[1:]
|
||||
elif not clean_phone.startswith("7"):
|
||||
clean_phone = "7" + clean_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 response: {text[:200]}")
|
||||
|
||||
# Парсим sms_id: sms.ru возвращает строки, вторая строка = sms_id
|
||||
lines = text.strip().split("\n")
|
||||
if len(lines) >= 2 and lines[0].strip() == "100":
|
||||
sms_id = lines[1].strip()
|
||||
return sms_id, text
|
||||
elif lines and lines[0].strip() == "100":
|
||||
sms_id = lines[1].strip() if len(lines) > 1 else ""
|
||||
return sms_id, text
|
||||
else:
|
||||
return None, text
|
||||
except Exception as e:
|
||||
log.error(f"SMS send error: {e}")
|
||||
return None, str(e)
|
||||
|
||||
def check_sms_status(sms_id, api_id):
|
||||
"""Проверяет статус SMS, возвращает код (102=в пути, 103=доставлено)"""
|
||||
try:
|
||||
resp = requests.post(SMS_STATUS_URL, params={
|
||||
"api_id": api_id,
|
||||
"sms_id": sms_id,
|
||||
}, timeout=30)
|
||||
text = resp.text
|
||||
log.info(f"SMS status response for {sms_id}: {text[:200]}")
|
||||
|
||||
lines = text.strip().split("\n")
|
||||
if len(lines) >= 2:
|
||||
code = lines[1].strip()
|
||||
return code, text
|
||||
return None, text
|
||||
except Exception as e:
|
||||
log.error(f"SMS status check error: {e}")
|
||||
return None, str(e)
|
||||
|
||||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||||
|
||||
def send_telegram(message, chat_id):
|
||||
"""Отправляет сообщение в Telegram"""
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram notification")
|
||||
return
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"Telegram error: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram send failed: {e}")
|
||||
|
||||
# ─── Основная логика ─────────────────────────────────────────────────────────
|
||||
|
||||
def process_group(conn, group, settings):
|
||||
"""Обрабатывает одну группу: отправка SMS → проверка доставки → UPDATE"""
|
||||
group_id = group["id"]
|
||||
customer_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", "")
|
||||
|
||||
if not delivery_link:
|
||||
log.warning(f"Group {group_id}: no delivery_link, skipping")
|
||||
return False
|
||||
|
||||
if group.get("notification_status") != "link_ready":
|
||||
log.info(f"Group {group_id}: notification_status={group.get('notification_status')}, skipping")
|
||||
return False
|
||||
|
||||
api_id = settings.get("sms_api_id", SMS_API_ID)
|
||||
wait_sec = settings.get("wait_between_checks_seconds", 25)
|
||||
max_duration_min = settings.get("max_check_duration_minutes", 90)
|
||||
max_attempts = settings.get("max_attempts", 2)
|
||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||
|
||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||
|
||||
log.info(f"=== Group {group_id} ({customer_name}, phone={phone}) ===")
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
log.info(f"Group {group_id}: attempt {attempt}/{max_attempts}")
|
||||
|
||||
# Запись лога отправки
|
||||
log_id = None
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute("""
|
||||
INSERT INTO sms_campaign_log
|
||||
(campaign_type, order_group_id, customer_phone, sms_text, status, attempts, created_at)
|
||||
VALUES ('first_sms', %s, %s, %s, 'sending', %s, NOW())
|
||||
RETURNING id
|
||||
""", (str(group_id), phone, sms_text, attempt))
|
||||
log_id = cur.fetchone()["id"]
|
||||
conn.commit()
|
||||
|
||||
# Отправка SMS
|
||||
sms_id, raw = send_sms(phone, sms_text, api_id)
|
||||
|
||||
if not sms_id:
|
||||
error = raw[:500] if raw else "No sms_id in response"
|
||||
log.error(f"Group {group_id}: SMS send failed: {error}")
|
||||
update_sms_log(conn, log_id, status="send_failed", sms_code=None, error_message=error)
|
||||
if attempt < max_attempts:
|
||||
log.info(f"Group {group_id}: retrying in {wait_sec}s...")
|
||||
time.sleep(wait_sec)
|
||||
continue
|
||||
else:
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "send_failed",
|
||||
"last_sms_error": error[:200],
|
||||
"status": "ready_to_launch", # остаётся для повторной попытки
|
||||
})
|
||||
send_telegram(f"❌ SMS не отправлена: {customer_name} ({phone})\nОшибка: {error[:200]}", tg_chat)
|
||||
return False
|
||||
|
||||
# SMS отправлена успешно
|
||||
update_sms_log(conn, log_id, status="sent", sms_id=sms_id, sms_code="100")
|
||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}")
|
||||
|
||||
# Цикл проверки доставки (до max_check_duration_minutes)
|
||||
start_time = datetime.now(timezone.utc)
|
||||
max_duration = timedelta(minutes=max_duration_min)
|
||||
check_interval = wait_sec
|
||||
|
||||
while datetime.now(timezone.utc) - start_time < max_duration:
|
||||
log.info(f"Group {group_id}: waiting {check_interval}s before status check...")
|
||||
time.sleep(check_interval)
|
||||
|
||||
code, raw_status = check_sms_status(sms_id, api_id)
|
||||
update_sms_log(conn, log_id, status="checking", sms_code=code)
|
||||
|
||||
if code == "103":
|
||||
# Доставлено!
|
||||
log.info(f"Group {group_id}: SMS delivered (code=103)!")
|
||||
update_sms_log(conn, log_id, status="delivered", sms_code="103")
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "first_sms_sent",
|
||||
"sms_attempts": attempt,
|
||||
"first_sms_sent_at": "NOW()",
|
||||
"last_sms_error": None,
|
||||
"sms_sent_at": "NOW()",
|
||||
"next_notification_check_at": (datetime.now(timezone.utc) + timedelta(hours=settings.get("second_sms_delay_hours", 3))).isoformat(),
|
||||
"status": "first_sms_sent",
|
||||
})
|
||||
|
||||
send_telegram(
|
||||
f"✅ SMS доставлена: {customer_name} ({phone})\nПопытка {attempt}\nsms_id: {sms_id}",
|
||||
tg_chat,
|
||||
)
|
||||
return True
|
||||
|
||||
elif code == "102":
|
||||
# В пути — продолжаем ждать
|
||||
log.info(f"Group {group_id}: SMS in transit (code=102), continuing to check...")
|
||||
continue
|
||||
|
||||
elif code in ("104", "105", "106", "107", "108"):
|
||||
# Ошибка доставки
|
||||
error_msg = f"SMS error code: {code}"
|
||||
log.error(f"Group {group_id}: {error_msg}")
|
||||
update_sms_log(conn, log_id, status="error", sms_code=code, error_message=error_msg)
|
||||
# Прерываем внутренний цикл, переходим к следующей попытке
|
||||
break
|
||||
|
||||
else:
|
||||
# Неизвестный код — продолжаем проверять
|
||||
log.warning(f"Group {group_id}: unknown SMS code: {code}")
|
||||
continue
|
||||
|
||||
# Время проверки истекло или ошибка — повторная отправка
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() / 60
|
||||
log.warning(f"Group {group_id}: check duration exceeded ({elapsed:.0f} min) or error, attempt {attempt}")
|
||||
update_sms_log(conn, log_id, status="timeout", error_message=f"Not delivered in {max_duration_min} min")
|
||||
|
||||
if attempt < max_attempts:
|
||||
log.info(f"Group {group_id}: will retry SMS send...")
|
||||
continue
|
||||
else:
|
||||
# Все попытки исчерпаны
|
||||
update_order_group(conn, group_id, {
|
||||
"notification_status": "send_failed",
|
||||
"last_sms_error": f"Not delivered after {max_attempts} attempts",
|
||||
"status": "ready_to_launch",
|
||||
})
|
||||
send_telegram(
|
||||
f"⚠️ SMS не доставлена после {max_attempts} попыток: {customer_name} ({phone})\nТребуется ручное управление",
|
||||
tg_chat,
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
log.info("=" * 60)
|
||||
log.info("SMS First Campaign — START")
|
||||
conn = get_db_conn()
|
||||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
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
|
||||
|
||||
groups = get_pending_groups(conn)
|
||||
log.info(f"Found {len(groups)} groups with status=ready_to_launch, delivery_status=pending_confirmation")
|
||||
|
||||
if not groups:
|
||||
log.info("No groups to process, exiting")
|
||||
return
|
||||
|
||||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||||
sent = 0
|
||||
delivered = 0
|
||||
failed = 0
|
||||
|
||||
for group in groups:
|
||||
try:
|
||||
result = process_group(conn, group, settings)
|
||||
if result:
|
||||
delivered += 1
|
||||
else:
|
||||
failed += 1
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
log.error(f"Error processing group {group.get('id')}: {e}", exc_info=True)
|
||||
failed += 1
|
||||
|
||||
# Сводка в Telegram
|
||||
summary = (f"📊 <b>Первая отправка — сводка</b>\n\n"
|
||||
f"Групп найдено: {len(groups)}\n"
|
||||
f"Отправлено: {sent}\n"
|
||||
f"Доставлено: {delivered}\n"
|
||||
f"Ошибок: {failed}")
|
||||
send_telegram(summary, tg_chat)
|
||||
log.info(f"Summary: sent={sent}, delivered={delivered}, failed={failed}")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log.info("SMS First Campaign — END")
|
||||
log.info("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=SuperSam SMS First Campaign
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /opt/supersam/scripts/sms_first_campaign.py
|
||||
Environment=DB_HOST=10.0.1.3
|
||||
Environment=DB_PORT=5432
|
||||
Environment=DB_NAME=postgres
|
||||
Environment=DB_USER=supabase_admin
|
||||
Environment=DB_PASS=4fe80bb21c7c3d17a8d8b226adf7a479
|
||||
Environment=TELEGRAM_BOT_TOKEN=
|
||||
Environment=TELEGRAM_CHAT_ID=25164483
|
||||
Environment=SMS_API_ID=C92063B3-95ED-8559-157B-1946EB5A2486
|
||||
WorkingDirectory=/opt/supersam
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
[Unit]
|
||||
Description=SuperSam SMS First Campaign Timer
|
||||
|
||||
[Timer]
|
||||
# Every 5 minutes, 8-19, Mon-Sat (matching n8n schedule)
|
||||
OnCalendar=*:0/5
|
||||
OnCalendar=*-*-* 08-19:0/5:00
|
||||
Persistent=false
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Loading…
Reference in New Issue