diff --git a/.gitignore b/.gitignore index e44fe6a..3dc9efc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,8 @@ __pycache__/ # Logs *.log +# Single-instance lock-файл бота (рантайм) +bot.lock + # Резервные копии исходных картинок (до кадрирования) help_img/_orig/ diff --git a/bot.py b/bot.py index 429c3e4..78f422f 100644 --- a/bot.py +++ b/bot.py @@ -40,6 +40,8 @@ NOTIFY_COOLDOWN_SEC = 120 BAN_STRIKES = 3 BAN_WINDOW_SEC = 60 BAN_SEC = 600 +# Сколько секунд кэшировать список рантайм-админов из БД +ADMINS_CACHE_TTL = 30 from validators import ( RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX, @@ -48,6 +50,37 @@ from validators import ( from keyboards import * # все get_*_keyboard +# Хэндл lock-файла держим в глобали на весь процесс — пока он открыт, блокировка жива. +_INSTANCE_LOCK_HANDLE = None + + +def _acquire_single_instance_lock() -> bool: + """Не даёт запустить второй экземпляр бота (два параллельных longpoll ломают UX). + + Берём неблокирующую эксклюзивную блокировку на bot.lock в каталоге скрипта. + Кросс-платформенно: msvcrt на Windows, fcntl на POSIX. Если уже заблокирован + другим процессом — возвращаем False (выживает уже запущенный инстанс). + """ + global _INSTANCE_LOCK_HANDLE + import os + lock_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bot.lock") + handle = None + try: + handle = open(lock_path, "w") + if os.name == "nt": + import msvcrt + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + if handle is not None: + handle.close() # лок занят (или сбой) — не оставляем хэндл открытым + return False + _INSTANCE_LOCK_HANDLE = handle # держим открытым до конца процесса + return True + + # ========================================== # КЛАСС БОТА И ЛОГИКА ОБРАБОТКИ # ========================================== @@ -69,6 +102,10 @@ class VKBotApplication: self._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен self._last_notify: Dict[int, float] = {} # user_id -> время последнего уведомления оператора + self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда + # Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение + self._admins_cache: Optional[set] = None + self._admins_cache_ts: float = 0.0 # Инициализируем динамические услуги из базы данных config.SERVICES = db.get_services() @@ -122,16 +159,29 @@ class VKBotApplication: """Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде).""" return user_id in getattr(config, "SUPER_ADMIN_IDS", []) + def _runtime_admins(self) -> set: + """Кэшированный набor админов, добавленных в рантайме (из БД). + + Перечитываем из SQLite не чаще раза в ADMINS_CACHE_TTL — _is_admin вызывается + многократно на каждое сообщение, и прямой запрос каждый раз был бы расточителен. + Кэш инвалидируется при добавлении/удалении админа. + """ + now = time.time() + if self._admins_cache is None or now - self._admins_cache_ts > ADMINS_CACHE_TTL: + try: + self._admins_cache = set(db.get_admins()) + except Exception: + self._admins_cache = set() + self._admins_cache_ts = now + return self._admins_cache + def _is_admin(self, user_id: int) -> bool: """Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме.""" if self._is_super_admin(user_id): return True if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: return True - try: - return user_id in db.get_admins() - except Exception: - return False + return user_id in self._runtime_admins() def _operator_ids(self) -> list: """Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID.""" @@ -223,6 +273,7 @@ class VKBotApplication: return new_id = int(arg) if db.add_admin(new_id): + self._admins_cache = None # инвалидируем кэш админов self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.") try: self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. " @@ -243,12 +294,33 @@ class VKBotApplication: return rem_id = int(arg) if db.remove_admin(rem_id): + self._admins_cache = None # инвалидируем кэш админов self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") else: self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " "(админов из .env и супер-админов отсюда снять нельзя).") self._send_admins_panel(user_id) + def _cleanup_antiflood(self): + """Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно.""" + now = time.time() + if now - self._last_antiflood_cleanup < BAN_SEC: + return + self._last_antiflood_cleanup = now + # Сообщения/страйки старше своих окон бесполезны — удаляем пустые ключи + for uid in [u for u, ts in self._msg_times.items() + if not ts or now - ts[-1] > FLOOD_WINDOW_SEC]: + del self._msg_times[uid] + for uid in [u for u, ts in self._flood_strikes.items() + if not ts or now - ts[-1] > BAN_WINDOW_SEC]: + del self._flood_strikes[uid] + for uid in [u for u, t in self._banned_until.items() if t <= now]: + del self._banned_until[uid] + for uid in [u for u, t in self._flood_warned.items() if now - t > FLOOD_WINDOW_SEC]: + del self._flood_warned[uid] + for uid in [u for u, t in self._last_notify.items() if now - t > NOTIFY_COOLDOWN_SEC]: + del self._last_notify[uid] + def _notify_allowed(self, user_id: int) -> bool: """Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе.""" now = time.time() @@ -263,6 +335,9 @@ class VKBotApplication: text_lower = text_clean.lower() attachments = attachments or [] + # Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение) + self._cleanup_antiflood() + # Анти-флуд + временный бан (админы — исключение) if not self._is_admin(user_id): now = time.time() @@ -788,12 +863,19 @@ class VKBotApplication: # Показываем услуги каруселью (с фолбэком на текст) self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n") - def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): - """Обработка выбора услуги с прямым переходом к проверке данных.""" - selected_service = None + @staticmethod + def _match_service(text: str) -> Optional[Dict[str, str]]: + """Сопоставляет введённый текст с услугой: сначала по синонимам дисциплин, + затем по ключу/названию (с очисткой эмодзи). Возвращает svc или None.""" text_lower = text.lower().strip() - - # 1. Прямое сопоставление синонимов дисциплин для надежности + + # 0. Точное совпадение по номеру/ключу услуги — высший приоритет. + # Иначе нечёткий поиск ниже ловит «2» внутри «counter-strike 2» и ломает выбор по номеру. + for key, svc in config.SERVICES.items(): + if text_lower in (key, f"{key}.", f"{key}️⃣"): + return svc + + # 1. Прямое сопоставление синонимов дисциплин для надёжности synonyms = { "1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"], "2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"], @@ -801,25 +883,26 @@ class VKBotApplication: } for key_opt, words in synonyms.items(): if any(w in text_lower for w in words): - selected_service = config.SERVICES.get(key_opt) - break - - # 2. Поиск по названию или ключу (если синонимы не сработали) - if not selected_service: - for key, svc in config.SERVICES.items(): - name_clean = svc["name"].lower().strip() - # Убираем эмодзи и спецсимволы для гибкого сравнения - name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() - text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() - - if (text_lower == key or - text_lower == f"{key}." or - text_lower == f"{key}️⃣" or - name_clean in text_lower or + svc = config.SERVICES.get(key_opt) + if svc: + return svc + + # 2. Нечёткий поиск по названию (если точный ключ и синонимы не сработали) + for key, svc in config.SERVICES.items(): + name_clean = svc["name"].lower().strip() + # Убираем эмодзи и спецсимволы для гибкого сравнения + name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() + text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() + + if (name_clean in text_lower or text_lower in name_clean or (name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))): - selected_service = svc - break + return svc + return None + + def _handle_form_question(self, user_id: int, text: str, user_data: Dict[str, Any]): + """Обработка выбора услуги с прямым переходом к проверке данных.""" + selected_service = self._match_service(text) if selected_service: # Сохраняем временный выбор в поле question @@ -1039,35 +1122,7 @@ class VKBotApplication: def _handle_form_edit_question(self, user_id: int, text: str): """Сохранение новой услуги и возврат к проверке.""" - selected_service = None - text_lower = text.lower().strip() - - # 1. Прямое сопоставление синонимов дисциплин для надежности - synonyms = { - "1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"], - "2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"], - "3": ["танки", "танк", "wot", "world of tanks", "ворлд оф танкс", "вот", "tanks"] - } - for key_opt, words in synonyms.items(): - if any(w in text_lower for w in words): - selected_service = config.SERVICES.get(key_opt) - break - - # 2. Поиск по названию или ключу (если синонимы не сработали) - if not selected_service: - for key, svc in config.SERVICES.items(): - name_clean = svc["name"].lower().strip() - name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).strip() - text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip() - - if (text_lower == key or - text_lower == f"{key}." or - text_lower == f"{key}️⃣" or - name_clean in text_lower or - text_lower in name_clean or - (name_no_emoji and text_no_emoji and (text_no_emoji in name_no_emoji or name_no_emoji in text_no_emoji))): - selected_service = svc - break + selected_service = self._match_service(text) if selected_service: db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") @@ -2474,5 +2529,9 @@ class VKBotApplication: if __name__ == "__main__": + import sys + if not _acquire_single_instance_lock(): + logger.error("Уже запущен другой экземпляр бота — выходим.") + sys.exit(0) bot = VKBotApplication() bot.run() diff --git a/config.py b/config.py index fad56fd..6be5877 100644 --- a/config.py +++ b/config.py @@ -15,7 +15,9 @@ def _load_env(): if "=" in line: key, val = line.split("=", 1) val = val.strip().strip('"').strip("'") - os.environ[key.strip()] = val + # Не перетираем переменные, уже заданные в реальном окружении + # (на проде токены приходят из env, .env — лишь фолбэк). + os.environ.setdefault(key.strip(), val) except Exception as e: print(f"Warning: Failed to load .env file: {e}") diff --git a/database.py b/database.py index c7d2fb8..cba807e 100644 --- a/database.py +++ b/database.py @@ -338,12 +338,20 @@ class DatabaseManager: ) conn.commit() - def count_web_registrations(self) -> int: - """Количество заявок с сайта (для генерации следующего ID независимо от Excel).""" + def next_web_registration_id(self) -> str: + """Следующий ID веб-заявки вида id_004 по МАКСИМАЛЬНОМУ существующему номеру. + + Считаем по max(числовой суффикс), а не по COUNT: при удалении записей + COUNT+1 может совпасть с существующим id и нарушить PRIMARY KEY. + """ with self._get_connection() as conn: cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM web_registrations") - return cursor.fetchone()[0] + # substr(id, 4) отрезает префикс 'id_' и приводит к числу + cursor.execute( + "SELECT COALESCE(MAX(CAST(substr(id, 4) AS INTEGER)), 0) FROM web_registrations " + "WHERE id LIKE 'id\\_%' ESCAPE '\\'" + ) + return f"id_{cursor.fetchone()[0] + 1:03d}" def add_lead(self, user_id: int, source: str, name: str, phone: str, service: Optional[str] = None, call_time: Optional[str] = None, diff --git a/form/server.py b/form/server.py index 3be54db..da6b730 100644 --- a/form/server.py +++ b/form/server.py @@ -76,10 +76,21 @@ _rate_hits = defaultdict(list) _submit_lock = threading.Lock() +_last_rate_cleanup = 0.0 + + def is_rate_limited(ip: str) -> bool: """True, если с этого IP было слишком много заявок за окно времени.""" + global _last_rate_cleanup now = time.time() with _rate_lock: + # Периодически выметаем протухшие IP, чтобы словарь не рос бесконечно + if now - _last_rate_cleanup > RATE_LIMIT_WINDOW: + for old_ip in [k for k, v in _rate_hits.items() + if not v or now - v[-1] >= RATE_LIMIT_WINDOW]: + del _rate_hits[old_ip] + _last_rate_cleanup = now + hits = [t for t in _rate_hits[ip] if now - t < RATE_LIMIT_WINDOW] if len(hits) >= RATE_LIMIT_MAX: _rate_hits[ip] = hits @@ -196,7 +207,8 @@ def generate_next_id(): """Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной.""" if db: try: - return f"id_{db.count_web_registrations() + 1:03d}" + # По max(номеру), а не по COUNT — иначе при удалении записей возможна коллизия PRIMARY KEY + return db.next_web_registration_id() except Exception as e: print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.") # Запасной вариант — по строкам Excel @@ -265,9 +277,15 @@ def _add_registration_locked(data): # Промокод пока выключен флагом config.PROMO_ENABLED promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None - # 1. Запись в SQLite базу данных — основной приёмник, выполняется первым + # 1. Запись в SQLite базу данных — основной приёмник, выполняется первым. + # Ошибка записи (например, коллизия ID) НЕ должна срывать остальные каналы + # доставки (Excel/VK/почта) — иначе заявка теряется целиком. if db: - db.add_web_registration(next_id, data) + try: + db.add_web_registration(next_id, data) + except Exception as reg_err: + print(f"[{current_time}] ⚠️ Не удалось записать web_registration {next_id}: {reg_err}. " + f"Продолжаем доставку через Excel/VK/почту.") # Единый журнал заявок для статистики (source='web') try: db.add_lead( diff --git a/keyboards.py b/keyboards.py index f58c91a..205b55c 100644 --- a/keyboards.py +++ b/keyboards.py @@ -104,32 +104,6 @@ def get_vacancy_choice_keyboard() -> str: return keyboard.get_keyboard() -def get_vacancy_discipline_keyboard() -> str: - """Выбор дисциплины для набора в состав.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("Counter-Strike 2 🔫", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Dota 2 🎮", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("World of Tanks 🛡️", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - -def get_vacancy_position_keyboard() -> str: - """Выбор должности для найма сотрудника.""" - keyboard = VkKeyboard(one_time=False) - keyboard.add_button("SMM / контент", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Монтажёр / дизайнер", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Менеджер", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Разработчик", color=VkKeyboardColor.SECONDARY) - keyboard.add_line() - keyboard.add_button("Другое ✍️", color=VkKeyboardColor.SECONDARY) - keyboard.add_button("Отменить ❌", color=VkKeyboardColor.NEGATIVE) - return keyboard.get_keyboard() - - def get_vacancy_cancel_keyboard() -> str: """Клавиатура отмены отклика на свободном текстовом шаге.""" keyboard = VkKeyboard(one_time=False) @@ -178,6 +152,8 @@ def get_socials_keyboard() -> str: keyboard.add_line() keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro") keyboard.add_line() + keyboard.add_openlink_button("Наш сайт 🔗", "https://infocyber.pro/") + keyboard.add_line() keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY) return keyboard.get_keyboard() @@ -314,7 +290,7 @@ def get_edit_menu_keyboard() -> str: keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY) keyboard.add_line() - keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.NEGATIVE) + keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.SECONDARY) return keyboard.get_keyboard() diff --git a/start_bot.bat b/start_bot.bat index d7c054e..5bd78f8 100644 --- a/start_bot.bat +++ b/start_bot.bat @@ -3,7 +3,10 @@ chcp 65001 >nul cd /d "%~dp0" title Бот ИКП (VK) -REM Защита от нескольких экземпляров: убиваем старый бот перед запуском +REM Защита от нескольких экземпляров. ОБЯЗАТЕЛЬНО глушим старые процессы бота +REM перед стартом: иначе два longpoll-инстанса (старый и новый) отвечают на одни +REM и те же нажатия, и цвета кнопок «пляшут» (старая палитра vs новая). +REM Внутри бота дополнительно есть lock-файл (bot.lock) — подстраховка от дублей. powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*bot.py*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" >nul 2>&1 :loop