supersam/scripts/sms_manual_campaign.py

394 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"🔧 <b>Ручное управление</b>\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"📊 <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()