From 05d4d86d37cac9334f1ce5c908dd572fcf27c480 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 23 Jun 2026 08:20:42 +0000 Subject: [PATCH] feat: Python script replacing n8n first SMS campaign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../sms_first_campaign.cpython-311.pyc | Bin 0 -> 23996 bytes scripts/sms_first_campaign.py | 400 ++++++++++++++++++ scripts/sms_first_campaign.service | 19 + scripts/sms_first_campaign.timer | 11 + 4 files changed, 430 insertions(+) create mode 100644 scripts/__pycache__/sms_first_campaign.cpython-311.pyc create mode 100644 scripts/sms_first_campaign.py create mode 100644 scripts/sms_first_campaign.service create mode 100644 scripts/sms_first_campaign.timer diff --git a/scripts/__pycache__/sms_first_campaign.cpython-311.pyc b/scripts/__pycache__/sms_first_campaign.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7950aa6f2f2bf62674bea09d5d7b6c7858d22e08 GIT binary patch literal 23996 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 IF`J_Q7j>sRb^rhX literal 0 HcmV?d00001 diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py new file mode 100644 index 0000000..02fb158 --- /dev/null +++ b/scripts/sms_first_campaign.py @@ -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"📊 Первая отправка — сводка\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() \ No newline at end of file diff --git a/scripts/sms_first_campaign.service b/scripts/sms_first_campaign.service new file mode 100644 index 0000000..ed66e0b --- /dev/null +++ b/scripts/sms_first_campaign.service @@ -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 \ No newline at end of file diff --git a/scripts/sms_first_campaign.timer b/scripts/sms_first_campaign.timer new file mode 100644 index 0000000..1982112 --- /dev/null +++ b/scripts/sms_first_campaign.timer @@ -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 \ No newline at end of file