feat(autocall): добавить autocall_queue (ручной звонок через планировщик). Миграция 20260902 + чтение очереди в scheduler

This commit is contained in:
Hermes 2026-09-02 10:56:02 +02:00
parent 75476c9d70
commit 4ed7f64a37
2 changed files with 307 additions and 0 deletions

View File

@ -0,0 +1,287 @@
#!/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 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,
(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 build_call_file(group, settings, attempt_no):
"""Формирует содержимое 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
test_mode = settings.get("test_mode", True)
if 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
scenario = group.get("scenario", "A") # выбор дня + времени
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['id']}",
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):
"""Кладёт call-файл на АТС через SSH."""
content = build_call_file(group, settings, attempt_no)
filename = f"ss-{group['id'][:8]}-{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
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 для call-файла
if rows:
ids = tuple(r["order_group_id"] for r in rows)
cur.execute("""
SELECT og.id, og.customer_phone, og.customer_phone_normalized
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 g:
r["customer_phone_normalized"] = g.get("customer_phone_normalized")
r.setdefault("customer_phone", g.get("customer_phone"))
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"]
if group_id not in attempt_map:
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
attempt_no = attempt_map[group_id]
g = dict(q)
g["id"] = group_id
if push_call_file(g, settings, attempt_no):
mark_queue_processed(conn, q["queue_id"])
pushed += 1
attempt_map[group_id] = attempt_no + 1
return pushed
def main():
conn = get_db_conn()
try:
settings = load_settings(conn)
# 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:
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()

View File

@ -0,0 +1,20 @@
-- ============================================================
-- SuperSam: Автодозвон — очередь ручных звонков (Фаза 3, п.9)
-- 02.09.2026. Аддитивная. Убирает нехватку autocall_queue из базовой миграции.
-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f <file>
-- ============================================================
create table if not exists public.autocall_queue (
id bigint generated always as identity primary key,
order_group_id uuid not null references public.order_groups(id) on delete cascade,
phone text not null,
scenario text not null default 'A',
requested_by uuid,
requested_at timestamptz not null default now(),
processed_at timestamptz
);
create index if not exists idx_autocall_queue_pending
on public.autocall_queue(processed_at) where processed_at is null;
grant select, insert, update on public.autocall_queue to authenticated;
comment on table public.autocall_queue
is 'Ручные запросы «Позвонить сейчас». Планировщик забирает processed_at is null и создаёт call-файл (без ssh из edge fn).';