450 lines
21 KiB
Python
450 lines
21 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
SuperSam — Автодозвон: планировщик (Фаза 3)
|
||
Генерирует call-файлы для АТС клиента (Asterisk) и кладёт их в spool через SSH.
|
||
|
||
Архитектура: systemd timer (каждые N минут) + этот скрипт.
|
||
Каждый запуск:
|
||
1. Читает настройки кампании autocall_slot из sms_campaign_settings.
|
||
2. Находит группы, которым пора звонить (тайминг от sms_sent_at + пауза).
|
||
3. Генерирует call-файл (Context: SS-Autocall-Slot) и кладёт на АТС.
|
||
4. В test_mode звонит только на test_phone.
|
||
|
||
Безопасность: сценарий НЕ активируется сам — только если enabled=true в БД.
|
||
"""
|
||
import os
|
||
import sys
|
||
import time
|
||
import logging
|
||
import subprocess
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
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")
|
||
|
||
# АТС (клиентская, через OpenVPN)
|
||
ATS_HOST = os.environ.get("ATS_HOST", "192.168.122.203")
|
||
ATS_USER = os.environ.get("ATS_USER", "root")
|
||
ATS_PASS = os.environ.get("ATS_PASS", "tigrissoro")
|
||
ATS_SPOOL = "/var/spool/asterisk/outgoing"
|
||
CALLER_ID = "2565551100"
|
||
|
||
# Edge function для приёма результата
|
||
API_URL = os.environ.get("AUTOCALL_API_URL", "https://supa.supersamsev.ru/functions/v1/autocall-result")
|
||
API_KEY = os.environ.get("AUTOCALL_API_KEY", "a7e1a7e9b543d22a93b29cb1291cbf7b3dd2ec67f1a4dae2")
|
||
|
||
LOG_FILE = "/var/log/supersam-autocall.log"
|
||
|
||
# ─── Логирование ─────────────────────────────────────────────────────────────
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout)],
|
||
)
|
||
log = logging.getLogger("autocall")
|
||
|
||
# ─── БД ──────────────────────────────────────────────────────────────────────
|
||
|
||
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 = 'autocall_slot'")
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return {
|
||
"enabled": False, "test_mode": True, "test_phone": "9067029953",
|
||
"work_hours_start": 9, "work_hours_end": 20, "work_days": "1,2,3,4,5,6",
|
||
"delay_after_sms_minutes": 90, "retry_interval_minutes": 120,
|
||
"attempts_per_day": 3, "max_attempts": 5,
|
||
"no_input_escalate_after": 2, "next_day_attempt_times": "10:00,14:00",
|
||
"call_timeout_seconds": 30,
|
||
}
|
||
return dict(row)
|
||
|
||
def resolve_scenario(created_at, now=None):
|
||
"""Сценарий по аналогии с SMS: в день добавления группы → A (завтра/послезавтра),
|
||
со следующего дня → B (день фиксирован = завтра, только половина дня)."""
|
||
if not created_at:
|
||
return "A"
|
||
now = now or datetime.now(timezone.utc)
|
||
msk_tz = timezone(timedelta(hours=3))
|
||
created = created_at.astimezone(msk_tz)
|
||
current = now.astimezone(msk_tz)
|
||
return "A" if created.date() == current.date() else "B"
|
||
|
||
|
||
def get_groups_to_call(conn, settings):
|
||
"""Группы, которым пора звонить."""
|
||
delay_min = settings.get("delay_after_sms_minutes", 90)
|
||
retry_min = settings.get("retry_interval_minutes", 120)
|
||
max_attempts = settings.get("max_attempts", 5)
|
||
no_input_escalate = settings.get("no_input_escalate_after", 2)
|
||
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT og.id, og.customer_phone, og.customer_phone_normalized,
|
||
og.notification_status, og.delivery_status, og.sms_sent_at,
|
||
og.autocall_opt_out, og.created_at,
|
||
(SELECT count(*) FROM autocall_log l
|
||
WHERE l.order_group_id = og.id) as attempt_count,
|
||
(SELECT count(*) FROM autocall_log l
|
||
WHERE l.order_group_id = og.id
|
||
AND l.outcome IN ('no_input_day','no_input_half')) as no_input_count,
|
||
(SELECT max(l.created_at) FROM autocall_log l
|
||
WHERE l.order_group_id = og.id) as last_call_at
|
||
FROM order_groups og
|
||
WHERE og.delivery_status = 'pending_confirmation'
|
||
AND og.autocall_opt_out = false
|
||
AND og.notification_status NOT IN ('confirmed', 'manual_required', 'opt_out')
|
||
AND og.sms_sent_at IS NOT NULL
|
||
-- Пауза после SMS прошла
|
||
AND og.sms_sent_at < NOW() - make_interval(mins => %s)
|
||
-- Не звонили или прошёл интервал повтора
|
||
AND (
|
||
NOT EXISTS (SELECT 1 FROM autocall_log l WHERE l.order_group_id = og.id)
|
||
OR (SELECT max(l.created_at) FROM autocall_log l
|
||
WHERE l.order_group_id = og.id) < NOW() - make_interval(mins => %s)
|
||
)
|
||
-- Не превышен лимит попыток
|
||
AND (SELECT count(*) FROM autocall_log l
|
||
WHERE l.order_group_id = og.id) < %s
|
||
ORDER BY og.sms_sent_at ASC
|
||
""", (delay_min, retry_min, max_attempts))
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
def is_work_time(settings, now=None):
|
||
"""Проверяет, входит ли сейчас в рабочее окно."""
|
||
now = now or datetime.now(timezone.utc)
|
||
# Рабочие часы в МСК (UTC+3)
|
||
msk = now.astimezone(timezone(timedelta(hours=3)))
|
||
hour = msk.hour
|
||
start = settings.get("work_hours_start", 9)
|
||
end = settings.get("work_hours_end", 20)
|
||
work_days = [int(d) for d in str(settings.get("work_days", "1,2,3,4,5,6")).split(",") if d.strip().isdigit()]
|
||
# Python: Monday=0, Sunday=6. БД: 1=Пн ... 7=Вс
|
||
weekday = msk.weekday() + 1
|
||
if weekday not in work_days:
|
||
return False
|
||
return start <= hour < end
|
||
|
||
def normalize_phone(phone):
|
||
"""Нормализует телефон: только цифры, начинается с 7."""
|
||
clean = "".join(c for c in str(phone) if c.isdigit())
|
||
if clean.startswith("8"):
|
||
clean = "7" + clean[1:]
|
||
elif not clean.startswith("7"):
|
||
clean = "7" + clean
|
||
return clean
|
||
|
||
def get_exclusion_match(conn, phone):
|
||
"""Возвращает правило исключения, если на этот номер звонить нельзя, иначе None."""
|
||
phone = normalize_phone(phone)
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT rule_type, value, reason
|
||
FROM autocall_exclusions
|
||
WHERE active = true
|
||
AND (
|
||
(rule_type = 'phone' AND %s = value)
|
||
OR
|
||
(rule_type = 'prefix' AND %s LIKE value || '%%')
|
||
)
|
||
LIMIT 1
|
||
""", (phone, phone))
|
||
return cur.fetchone()
|
||
|
||
def build_call_file(group, settings, attempt_no, is_test_call=False):
|
||
"""Формирует содержимое call-файла."""
|
||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||
phone = normalize_phone(phone)
|
||
# Формат набора: 0 + номер без ведущей 7
|
||
dial = "0" + phone[1:] if phone.startswith("7") else "0" + phone
|
||
|
||
# Тест-звонок из UI: звоним ровно на введённый из UI номер (settings.test_mode не важен),
|
||
# но помечаем was_test_mode=1, чтобы в логе и в БД было видно, что это тест.
|
||
test_mode = bool(settings.get("test_mode", True))
|
||
if is_test_call:
|
||
test_mode = True
|
||
elif test_mode:
|
||
test_phone = normalize_phone(settings.get("test_phone", "9067029953"))
|
||
dial = "0" + test_phone[1:] if test_phone.startswith("7") else "0" + test_phone
|
||
|
||
# Сценарий: в день добавления группы → A (завтра/послезавтра),
|
||
# со следующего дня → B (только завтра + половина дня).
|
||
scenario = group.get("scenario") or resolve_scenario(group.get("created_at"))
|
||
call_type = "dlv"
|
||
|
||
lines = [
|
||
f"Channel: PJSIP/{dial}@mmedia",
|
||
f"CallerID: {CALLER_ID}",
|
||
"Context: SS-Autocall-Slot",
|
||
"Extension: s",
|
||
"Priority: 1",
|
||
"MaxRetries: 0",
|
||
"WaitTime: 45",
|
||
"Archive: yes",
|
||
f"Setvar: SCENARIO={scenario}",
|
||
f"Setvar: CALLTYPE={call_type}",
|
||
f"Setvar: GROUP_ID={group.get('id') or ''}",
|
||
f"Setvar: ATTEMPT={attempt_no}",
|
||
f"Setvar: CUSTOMER_PHONE={phone}",
|
||
f"Setvar: API_URL={API_URL}",
|
||
f"Setvar: API_KEY={API_KEY}",
|
||
f"Setvar: TEST_MODE={1 if test_mode else 0}",
|
||
"",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
def push_call_file(group, settings, attempt_no, is_test_call=False):
|
||
"""Кладёт call-файл на АТС через SSH."""
|
||
content = build_call_file(group, settings, attempt_no, is_test_call)
|
||
gid = str(group.get("id") or "test")[:8]
|
||
filename = f"ss-{gid}-{int(time.time())}.call"
|
||
# Пишем файл на АТС через sshpass + cat
|
||
cmd = [
|
||
"sshpass", "-p", ATS_PASS,
|
||
"ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||
"-o", "ConnectTimeout=10", "-q",
|
||
f"{ATS_USER}@{ATS_HOST}",
|
||
f"cat > {ATS_SPOOL}/{filename}",
|
||
]
|
||
try:
|
||
proc = subprocess.run(cmd, input=content.encode(), capture_output=True, timeout=20)
|
||
if proc.returncode != 0:
|
||
log.error(f"Failed to push call file for {group['id']}: {proc.stderr.decode()[:200]}")
|
||
return False
|
||
log.info(f"Pushed call file {filename} for group {group['id']} (phone={group.get('customer_phone')})")
|
||
return True
|
||
except Exception as e:
|
||
log.error(f"SSH push error for {group['id']}: {e}")
|
||
return False
|
||
|
||
def get_queued_calls(conn):
|
||
"""Ручные запросы «Позвонить сейчас» из autocall_queue."""
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT q.id as queue_id, q.order_group_id, q.phone, q.scenario, q.is_test_call
|
||
FROM autocall_queue q
|
||
WHERE q.processed_at IS NULL
|
||
ORDER BY q.requested_at ASC
|
||
""")
|
||
rows = [dict(r) for r in cur.fetchall()]
|
||
# Подтянем телефон из order_groups для звонков по конкретному заказу
|
||
# (для test-call order_group_id может быть NULL — номер берём из q.phone)
|
||
ids = tuple(r["order_group_id"] for r in rows if r["order_group_id"])
|
||
if ids:
|
||
cur.execute("""
|
||
SELECT og.id, og.customer_phone, og.customer_phone_normalized, og.created_at
|
||
FROM order_groups og WHERE og.id IN %s
|
||
""", (ids,))
|
||
og = {r["id"]: r for r in cur.fetchall()}
|
||
for r in rows:
|
||
g = og.get(r["order_group_id"]) if r["order_group_id"] else None
|
||
if g:
|
||
r["customer_phone_normalized"] = g.get("customer_phone_normalized")
|
||
r.setdefault("customer_phone", g.get("customer_phone"))
|
||
r.setdefault("created_at", g.get("created_at"))
|
||
return rows
|
||
|
||
|
||
def mark_queue_processed(conn, queue_id):
|
||
with conn.cursor() as cur:
|
||
cur.execute("UPDATE autocall_queue SET processed_at = now() WHERE id = %s", (queue_id,))
|
||
conn.commit()
|
||
|
||
|
||
def process_queue(conn, settings):
|
||
"""Забирает ручные запросы и создаёт call-файлы. Возвращает число созданных."""
|
||
queued = get_queued_calls(conn)
|
||
if not queued:
|
||
return 0
|
||
# Номера попыток для ручных звонков — с учётом уже сделанных автозвонков
|
||
attempt_map = {}
|
||
pushed = 0
|
||
for q in queued:
|
||
group_id = q["order_group_id"]
|
||
is_test = bool(q.get("is_test_call"))
|
||
# Номер: для теста — из q.phone; для заказа — подтянут в customer_phone через get_queued_calls
|
||
phone = q.get("phone") or q.get("customer_phone") or ""
|
||
if not phone:
|
||
log.warning(f"Queue item {q['queue_id']} has no phone, skip")
|
||
continue
|
||
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||
excl = get_exclusion_match(conn, phone)
|
||
if excl:
|
||
log.info(f"Queue item {q['queue_id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
||
mark_queue_processed(conn, q["queue_id"])
|
||
continue
|
||
if group_id not in attempt_map:
|
||
if group_id:
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (group_id,))
|
||
attempt_map[group_id] = (cur.fetchone()[0] or 0) + 1
|
||
else:
|
||
attempt_map[group_id] = 1
|
||
attempt_no = attempt_map[group_id]
|
||
g = dict(q)
|
||
g["id"] = group_id
|
||
g["customer_phone"] = phone
|
||
# Для теста не подставляем customer_phone_normalized (нет заказа)
|
||
if is_test:
|
||
g["customer_phone_normalized"] = None
|
||
if push_call_file(g, settings, attempt_no, is_test_call=is_test):
|
||
mark_queue_processed(conn, q["queue_id"])
|
||
pushed += 1
|
||
attempt_map[group_id] = attempt_no + 1
|
||
return pushed
|
||
|
||
|
||
def has_pending_items(conn):
|
||
"""Дёшево: есть ли непрочитанные ручные запросы. Для fast-path, чтобы не шуметь в логе."""
|
||
with conn.cursor() as cur:
|
||
cur.execute("SELECT 1 FROM autocall_queue WHERE processed_at IS NULL LIMIT 1")
|
||
return cur.fetchone() is not None
|
||
|
||
|
||
def backfill_no_answer(conn, settings):
|
||
"""Дописывает no_answer звонкам, которые не получили ответа сети.
|
||
|
||
Call-файл с истёкшим WaitTime не запускает AGI — в autocall_log ничего
|
||
не пишется, и «не дозвонились» не появляется никогда. Ищем обработанные
|
||
заявки старше 10 минут без строки в логе и дописываем no_answer.
|
||
Самозакрывающаяся: вставленная строка сама удовлетворяет проверке
|
||
существования, повторов не будет.
|
||
"""
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT q.id, q.order_group_id, q.phone, q.is_test_call,
|
||
q.requested_at, q.processed_at
|
||
FROM autocall_queue q
|
||
WHERE q.processed_at IS NOT NULL
|
||
AND q.processed_at < NOW() - make_interval(mins => 10)
|
||
ORDER BY q.processed_at ASC
|
||
LIMIT 20
|
||
""")
|
||
rows = [dict(r) for r in cur.fetchall()]
|
||
backfilled = 0
|
||
for q in rows:
|
||
gid = q["order_group_id"]
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
if gid:
|
||
cur.execute("""
|
||
SELECT 1 FROM autocall_log l
|
||
WHERE l.order_group_id = %s AND l.created_at >= %s LIMIT 1
|
||
""", (gid, q["requested_at"]))
|
||
else:
|
||
cur.execute("""
|
||
SELECT 1 FROM autocall_log l
|
||
WHERE l.order_group_id IS NULL
|
||
AND l.customer_phone = %s
|
||
AND l.created_at >= %s LIMIT 1
|
||
""", (normalize_phone(q["phone"] or ""), q["requested_at"]))
|
||
if cur.fetchone():
|
||
continue # строка есть (AGI отработал) — дописывать нечего
|
||
# Телефон из заказа (или нормализованный из заявки для pure-теста)
|
||
phone = normalize_phone(q["phone"] or "")
|
||
group_created = None
|
||
if gid:
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
cur.execute("""
|
||
SELECT customer_phone_normalized, customer_phone, created_at
|
||
FROM order_groups WHERE id = %s
|
||
""", (gid,))
|
||
grow = cur.fetchone() or {}
|
||
phone = (grow.get("customer_phone_normalized")
|
||
or normalize_phone(grow.get("customer_phone") or "")
|
||
or phone)
|
||
group_created = grow.get("created_at")
|
||
# По исключённым номерам звонка не было — строку не пишем
|
||
# (статус «исключение» и так показывается из настроек).
|
||
if get_exclusion_match(conn, phone):
|
||
continue
|
||
scenario = resolve_scenario(group_created) if gid else "A"
|
||
with conn.cursor() as cur:
|
||
if gid:
|
||
cur.execute("SELECT count(*) FROM autocall_log WHERE order_group_id = %s", (gid,))
|
||
attempt_no = (cur.fetchone()[0] or 0) + 1
|
||
else:
|
||
attempt_no = 1
|
||
test_mode = bool(q["is_test_call"] or settings.get("test_mode", True))
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
INSERT INTO autocall_log
|
||
(order_group_id, customer_phone, attempt_no, scenario,
|
||
call_started_at, outcome, was_test_mode, channel_state)
|
||
VALUES (%s, %s, %s, %s, %s, 'no_answer', %s, 'NoAnswer')
|
||
""", (gid, phone, attempt_no, scenario,
|
||
q["processed_at"] or q["requested_at"], test_mode))
|
||
conn.commit()
|
||
backfilled += 1
|
||
log.info(f"Backfilled no_answer for queue item {q['id']} (phone={phone})")
|
||
return backfilled
|
||
|
||
|
||
def main():
|
||
conn = get_db_conn()
|
||
try:
|
||
settings = load_settings(conn)
|
||
|
||
# Добор «не взял трубку»: заявки, по которым сеть так и не ответила
|
||
# (истёкший call-файл, AGI не запускался). Только работа с БД.
|
||
try:
|
||
nb = backfill_no_answer(conn, settings)
|
||
if nb:
|
||
log.info(f"Backfilled {nb} no_answer rows")
|
||
except Exception as e:
|
||
log.warning(f"backfill_no_answer failed: {e}")
|
||
|
||
# Fast-path: если кампания выключена и очередь пуста — выходим молча.
|
||
# Таймер стучит каждую минуту; при 1440 тиках/сутки не хотим шум в логе.
|
||
if not settings.get("enabled", False) and not has_pending_items(conn):
|
||
return
|
||
|
||
# 0) Ручные звонки «Позвонить сейчас» — даже если кампания выключена,
|
||
# но в рабочее время и enabled_or_queue. Ручной запрос — явный сигнал.
|
||
if is_work_time(settings):
|
||
nq = process_queue(conn, settings)
|
||
if nq:
|
||
log.info(f"Processed {nq} queued manual calls")
|
||
else:
|
||
log.info("Outside work hours, manual queue not processed")
|
||
|
||
if not settings.get("enabled", False):
|
||
log.info("Autocall campaign disabled (enabled=false), skipping auto")
|
||
return
|
||
|
||
groups = get_groups_to_call(conn, settings)
|
||
log.info(f"Found {len(groups)} groups to call")
|
||
|
||
pushed = 0
|
||
for group in groups:
|
||
phone = group.get("customer_phone_normalized") or group.get("customer_phone") or ""
|
||
# Исключение: на этот номер не звоним (например, МТС-диапазон)
|
||
excl = get_exclusion_match(conn, phone)
|
||
if excl:
|
||
log.info(f"Group {group['id']} skipped by exclusion ({excl['rule_type']}={excl['value']}): {excl.get('reason') or 'no reason'}")
|
||
continue
|
||
attempt_no = (group.get("attempt_count") or 0) + 1
|
||
if push_call_file(group, settings, attempt_no):
|
||
pushed += 1
|
||
time.sleep(1) # пауза между звонками
|
||
|
||
log.info(f"Pushed {pushed} call files")
|
||
finally:
|
||
conn.close()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|