Соцсети: добавлена кнопка-ссылка на сайт infocyber.pro
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,5 +17,8 @@ __pycache__/
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Single-instance lock-файл бота (рантайм)
|
||||
bot.lock
|
||||
|
||||
# Резервные копии исходных картинок (до кадрирования)
|
||||
help_img/_orig/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
+12
-4
@@ -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,
|
||||
|
||||
+21
-3
@@ -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(
|
||||
|
||||
+3
-27
@@ -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()
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user