#!/usr/bin/env python3 """ SuperSam — SMS First Campaign (State Machine) Заменяет n8n workflow "Первая отправка согласования" Архитектура: state machine через cron (каждые 5 мин) Каждый запуск: 1. Отправляет SMS новым группам (ready_to_launch + pending_confirmation + нет недавней SMS) 2. Проверяет статус ранее отправленных SMS (sent но не delivered, в пределах max_check_duration) 3. Обновляет статусы в order_groups + sms_campaign_log Защита от повторной отправки (ДВОЙНАЯ): 1. После отправки SMS → notification_status = 'sms_sending' (не 'link_ready') → get_groups_to_send НЕ находит эту группу (фильтр по notification_status = 'link_ready') 2. Дополнительно: EXISTS проверка в sms_campaign_log (status sent/checking за последние 24h) 3. Код 231/132 = лимит одинаковых → не повторять needs_check=true — admin нажал "Проверить снова" в UI → скрипт проверяет даже если запись старше max_check_duration Коды sms.ru: 100 = в очереди, 101 = оператору, 102 = в пути → ждём 103 = доставлено → цель 104-108, 130-132, 230-232 = ошибки доставки / лимиты """ import os import sys import json import logging import time import fcntl 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") SMS_API_ID = os.environ.get("SMS_API_ID", "C92063B3-95ED-8559-157B-1946EB5A2486") SMS_SEND_URL = "https://sms.ru/sms/send" SMS_STATUS_URL = "https://sms.ru/sms/status" LOG_FILE = "/var/log/supersam-sms-first.log" # ТЕСТОВЫЙ РЕЖИМ — управляется из админки (sms_campaign_settings.test_mode) # По умолчанию ВКЛЮЧЕН — SMS идут только на test_phone # Мегаадмин выключает через админку → SMS идут реальным клиентам # Коды, которые означают "в процессе" (ждём дальше) IN_TRANSIT_CODES = {"100", "101", "102"} # Код доставки DELIVERED_CODE = "103" # Коды ошибок доставки (не временные, можно повторять отправку) DELIVERY_ERROR_CODES = {"104", "105", "106", "107", "108", "130"} # Коды превышения лимитов (нельзя повторять отправку — заблокируют) LIMIT_ERROR_CODES = {"131", "132", "230", "231", "232"} # ─── Логирование ───────────────────────────────────────────────────────────── 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_first") # ─── БД ────────────────────────────────────────────────────────────────────── 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 = 'first_sms'") row = cur.fetchone() if not row: return { "wait_between_checks_seconds": 25, "max_check_duration_minutes": 90, "max_attempts": 2, "enabled": True, "telegram_chat_id": "25164483", "sms_api_id": SMS_API_ID, "test_mode": True, "test_phone": "79788382260", } return dict(row) # ─── SMS API ───────────────────────────────────────────────────────────────── def normalize_phone(phone): """Нормализует телефон: только цифры, начинается с 7. Возвращает None если пустой. Обрабатляет: - +7XXX, 7XXX, 8XXX, XXX (без кода страны) → 7XXX - Текст/имена в поле (Екатерина +7978...) → извлекает только цифры - Два номера в одном поле (XXX / YYY) → берёт первый (обрезает до 11 цифр) - Минимум 10 цифр (без 7), максимум 11 (с 7) """ clean = "".join(c for c in str(phone) if c.isdigit()) if len(clean) < 10: return None # If 8 prefix (Russian landline style) → replace with 7 if clean.startswith("8") and len(clean) == 11: clean = "7" + clean[1:] # If starts with 7 and is 11 digits → ok elif clean.startswith("7") and len(clean) == 11: pass # If 10 digits (no country code) → prepend 7 elif len(clean) == 10: clean = "7" + clean # If too long (multiple numbers concatenated) → take first 10 digits + prepend 7 elif len(clean) > 11: # Try to extract first number: look for 10-digit sequence starting with 9 # Common case: "9242377967 / 89783225219" → "9242377967" (10 digits) clean10 = clean[:10] clean = "7" + clean10 # If starts with 7 but wrong length → prepend 7 to first 10 digits elif not clean.startswith("7"): clean = "7" + clean return clean def send_sms(phone, message, api_id): """Отправляет SMS, возвращает (sms_id, raw_response)""" try: clean_phone = normalize_phone(phone) resp = requests.post(SMS_SEND_URL, params={ "api_id": api_id, "to": clean_phone, }, data={"msg": message}, timeout=30) text = resp.text log.info(f"SMS send to {clean_phone}: {text[:200]}") lines = text.strip().split("\n") status_code = lines[0].strip() if lines else "" if status_code == "100" and len(lines) >= 2: sms_id = lines[1].strip() return sms_id, text, "100" else: return None, text, status_code except Exception as e: log.error(f"SMS send error: {e}") return None, str(e), "error" def check_sms_status(sms_id, api_id): """Проверяет статус, возвращает (sms_status_code, raw_response, api_code)""" try: resp = requests.post(SMS_STATUS_URL, params={ "api_id": api_id, "sms_id": sms_id, }, timeout=30) text = resp.text lines = text.strip().split("\n") status_code = lines[0].strip() if lines else "" sms_status_code = lines[1].strip() if len(lines) >= 2 else None log.info(f"SMS status for {sms_id}: code={status_code}, sms_status={sms_status_code}") return sms_status_code, text, status_code except Exception as e: log.error(f"SMS status check error: {e}") return None, str(e), "error" def fetch_balance(api_id): """Получает баланс sms.ru, возвращает (balance_float, raw)""" try: resp = requests.get("https://sms.ru/my/balance", params={"api_id": api_id}, timeout=15) text = resp.text lines = text.strip().split("\n") if lines[0].strip() == "100" and len(lines) >= 2: return float(lines[1].strip()), text return None, text except Exception as e: log.error(f"Balance fetch error: {e}") return None, str(e) # ─── Telegram ──────────────────────────────────────────────────────────────── def send_telegram(message, chat_id): pass # Telegram notifications moved to n8n+Supabase integration # ─── State Machine ─────────────────────────────────────────────────────────── def get_groups_to_send(conn): """Группы, которым ещё не отправили SMS (или отправили очень давно)""" with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT og.id, og.group_key, og.customer_name, og.customer_phone, og.customer_phone_normalized, og.delivery_link, og.notification_status, og.sms_attempts FROM order_groups og WHERE og.status = 'ready_to_launch' AND og.delivery_status = 'pending_confirmation' AND og.delivery_link IS NOT NULL AND og.delivery_link != '' AND COALESCE(og.notification_status, '') = 'link_ready' -- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl WHERE scl.order_group_id = og.id AND scl.campaign_type = 'first_sms' AND scl.status IN ('sent', 'checking') AND scl.created_at > NOW() - INTERVAL '2 hours' ) ORDER BY og.created_at ASC """) return [dict(r) for r in cur.fetchall()] def get_sms_to_check(conn, max_duration_min): """SMS в логе со status='sent'/'checking', которые ещё не доставлены. Включает: - Записи младше max_duration_min (обычная автопроверка) - Записи с needs_check=true (admin нажал 'Проверить снова') — независимо от возраста """ with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, scl.attempts, scl.created_at, scl.sms_code, scl.needs_check, og.customer_name, og.group_key FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'first_sms' AND scl.status IN ('sent', 'checking') AND scl.sms_id IS NOT NULL AND ( scl.created_at > NOW() - INTERVAL '%s minutes' OR scl.needs_check = true ) ORDER BY scl.needs_check DESC, scl.created_at ASC """ % max_duration_min) return [dict(r) for r in cur.fetchall()] def get_sms_expired(conn, max_duration_min): """SMS, у которых истёк срок проверки (старше max_check_duration, не доставлены). Также считаем общее количество failed попыток для группы. """ with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute(""" SELECT scl.id as log_id, scl.sms_id, scl.order_group_id, scl.customer_phone, scl.attempts, scl.created_at, og.customer_name, og.group_key, og.delivery_link, og.sms_attempts as group_sms_attempts, (SELECT COUNT(*) FROM sms_campaign_log scl2 WHERE scl2.order_group_id = scl.order_group_id AND scl2.campaign_type = 'first_sms' AND scl2.status IN ('expired', 'error', 'limit_exceeded')) as total_failed FROM sms_campaign_log scl JOIN order_groups og ON og.id = scl.order_group_id WHERE scl.campaign_type = 'first_sms' AND scl.status IN ('sent', 'checking') AND scl.sms_id IS NOT NULL AND scl.created_at < NOW() - INTERVAL '%s minutes' ORDER BY scl.created_at ASC """ % max_duration_min) return [dict(r) for r in cur.fetchall()] def insert_sms_log(conn, **kwargs): with conn.cursor() as cur: cols = ", ".join(kwargs.keys()) placeholders = ", ".join(["%s"] * len(kwargs)) cur.execute(f"INSERT INTO sms_campaign_log ({cols}) VALUES ({placeholders}) RETURNING id", list(kwargs.values())) log_id = cur.fetchone()[0] conn.commit() return log_id def update_sms_log(conn, log_id, **kwargs): with conn.cursor() as cur: set_parts = [] values = [] for k, v in kwargs.items(): if v == "NOW()": set_parts.append(f"{k} = NOW()") else: set_parts.append(f"{k} = %s") values.append(v) values.append(log_id) cur.execute(f"UPDATE sms_campaign_log SET {', '.join(set_parts)}, updated_at = NOW() WHERE id = %s", values) conn.commit() def update_order_group(conn, group_id, fields): with conn.cursor() as cur: set_parts = [] values = [] for k, v in fields.items(): if v == "NOW()": set_parts.append(f"{k} = NOW()") 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 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): """Проверка: сейчас рабочие часы. settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5') """ 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") 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", 8) end_h = settings.get("work_hours_end", 21) return start_h <= hour < end_h # ─── Основная логика ───────────────────────────────────────────────────────── def step_send_new(conn, settings, test_send=False): """Шаг 1: Отправка SMS новым группам""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) max_attempts = settings.get("max_attempts", 2) second_sms_delay = settings.get("second_sms_delay_hours", 3) if test_send: # Test mode: send ONE SMS to test phone, pick first available group all_groups = get_groups_to_send(conn) if not all_groups: log.info("Test send: no groups available in queue") return 0 groups = [all_groups[0]] # Only first group log.info(f"TEST SEND: sending 1 SMS to test phone (skipping {len(all_groups)-1} others)") else: groups = get_groups_to_send(conn) log.info(f"Step 1: {len(groups)} groups to send SMS") sent_count = 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_normalized") or group.get("customer_phone", "") delivery_link = group.get("delivery_link", "") # Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки) with conn.cursor() as cur: cur.execute(""" SELECT COUNT(*) as cnt FROM sms_campaign_log WHERE order_group_id = %s AND campaign_type = 'first_sms' AND created_at > NOW() - INTERVAL '24 hours' AND status IN ('sent', 'checking', 'delivered') """, (group_id,)) recent_count = cur.fetchone()[0] if recent_count > 0: log.info(f"Group {group_id}: already has recent SMS in log, skipping") continue sms_text_template = settings.get("sms_text_template", "Ваш заказ готов. Согласуйте дату доставки по ссылке: {link}") sms_text = sms_text_template.replace("{link}", delivery_link + "?src=sms") # ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый send_phone = phone if settings.get("test_mode", True): send_phone = settings.get("test_phone", "79788382260") log.info(f"TEST MODE: sending to {send_phone} instead of {phone}") log.info(f"Sending SMS to {name} (orig={phone}, send={send_phone})") sms_id, raw, code = send_sms(send_phone, sms_text, api_id) if sms_id: # Use sms_attempts from order_groups (incremented on retry by step_handle_expired) current_attempts = (group.get("sms_attempts") or 0) + 1 log_id = insert_sms_log(conn, campaign_type="first_sms", order_group_id=group_id, customer_phone=phone, sms_id=sms_id, sms_text=sms_text, sent_to=send_phone, status="sent", sms_code=code, attempts=current_attempts, was_test_mode=bool(settings.get("test_mode", True)), ) # ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status, # чтобы get_groups_to_send не нашёл эту группу при следующем запуске update_order_group(conn, group_id, { "notification_status": "sms_sending", "sms_sent_at": "NOW()", "sms_attempts": current_attempts, }) log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending") sent_count += 1 time.sleep(settings.get("send_interval_seconds", 15)) # Configurable rate limit else: # Ошибка отправки error = raw[:500] if raw else "Unknown error" log_id = insert_sms_log(conn, campaign_type="first_sms", order_group_id=group_id, customer_phone=phone, sms_text=sms_text, sent_to=send_phone if "send_phone" in dir() else phone, status="send_failed", sms_code=code, attempts=1, error_message=error, was_test_mode=bool(settings.get("test_mode", True)), ) log.error(f"Group {group_id}: SMS send failed (code={code}): {error[:200]}") update_order_group(conn, group_id, { "notification_status": "send_failed", "last_sms_error": error[:200], }) send_telegram(f"❌ SMS не отправлена: {name} ({phone})\nКод: {code}\nОшибка: {error[:200]}", tg_chat) return sent_count def step_check_status(conn, settings): """Шаг 2: Проверка статуса ранее отправленных SMS""" api_id = settings.get("sms_api_id", SMS_API_ID) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) max_duration = settings.get("max_check_duration_minutes", 90) max_attempts = settings.get("max_attempts", 2) second_sms_delay = settings.get("second_sms_delay_hours", 3) sms_list = get_sms_to_check(conn, max_duration) log.info(f"Step 2: {len(sms_list)} SMS to check status") delivered = 0 for item in sms_list: log_id = item["log_id"] sms_id = item["sms_id"] group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") attempts = item.get("attempts", 1) was_needs_check = item.get("needs_check", False) code, raw, api_code = check_sms_status(sms_id, api_id) # Сбрасываем needs_check + ставим checked_at update_sms_log(conn, log_id, needs_check=False, checked_at="NOW()") if code == DELIVERED_CODE: # Доставлено! log.info(f"Group {group_id}: SMS delivered (103)!") update_sms_log(conn, log_id, status="delivered", sms_code=code) next_check = (datetime.now(timezone.utc) + timedelta(hours=second_sms_delay)).isoformat() update_order_group(conn, group_id, { "notification_status": "first_sms_sent", "sms_attempts": attempts, "first_sms_sent_at": "NOW()", "last_sms_error": None, "sms_sent_at": "NOW()", "next_notification_check_at": next_check, "status": "first_sms_sent", }) send_telegram(f"✅ SMS доставлена: {name} ({phone})", tg_chat) delivered += 1 elif code in IN_TRANSIT_CODES: # В пути / в очереди — продолжаем ждать log.info(f"Group {group_id}: SMS in transit (code={code}), will check again next run") update_sms_log(conn, log_id, status="checking", sms_code=code) elif code in DELIVERY_ERROR_CODES: # Ошибка доставки — можно повторить отправку log.error(f"Group {group_id}: SMS delivery error (code={code})") update_sms_log(conn, log_id, status="error", sms_code=code, error_message=f"Delivery error: {code}") # Переход к следующей попытке отправки (если есть) if attempts < max_attempts: log.info(f"Group {group_id}: retry first SMS (attempt {attempts+1}/{max_attempts})") update_sms_log(conn, log_id, status="expired") update_order_group(conn, group_id, { "notification_status": "link_ready", "sms_attempts": attempts, }) else: update_order_group(conn, group_id, { "notification_status": "send_failed", "last_sms_error": f"Delivery failed after {max_attempts} attempts (code={code})", }) send_telegram(f"⚠️ SMS не доставлена после {max_attempts} попыток: {name} ({phone})\nКод: {code}", tg_chat) elif code in LIMIT_ERROR_CODES: # Превышен лимит — НЕ повторять отправку! log.error(f"Group {group_id}: SMS limit exceeded (code={code}) — NOT retrying") update_sms_log(conn, log_id, status="limit_exceeded", sms_code=code, error_message=f"Limit exceeded: code={code}") update_order_group(conn, group_id, { "notification_status": "send_failed", "last_sms_error": f"Limit exceeded (code={code}), sending blocked", }) send_telegram(f"🚫 SMS заблокирована (лимит {code}): {name} ({phone})", tg_chat) else: # Неизвестный код — логируем, продолжаем проверять log.warning(f"Group {group_id}: unknown SMS code: {code}") update_sms_log(conn, log_id, status="checking", sms_code=code, error_message=f"Unknown code: {code}") return delivered def step_handle_expired(conn, settings): """Шаг 3: Обработка SMS с истёкшим сроком проверки""" max_duration = settings.get("max_check_duration_minutes", 90) max_attempts = settings.get("max_attempts", 2) tg_chat = settings.get("telegram_chat_id", TELEGRAM_CHAT_ID) expired = get_sms_expired(conn, max_duration) log.info(f"Step 3: {len(expired)} SMS expired (older than {max_duration} min)") for item in expired: log_id = item["log_id"] group_id = str(item["order_group_id"]) name = item.get("customer_name") or item.get("group_key", "—") phone = item.get("customer_phone", "") attempts = item.get("attempts", 1) delivery_link = item.get("delivery_link", "") total_failed = item.get("total_failed", 0) # Use total_failed across ALL attempts, not just this one effective_attempts = max(attempts, total_failed) log.warning(f"Group {group_id}: SMS expired after {max_duration} min, attempts={attempts}/{max_attempts}, total_failed={total_failed}") update_sms_log(conn, log_id, status="expired", error_message=f"Not delivered in {max_duration} minutes") if effective_attempts < max_attempts: # Повторная отправка ТЕКУЩЕЙ SMS (не откат назад) update_order_group(conn, group_id, { "notification_status": "link_ready", "sms_attempts": effective_attempts, }) log.info(f"Group {group_id}: retry first SMS (attempt {effective_attempts+1}/{max_attempts}), notification_status→link_ready") else: # Все попытки исчерпаны update_order_group(conn, group_id, { "notification_status": "manual_required", "delivery_status": "manual_confirmation_required", "last_sms_error": f"Not delivered after {max_attempts} attempts", "status": "manual_required", }) send_telegram( f"🔧 Требуется ручное управление: {name} ({phone})\n" f"SMS не доставлена после {max_attempts} попыток", tg_chat, ) # ─── 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("SMS First 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 = 'first_sms'", ) 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 = 'first_sms'", ) conn.commit() log.info("Test send requested — will send ONE SMS to test phone only") # Check run_requested — restart scenario (reset all groups to beginning) 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 = 'first_sms'") conn.commit() with conn.cursor() as cur: cur.execute("""UPDATE order_groups SET notification_status = 'link_ready', sms_sent_at = NULL, first_sms_sent_at = NULL, second_sms_sent_at = NULL, next_notification_check_at = NULL, sms_attempts = 0, last_sms_error = NULL WHERE delivery_status = 'pending_confirmation' AND notification_status IN ('first_sms_sent','sms_sending','send_failed','second_sms_sent','second_sms_sending','manual_required','not_started')""") reset_count = cur.rowcount conn.commit() log.info(f'RESTART first_sms: reset {reset_count} groups to link_ready') log.info(f"Settings: wait={settings.get('wait_between_checks_seconds')}s, " f"max_duration={settings.get('max_check_duration_minutes')}min, " f"max_attempts={settings.get('max_attempts')}") if not settings.get("enabled", True): log.info("Campaign disabled, exiting") return # State machine — каждый шаг быстрый, без blocking # Отправка только в рабочие часы work_hours = is_within_work_hours(settings) sent = 0 if work_hours: sent = step_send_new(conn, settings, test_send=test_send_requested) else: log.info("Outside work hours, skipping new SMS sends") # Проверка статусов работает всегда delivered = step_check_status(conn, settings) step_handle_expired(conn, settings) log.info(f"Run summary: sent={sent}, delivered={delivered}") if sent > 0 or delivered > 0: send_telegram( f"📊 Первая отправка\nОтправлено: {sent}\nДоставлено: {delivered}", settings.get("telegram_chat_id", TELEGRAM_CHAT_ID), ) # Обновляем баланс sms.ru if sent > 0: api_id = settings.get("sms_api_id", SMS_API_ID) balance, raw = fetch_balance(api_id) if balance is not None: with conn.cursor() as cur: cur.execute( "UPDATE sms_campaign_settings SET last_balance = %s WHERE campaign_type = 'first_sms'", (balance,) ) conn.commit() log.info(f"Balance updated: {balance} ₽") 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("SMS First Campaign — END") log.info("=" * 60) if __name__ == "__main__": main()