#!/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()