#!/usr/bin/env python3 """ SuperSam — Manual Campaign (State Machine) Заменяет n8n workflow "Ручное управление" Логика: Клиент не выбрал дату/время за auto_manual_after_hours (настройка, по умолчанию 3 ч) после ПОСЛЕДНЕГО КОНТАКТА — SMS или звонка робота — и (delivery_status = 'pending_confirmation'): → notification_status = 'manual_required' → delivery_status = 'manual_confirmation_required' → Telegram-уведомление Ожидание считаем только по тем каналам, что реально включены в системе (load_channels): • второе приглашение включено → ждём, пока оно уйдёт; • автодозвон включён и номеру звонить можно → ждём звонок, отсчёт начнётся от него (позвонили позже — ожидание начинается заново); • канал выключен / звонить нельзя (не звонить, гео, исключение) — не ждём его и переводим по сроку ожидания. Расписание: 9-21, Пн-Сб (настраивается из админки) Скрипт НЕ отправляет SMS — только перевод в ручное управление. """ import os import sys import fcntl import logging 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") LOG_FILE = "/var/log/supersam-sms-manual.log" CAMPAIGN_TYPE = "manual" # ─── Логирование ───────────────────────────────────────────────────────────── 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_manual") # ─── БД ────────────────────────────────────────────────────────────────────── def get_db_conn(): return psycopg2.connect( host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS, ) def apply_schedule_work_days(conn, settings): """Дни отправки — из «Графика работы» в ЛК (единый источник правды). Если график прочитать не удалось, остаются прежние дни кампании. """ try: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1") row = cur.fetchone() or {} days = row.get("sms_days") or [] if days: settings["work_days"] = ",".join(str(int(d)) for d in days) except Exception as exc: # noqa: BLE001 — не роняем рассылку из-за графика print(f"График работы не прочитан, дни SMS из настроек кампании: {exc}") return settings def load_settings(conn): with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute("SELECT * FROM sms_campaign_settings WHERE campaign_type = %s", (CAMPAIGN_TYPE,)) row = cur.fetchone() if not row: return {"enabled": True, "auto_manual_after_hours": 3, "telegram_chat_id": TELEGRAM_CHAT_ID} settings = dict(row) return apply_schedule_work_days(conn, settings) # ─── Telegram ──────────────────────────────────────────────────────────────── def send_telegram(message, chat_id): pass # Telegram notifications moved to n8n+Supabase integration # ─── Проверка рабочего времени ──────────────────────────────────────────────── def is_within_work_hours(settings): 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,6") 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", 9) end_h = settings.get("work_hours_end", 21) return start_h <= hour < end_h # ─── State Machine ─────────────────────────────────────────────────────────── def get_groups_to_manual(conn, channels=None, manual_after_hours=3): """Группы, где клиент не выбрал дату/время и пора в ручную работу. Отсчёт — от ПОСЛЕДНЕГО КОНТАКТА (SMS или звонок робота), а не от первого приглашения: если клиенту позвонили позже, ожидание начинается заново. Каналы берём из настроек: ждём только те шаги, которые реально включены. • второе приглашение включено → ждём, пока оно уйдёт; • автодозвон включён и этому номеру звонить можно → ждём звонка; • канал выключен (или звонить нельзя: не звонить/гео/исключение) — не ждём его и переводим в ручную работу по сроку ожидания. Гео-исключения и «не звонить» не дают перевести заказ в ручную: такие заказы в системе ведёт только менеджер, автоматика их не трогает. """ channels = channels or {} with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(r""" WITH contact AS ( SELECT og.id, GREATEST( COALESCE(og.first_sms_sent_at, TIMESTAMP 'epoch'), COALESCE(og.second_sms_sent_at, TIMESTAMP 'epoch'), COALESCE(og.sms_sent_at, TIMESTAMP 'epoch'), COALESCE(og.called_at, TIMESTAMP 'epoch'), COALESCE((SELECT max(l.created_at) FROM sms_campaign_log l WHERE l.order_group_id = og.id AND COALESCE(l.status, '') IN ('sent','checking','delivered','send_failed','error','limit_exceeded')), TIMESTAMP 'epoch'), COALESCE((SELECT max(a.created_at) FROM autocall_log a WHERE a.order_group_id = og.id), TIMESTAMP 'epoch') ) AS last_contact_at, (SELECT count(*) FROM autocall_log a WHERE a.order_group_id = og.id) AS call_attempts FROM order_groups og ), callable AS ( SELECT og.id, (og.autocall_opt_out = false AND COALESCE(og.customer_phone, '') <> '' AND NOT EXISTS ( SELECT 1 FROM autocall_city_rules cr WHERE cr.city = og.town AND cr.calls_enabled = false) AND NOT EXISTS ( SELECT 1 FROM sms_city_rules cr2 WHERE cr2.city = og.town AND cr2.enabled = false AND cr2.campaign_type IN ('*', 'autocall_slot') AND (cr2.valid_from IS NULL OR cr2.valid_from <= CURRENT_DATE) AND (cr2.valid_until IS NULL OR cr2.valid_until >= CURRENT_DATE)) AND NOT EXISTS ( SELECT 1 FROM autocall_exclusions e WHERE e.active = true AND ((e.rule_type = 'phone' AND e.value = '7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) OR (e.rule_type = 'prefix' AND ('7' || right(regexp_replace(og.customer_phone, '\D', '', 'g'), 10)) LIKE e.value || '%%'))) ) AS can_call FROM order_groups og ) SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.notification_status, og.delivery_link, og.first_sms_sent_at, og.second_sms_sent_at, og.sms_sent_at, og.next_notification_check_at, og.sms_attempts, c.last_contact_at, c.call_attempts, cl.can_call FROM order_groups og JOIN contact c ON c.id = og.id JOIN callable cl ON cl.id = og.id WHERE og.delivery_status = 'pending_confirmation' -- Клиент уже выбрал дату/время — в ручную не переводим AND og.delivery_date IS NULL -- Контакта не было — отсчитывать не от чего AND c.last_contact_at > TIMESTAMP 'epoch' -- Срок ожидания истёк от последнего контакта AND NOW() - c.last_contact_at >= make_interval(hours => %s) -- Второе приглашение ждём, только если канал включён AND (NOT %s OR og.second_sms_sent_at IS NOT NULL) -- Звонок ждём, только если автодозвон включён и звонить можно AND (NOT %s OR c.call_attempts > 0 OR NOT cl.can_call) -- Гео-исключения — общие для SMS и ручного управления AND NOT EXISTS ( SELECT 1 FROM sms_city_rules r WHERE r.city = og.town AND r.enabled = false AND r.campaign_type IN ('*', 'manual') AND (r.valid_from IS NULL OR r.valid_from <= CURRENT_DATE) AND (r.valid_until IS NULL OR r.valid_until >= CURRENT_DATE) ) ORDER BY c.last_contact_at ASC """, ( int(manual_after_hours or 3), bool(channels.get("second_sms")), bool(channels.get("autocall")), )) return [dict(r) for r in cur.fetchall()] def load_channels(conn): """Какие каналы связи сейчас включены в системе. `first_sms` — первое приглашение, `second_sms` — повторное SMS, `autocall` — робот-автодозвон (настройки кампании 'autocall_slot'). От этого зависит, чьё ожидание отсчитывать: ждём только включённые шаги. """ channels = {"first_sms": True, "second_sms": False, "autocall": False} try: with conn.cursor() as cur: cur.execute(""" SELECT campaign_type, enabled FROM sms_campaign_settings WHERE campaign_type IN ('first_sms', 'second_sms', 'autocall_slot') """) for campaign_type, enabled in cur.fetchall(): if campaign_type == "autocall_slot": channels["autocall"] = bool(enabled) else: channels[campaign_type] = bool(enabled) except Exception: # noqa: BLE001 — не прочитали настройки: считаем, что есть только первое SMS pass return channels def update_order_group(conn, group_id, fields): with conn.cursor() as cur: set_parts = [] values = [] for k, v in fields.items(): if isinstance(v, str) and v.startswith("NOW()"): set_parts.append(f"{k} = {v}") 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 step_move_to_manual(conn, settings, channels=None): """Перевод групп в ручное управление""" tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) wait_hours = int(settings.get("auto_manual_after_hours", 3) or 3) channels = channels or load_channels(conn) groups = get_groups_to_manual(conn, channels=channels, manual_after_hours=wait_hours) log.info( f"Step 1: {len(groups)} groups to move to manual " f"(ожидание {wait_hours} ч от последнего контакта; каналы: " f"первое SMS={'вкл' if channels.get('first_sms') else 'выкл'}, " f"второе SMS={'вкл' if channels.get('second_sms') else 'выкл'}, " f"автодозвон={'вкл' if channels.get('autocall') else 'выкл'})" ) moved = 0 dry_run = os.environ.get("DRY_RUN") == "1" for group in groups: group_id = str(group["id"]) name = group.get("customer_name") or group.get("group_key", "—") phone = group.get("customer_phone", "") delivery_link = group.get("delivery_link", "") attempts = group.get("sms_attempts") or 1 if dry_run: log.info(f"[DRY RUN] Group {group_id}: будет переведена в ручное ({name})") moved += 1 continue log.info( f"Group {group_id}: moving to manual_required ({name}); " f"последний контакт {group.get('last_contact_at')}, звонков {group.get('call_attempts')}" ) update_order_group(conn, group_id, { "notification_status": "manual_required", "delivery_status": "manual_confirmation_required", # Попытки НЕ подменяем: показываем столько, сколько реально отправлено "sms_attempts": attempts, "last_sms_error": None, # Дальше ждать нечего — проверки по этому заказу закончились "next_notification_check_at": None, "status": "manual_required", }) send_telegram( f"🔧 Ручное управление\n{name} ({phone})\n" f"Клиент не выбрал дату/время за {wait_hours} ч после последнего контакта " f"({'звонки' if channels.get('autocall') else 'SMS'})\n" f"Ссылка: {delivery_link}", tg_chat, ) moved += 1 return moved # ─── 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')}") if not settings.get("enabled", True): log.info("Campaign disabled, exiting") return # Только в рабочие часы if not is_within_work_hours(settings): log.info("Outside work hours, skipping") return 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") channels = load_channels(conn) moved = step_move_to_manual(conn, settings, channels=channels) log.info(f"Run summary: moved_to_manual={moved}") if moved > 0: send_telegram( f"📊 Ручное управление\nПереведено в ручное: {moved}", settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), ) 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("Manual Campaign — END") log.info("=" * 60) if __name__ == "__main__": main()