diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py index a35d142..6a30f97 100644 --- a/scripts/sms_manual_campaign.py +++ b/scripts/sms_manual_campaign.py @@ -18,6 +18,7 @@ SuperSam — Manual Campaign (State Machine) import os import sys +import fcntl import logging from datetime import datetime, timezone, timedelta @@ -71,19 +72,7 @@ def load_settings(conn): # ─── Telegram ──────────────────────────────────────────────────────────────── def send_telegram(message, chat_id): - if not TELEGRAM_BOT_TOKEN: - log.warning("TELEGRAM_BOT_TOKEN not set, skipping Telegram") - 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 failed: {e}") + pass # Telegram notifications moved to n8n+Supabase integration # ─── Проверка рабочего времени ──────────────────────────────────────────────── @@ -106,18 +95,47 @@ def is_within_work_hours(settings): # ─── State Machine ─────────────────────────────────────────────────────────── def get_groups_to_manual(conn): - """Группы, где вторая SMS отправлена, но клиент не согласовал, - и пришло время перехода к ручному управлению. + """Группы, где срок ожидания истёк и нужно перевести в ручное управление. + + Сценарии: + 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 без доставки → зависшая группа """ with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.notification_status, og.delivery_link, - og.second_sms_sent_at, og.next_notification_check_at + og.first_sms_sent_at, og.second_sms_sent_at, + og.sms_sent_at, og.next_notification_check_at, + og.sms_attempts FROM order_groups og WHERE og.delivery_status = 'pending_confirmation' - AND COALESCE(og.notification_status, '') = 'second_sms_sent' - AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) + 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 в логе + OR (COALESCE(og.notification_status, '') = 'link_ready' + AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) + 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') + ) ORDER BY og.created_at ASC """) return [dict(r) for r in cur.fetchall()] @@ -127,8 +145,8 @@ def update_order_group(conn, group_id, fields): set_parts = [] values = [] for k, v in fields.items(): - if v == "NOW()": - set_parts.append(f"{k} = NOW()") + if isinstance(v, str) and v.startswith("NOW()"): + set_parts.append(f"{k} = {v}") else: set_parts.append(f"{k} = %s") values.append(v) @@ -177,12 +195,35 @@ def step_move_to_manual(conn, settings): # ─── 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("Manual Campaign — START") conn = get_db_conn() try: settings = load_settings(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 = %s", (CAMPAIGN_TYPE,)) + 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 = %s", (CAMPAIGN_TYPE,)) + conn.commit() + log.info("Test send requested — will send ONE SMS to test phone only") log.info(f"Settings: work_hours={settings.get('work_hours_start')}-{settings.get('work_hours_end')}, " f"work_days={settings.get('work_days')}") @@ -195,7 +236,18 @@ def main(): log.info("Outside work hours, skipping") return - moved = step_move_to_manual(conn, settings) + if test_send_requested: + log.info("Test send requested for manual campaign — no SMS to send, marking as done") + + # run_requested for manual = just run the step + 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 = 'manual'") + conn.commit() + log.info("RESTART: manual campaign — will process all eligible groups now") + else: + moved = step_move_to_manual(conn, settings) log.info(f"Run summary: moved_to_manual={moved}") @@ -209,6 +261,8 @@ def main(): log.error(f"Fatal error: {e}", exc_info=True) finally: conn.close() + fcntl.flock(lock_file, fcntl.LOCK_UN) + lock_file.close() log.info("Manual Campaign — END") log.info("=" * 60)