297 lines
13 KiB
Python
297 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
SuperSam — Manual Campaign (State Machine)
|
||
Заменяет n8n workflow "Ручное управление"
|
||
|
||
Логика:
|
||
Если после второй SMS (notification_status = 'second_sms_sent')
|
||
прошло auto_manual_after_hours и клиент не согласовал
|
||
(delivery_status = 'pending_confirmation'):
|
||
→ notification_status = 'manual_required'
|
||
→ delivery_status = 'manual_confirmation_required'
|
||
→ next_notification_check_at = +3 мин
|
||
→ Telegram-уведомление
|
||
|
||
Расписание: 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 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}
|
||
return dict(row)
|
||
|
||
# ─── Telegram ────────────────────────────────────────────────────────────────
|
||
|
||
def send_telegram(message, chat_id):
|
||
pass # Telegram notifications moved to n8n+Supabase integration
|
||
|
||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||
|
||
|
||
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):
|
||
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):
|
||
"""Группы, где срок ожидания истёк и нужно перевести в ручное управление.
|
||
|
||
Сценарии:
|
||
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.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 (
|
||
-- 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 уже отправлялась хотя бы раз
|
||
-- ВАЖНО: если SMS никогда не отправлялась (нет записи в логе), не переводить в manual --
|
||
-- возможно first campaign ещё не успел отправить (БД была недоступна и т.п.)
|
||
OR (COALESCE(og.notification_status, '') = 'link_ready'
|
||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
||
AND EXISTS (
|
||
SELECT 1 FROM sms_campaign_log scl
|
||
WHERE scl.order_group_id = og.id
|
||
AND scl.status IN ('sent', 'checking', 'delivered')
|
||
)
|
||
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()]
|
||
|
||
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):
|
||
"""Перевод групп в ручное управление"""
|
||
tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID)
|
||
delay_minutes = settings.get("auto_manual_after_hours", 3)
|
||
|
||
groups = get_groups_to_manual(conn)
|
||
log.info(f"Step 1: {len(groups)} groups to move to manual")
|
||
|
||
moved = 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", "")
|
||
delivery_link = group.get("delivery_link", "")
|
||
|
||
log.info(f"Group {group_id}: moving to manual_required ({name})")
|
||
|
||
update_order_group(conn, group_id, {
|
||
"notification_status": "manual_required",
|
||
"delivery_status": "manual_confirmation_required",
|
||
"sms_attempts": 2,
|
||
"last_sms_error": None,
|
||
"next_notification_check_at": f"NOW() + INTERVAL '{int(delay_minutes)} minutes'",
|
||
"status": "manual_required",
|
||
})
|
||
|
||
send_telegram(
|
||
f"🔧 <b>Ручное управление</b>\n{name} ({phone})\n"
|
||
f"Клиент не согласовал доставку после двух 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)
|
||
# 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 = %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")
|
||
else:
|
||
moved = step_move_to_manual(conn, settings)
|
||
|
||
log.info(f"Run summary: moved_to_manual={moved}")
|
||
|
||
if moved > 0:
|
||
send_telegram(
|
||
f"📊 <b>Ручное управление</b>\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() |