Compare commits

..

13 Commits

Author SHA1 Message Date
Kenik ce2860aec9 start_bot.bat: авто-подготовка окружения при первом запуске
- проверка наличия Python в PATH;
- создание .env из .env.example, если его нет (с открытием на заполнение);
- установка зависимостей из requirements.txt;
- далее как прежде: глушение дублей + цикл авто-перезапуска.
bot_database.db создаётся кодом автоматически (с сидом услуг).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:20:24 +03:00
Kenik 0aaf285d54 Соцсети: Discord заменён на Lolka (новая ссылка через vk away)
Кнопка соцсетей и текст «О компании» теперь ведут на Lolka:
https://vk.com/away.php?to=https%3A%2F%2Flolka.gg%2FlNfM3F9DD&utf=1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:58:05 +03:00
Kenik ffdd7fc1fc Seed: зашиты все 6 услуг (включая групповые) для свежих установок
Чтобы при запуске из репозитория услуги были сразу, а не только дефолтные 3.
Заявки/персональные данные по-прежнему только в локальной БД (не в git).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:53:34 +03:00
Kenik 26b6df77ef удаление документа "Dogovor_oferty_kibersport_versia_izmenennaya.docx" и добавление новых изображений для помощи в сервисе. 2026-06-26 17:00:41 +03:00
Kenik f14b8769e5 Добавление админа по ссылке VK (не только числовой ID)
Бот принимает ссылку (https://vk.com/gruzdevas), короткое имя (gruzdevas),
@имя или числовой ID и сам определяет user_id (короткое имя — через VK API
users.get). Резолвер _resolve_vk_user_id применён к добавлению и удалению админа.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:06:45 +03:00
Kenik 0ec849d80f Соцсети: добавлена кнопка-ссылка на сайт infocyber.pro
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:21:52 +03:00
Kenik fc3361c7ea Фикс клавиатуры возвращающегося клиента + защита от двух экземпляров
- Возвращающийся клиент: «Да/Отмена» — нижняя клавиатура, а «Сменить имя/
  телефон/Заполнить заново» — отдельные инлайн-кнопки под сообщением (ровно 3,
  как просил).
- start_bot.bat: перед запуском убивает старые экземпляры бота — устраняет
  «рандомные» цвета от нескольких параллельных longpoll-инстансов.
- Маска телефона в приветствии возвращающегося клиента.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:14:57 +03:00
Kenik 60ef76024e Правки кнопок + VK-имя в уведомлении о заявке
- Админка — всегда зелёная (POSITIVE).
- Меню изменения данных: «Назад к проверке» — красная.
- Возвращающийся клиент: «Да/Сменить имя/телефон/Заполнить заново/Отмена» —
  инлайн-кнопки под сообщением.
- Уведомление оператору: добавлено реальное имя из VK с флагом «не совпадает
  с введённым» — помогает отсеивать рандомные/фейковые данные (профиль VK уже был).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 09:00:06 +03:00
Kenik a7ae2cec21 Единая семантическая палитра кнопок
Синий (PRIMARY) — только действия/подтверждение/добавление (Заполнить, Да,
Согласен, Отправить,  Добавить). Белый (SECONDARY) — выбор и навигация
(Карьера, Частые вопросы, разделы админки, слоты времени, «назад»). Красный
(NEGATIVE) — отмена/удаление/выход/оператор. Убрана пестрота главного меню и
разнобой при выходе из формы.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:37:26 +03:00
Kenik 33906e7e7d Возврат исходной палитры кнопок: убран зелёный POSITIVE
Зелёного цвета (POSITIVE) в оригинальных клавиатурах не было — он появился
при добавлении карьеры и админ-разделов. Заменён на PRIMARY (синий) во всех
7 местах, чтобы вернуть исходную палитру (PRIMARY/SECONDARY/NEGATIVE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 08:20:56 +03:00
Kenik 667ca4d4f0 Предзагрузка всех картинок при старте + многопоточный веб-сервер
- _prewarm_media грузит ВСЕ *.jpg из help_img при старте (кроме bg.jpg),
  чтобы смена картинок услуг не подгружалась лениво в момент сообщения.
- Веб-форма: ThreadingHTTPServer — медленный запрос не блокирует остальные;
  приём заявки сериализован _submit_lock (без гонок в SQLite/Excel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:58:22 +03:00
Kenik 7ebcb851e7 Тесты: покрытие админ-CRUD (услуги, FAQ, вакансии, админы, рассылка, стата)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:55:39 +03:00
Kenik 46a7d2571f Pytest-набор: валидаторы, БД, интеграционные флоу
tests/ — 19 тестов: validators (телефон/имя/формат), database (услуги, FAQ,
вакансии, отклики, админы, лиды, подписчики), интеграция (полный флоу заявки,
карьера игрока, анти-флуд не роняет бота, доступ к админке). Работают на
временной БД, реальную не трогают. Запуск: python -m pytest tests/ -q

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:53:41 +03:00
15 changed files with 585 additions and 144 deletions
+3
View File
@@ -17,5 +17,8 @@ __pycache__/
# Logs # Logs
*.log *.log
# Single-instance lock-файл бота (рантайм)
bot.lock
# Резервные копии исходных картинок (до кадрирования) # Резервные копии исходных картинок (до кадрирования)
help_img/_orig/ help_img/_orig/
Binary file not shown.
+217 -80
View File
@@ -40,6 +40,8 @@ NOTIFY_COOLDOWN_SEC = 120
BAN_STRIKES = 3 BAN_STRIKES = 3
BAN_WINDOW_SEC = 60 BAN_WINDOW_SEC = 60
BAN_SEC = 600 BAN_SEC = 600
# Сколько секунд кэшировать список рантайм-админов из БД
ADMINS_CACHE_TTL = 30
from validators import ( from validators import (
RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX, RU_PHONE_REGEX, INTL_PHONE_REGEX, NAME_REGEX,
@@ -48,6 +50,37 @@ from validators import (
from keyboards import * # все get_*_keyboard 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._flood_strikes: Dict[int, list] = defaultdict(list) # user_id -> метки срабатываний флуда
self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен self._banned_until: Dict[int, float] = {} # user_id -> до какого времени забанен
self._last_notify: 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() config.SERVICES = db.get_services()
@@ -122,16 +159,29 @@ class VKBotApplication:
"""Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде).""" """Супер-админ (определён только в config.SUPER_ADMIN_IDS, меняется в коде)."""
return user_id in getattr(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: def _is_admin(self, user_id: int) -> bool:
"""Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме.""" """Админ: супер-админ, либо из config.ADMIN_IDS, либо добавленный в рантайме."""
if self._is_super_admin(user_id): if self._is_super_admin(user_id):
return True return True
if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS: if hasattr(config, "ADMIN_IDS") and user_id in config.ADMIN_IDS:
return True return True
try: return user_id in self._runtime_admins()
return user_id in db.get_admins()
except Exception:
return False
def _operator_ids(self) -> list: def _operator_ids(self) -> list:
"""Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID.""" """Список ID операторов (несколько). Фолбэк — один OPERATOR_PEER_ID."""
@@ -143,6 +193,46 @@ class VKBotApplication:
for op_id in self._operator_ids(): for op_id in self._operator_ids():
self.send_message(op_id, message) self.send_message(op_id, message)
def _vk_name(self, user_id: int) -> Optional[str]:
"""Реальное имя пользователя из VK (для сверки с введёнными данными)."""
if not self.vk or user_id <= 0:
return None
try:
info = self.vk.users.get(user_ids=user_id)
if info:
u = info[0]
full = f"{u.get('first_name', '')} {u.get('last_name', '')}".strip()
return full or None
except Exception as e:
logger.error(f"Не удалось получить имя VK для {user_id}: {e}")
return None
def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
Принимает что угодно: https://vk.com/gruzdevas, vk.com/id123, @gruzdevas,
gruzdevas, 123456. Короткое имя резолвится через VK API.
"""
s = (raw or "").strip()
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
s = re.sub(r"^(m\.)?vk\.com/", "", s, flags=re.I) # убираем домен
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
if not s:
return None
m = re.match(r"^id(\d+)$", s, flags=re.I) # id123456
if m:
return int(m.group(1))
if s.isdigit(): # чистое число
return int(s)
if self.vk: # короткое имя → через API
try:
info = self.vk.users.get(user_ids=s)
if info:
return int(info[0]["id"])
except Exception as e:
logger.error(f"Не удалось определить VK id по '{s}': {e}")
return None
@staticmethod @staticmethod
def _progress_bar(step: int, total: int = 3) -> str: def _progress_bar(step: int, total: int = 3) -> str:
"""Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3.""" """Визуальный прогресс шага формы: ▰▰▱ Шаг 2 из 3."""
@@ -186,7 +276,7 @@ class VKBotApplication:
lines += "\n➕ Добавленные из бота:\n" lines += "\n➕ Добавленные из бота:\n"
lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or "\n") lines += ("".join(f" • vk.com/id{a}\n" for a in runtime) or "\n")
kb = VkKeyboard(inline=True) kb = VkKeyboard(inline=True)
kb.add_button(" Добавить админа", color=VkKeyboardColor.POSITIVE) kb.add_button(" Добавить админа", color=VkKeyboardColor.PRIMARY)
kb.add_line() kb.add_line()
kb.add_button("🗑 Удалить админа", color=VkKeyboardColor.NEGATIVE) kb.add_button("🗑 Удалить админа", color=VkKeyboardColor.NEGATIVE)
self.send_message( self.send_message(
@@ -203,12 +293,20 @@ class VKBotApplication:
if not self._is_super_admin(user_id): if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.") self.send_message(user_id, "⛔ Добавлять админов может только супер-админ.")
return return
arg = text.split(" ", 2)[-1].strip() raw = text
if not arg.lstrip("-").isdigit(): if text.lower().startswith("админ доб"):
self.send_message(user_id, "⚠️ Формат: админ доб [VK_ID числом]") raw = text[len("админ доб"):].strip()
new_id = self._resolve_vk_user_id(raw)
if not new_id:
self.send_message(
user_id,
"⚠️ Не смог определить пользователя.\n"
"Пришлите ссылку на страницу VK (например, https://vk.com/gruzdevas), "
"короткое имя (gruzdevas) или числовой ID."
)
return return
new_id = int(arg)
if db.add_admin(new_id): if db.add_admin(new_id):
self._admins_cache = None # инвалидируем кэш админов
self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.") self.send_message(user_id, f"✅ Пользователь vk.com/id{new_id} теперь администратор.")
try: try:
self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. " self.send_message(new_id, "🛡 Вам выданы права администратора бота ИКП. "
@@ -223,18 +321,41 @@ class VKBotApplication:
if not self._is_super_admin(user_id): if not self._is_super_admin(user_id):
self.send_message(user_id, "⛔ Удалять админов может только супер-админ.") self.send_message(user_id, "⛔ Удалять админов может только супер-админ.")
return return
arg = text.split(" ", 2)[-1].strip() raw = text
if not arg.lstrip("-").isdigit(): if text.lower().startswith("админ удал"):
self.send_message(user_id, "⚠️ Формат: админ удал [VK_ID числом]") raw = text[len("админ удал"):].strip()
rem_id = self._resolve_vk_user_id(raw)
if not rem_id:
self.send_message(user_id, "⚠️ Не смог определить пользователя. Пришлите ссылку/имя/ID.")
return return
rem_id = int(arg)
if db.remove_admin(rem_id): if db.remove_admin(rem_id):
self._admins_cache = None # инвалидируем кэш админов
self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.")
else: else:
self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота "
"(админов из .env и супер-админов отсюда снять нельзя).") "(админов из .env и супер-админов отсюда снять нельзя).")
self._send_admins_panel(user_id) 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: def _notify_allowed(self, user_id: int) -> bool:
"""Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе.""" """Антиспам: не чаще раза в NOTIFY_COOLDOWN_SEC уведомляем оператора об одном пользователе."""
now = time.time() now = time.time()
@@ -249,6 +370,9 @@ class VKBotApplication:
text_lower = text_clean.lower() text_lower = text_clean.lower()
attachments = attachments or [] attachments = attachments or []
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
self._cleanup_antiflood()
# Анти-флуд + временный бан (админы — исключение) # Анти-флуд + временный бан (админы — исключение)
if not self._is_admin(user_id): if not self._is_admin(user_id):
now = time.time() now = time.time()
@@ -400,8 +524,9 @@ class VKBotApplication:
self.send_message(user_id, "⛔ Только супер-админ.") self.send_message(user_id, "⛔ Только супер-админ.")
return return
db.update_user_state(user_id, "ADMIN_ADMIN_ADD") db.update_user_state(user_id, "ADMIN_ADMIN_ADD")
self.send_message(user_id, " Пришлите VK ID нового администратора (числом).\n" self.send_message(user_id, " Пришлите ссылку на страницу нового администратора в VK\n"
"ID можно узнать на vk.com/<id> или через сервисы определения id.", "(например, https://vk.com/gruzdevas). Можно также короткое имя "
"или числовой ID — бот сам разберётся.",
keyboard=get_cancel_admin_keyboard()) keyboard=get_cancel_admin_keyboard())
return return
elif text_lower in ["🗑 удалить админа", "удалить админа"]: elif text_lower in ["🗑 удалить админа", "удалить админа"]:
@@ -641,6 +766,17 @@ class VKBotApplication:
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))) self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
return return
# 6.7 Автору поесть (запрет на удаление)
if text_lower in ["/favi","/Favi", "/FAVI" ]:
self.send_message(
user_id,
"Пасихалка для знающих\n\n"
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
"Желаю хороших впечатлений! От этого бота\n\n"
"https://vk.com/favilonia",
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
)
return
# 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру) # 7. Fallback (Бот не понял, но пытается помочь, предлагая клавиатуру)
self.send_message( self.send_message(
user_id, user_id,
@@ -649,18 +785,24 @@ class VKBotApplication:
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)) keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
) )
def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]): def _send_returning_prompt(self, user_id: int, user_data: Dict[str, Any]):
"""Приветствие для клиента, чьи имя и телефон уже сохранены.""" """Приветствие для клиента, чьи имя и телефон уже сохранены."""
name = user_data.get("name") name = user_data.get("name")
phone = user_data.get("phone") phone = format_phone_display(user_data.get("phone", ""))
self.send_message( self.send_message(
user_id, user_id,
f"Здравствуйте! Мы вас помним, {name}. 👋\n\n" f"Здравствуйте! Мы вас помним, {name}. 👋\n\n"
f"Ваш номер телефона по-прежнему {phone}?\n\n" f"Ваш номер телефона по-прежнему {phone}?\n\n"
"Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги. " "Если всё верно — нажмите «Да, всё верно ✅», и сразу перейдём к выбору услуги.",
"Либо измените данные или заполните заявку заново.",
keyboard=get_returning_keyboard() keyboard=get_returning_keyboard()
) )
# Кнопки изменения данных — инлайн, под отдельным сообщением
self.send_message(
user_id,
"Нужно поправить данные? 👇",
keyboard=get_returning_changes_keyboard()
)
def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]): def _handle_form_returning(self, user_id: int, text: str, text_lower: str, user_data: Dict[str, Any]):
"""Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново.""" """Развилка для возвращающегося клиента: подтвердить данные / сменить / заполнить заново."""
@@ -769,12 +911,19 @@ class VKBotApplication:
# Показываем услуги каруселью (с фолбэком на текст) # Показываем услуги каруселью (с фолбэком на текст)
self._send_services(user_id, f"✅ Телефон принят!\n\n{self._progress_bar(3)}\nКакое направление вас интересует?\n\n") 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]): @staticmethod
"""Обработка выбора услуги с прямым переходом к проверке данных.""" def _match_service(text: str) -> Optional[Dict[str, str]]:
selected_service = None """Сопоставляет введённый текст с услугой: сначала по синонимам дисциплин,
затем по ключу/названию (с очисткой эмодзи). Возвращает svc или None."""
text_lower = text.lower().strip() 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 = { synonyms = {
"1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"], "1": ["кс", "cs", "ксго", "кс2", "csgo", "cs2", "контра", "кондра", "counter", "counter-strike", "counter strike"],
"2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"], "2": ["дота", "dota", "дотку", "дотка", "dota2", "дота2", "доту"],
@@ -782,25 +931,26 @@ class VKBotApplication:
} }
for key_opt, words in synonyms.items(): for key_opt, words in synonyms.items():
if any(w in text_lower for w in words): if any(w in text_lower for w in words):
selected_service = config.SERVICES.get(key_opt) svc = config.SERVICES.get(key_opt)
break if svc:
return svc
# 2. Поиск по названию или ключу (если синонимы не сработали) # 2. Нечёткий поиск по названию (если точный ключ и синонимы не сработали)
if not selected_service: for key, svc in config.SERVICES.items():
for key, svc in config.SERVICES.items(): name_clean = svc["name"].lower().strip()
name_clean = svc["name"].lower().strip() # Убираем эмодзи и спецсимволы для гибкого сравнения
# Убираем эмодзи и спецсимволы для гибкого сравнения name_no_emoji = re.sub(r"[^\w\s\d]", "", name_clean).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()
text_no_emoji = re.sub(r"[^\w\s\d]", "", text_lower).strip()
if (text_lower == key or if (name_clean in text_lower or
text_lower == f"{key}." or
text_lower == f"{key}️⃣" or
name_clean in text_lower or
text_lower in name_clean 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))): (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 return svc
break 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: if selected_service:
# Сохраняем временный выбор в поле question # Сохраняем временный выбор в поле question
@@ -1020,35 +1170,7 @@ class VKBotApplication:
def _handle_form_edit_question(self, user_id: int, text: str): def _handle_form_edit_question(self, user_id: int, text: str):
"""Сохранение новой услуги и возврат к проверке.""" """Сохранение новой услуги и возврат к проверке."""
selected_service = None selected_service = self._match_service(text)
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
if selected_service: if selected_service:
db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})") db.update_user_field(user_id, "question", f"{selected_service['name']} ({selected_service['price']})")
@@ -1194,15 +1316,15 @@ class VKBotApplication:
try: try:
peer = config.OPERATOR_PEER_ID peer = config.OPERATOR_PEER_ID
base = os.path.join(os.path.dirname(__file__), "help_img") base = os.path.join(os.path.dirname(__file__), "help_img")
files = ["Logo.jpg", "cs2.jpg", "dota2.jpg", "wot.jpg", # Грузим ВСЕ картинки из папки (кроме фона веб-формы), чтобы ничего
"career_player.jpg", "career_worker.jpg"] # не подгружалось лениво в момент сообщения.
# Загруженные админом картинки услуг (service_<id>.jpg) — приоритетные для карусели count = 0
files += [os.path.basename(p) for p in glob.glob(os.path.join(base, "service_*.jpg"))] for path in sorted(glob.glob(os.path.join(base, "*.jpg"))):
for fn in files: if os.path.basename(path).lower() == "bg.jpg":
path = os.path.join(base, fn) continue
if os.path.exists(path): if self._upload_photo(peer, path):
self._upload_photo(peer, path) count += 1
logger.info("Медиа предзагружены в VK — карусель и логотип будут открываться быстро.") logger.info(f"Медиа предзагружены в VK ({count} картинок) — открываются мгновенно.")
except Exception as e: except Exception as e:
logger.error(f"Не удалось предзагрузить медиа: {e}") logger.error(f"Не удалось предзагрузить медиа: {e}")
@@ -1761,9 +1883,20 @@ class VKBotApplication:
consent_3 = user_data.get("consent_3", "Согласен") consent_3 = user_data.get("consent_3", "Согласен")
promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else "" promo_line = f"🎁 Промокод клиента: {promo}\n" if promo else ""
# Реальное имя из VK + флаг несовпадения (помогает отсеять «рандомные» данные)
vk_name = self._vk_name(user_id)
if vk_name:
mismatch = ""
if name and name.strip().lower() not in vk_name.lower() and vk_name.lower() not in name.strip().lower():
mismatch = " ⚠️ не совпадает с введённым!"
vk_name_line = f"🪪 Имя в VK: {vk_name}{mismatch}\n"
else:
vk_name_line = ""
operator_msg = ( operator_msg = (
f"⚡ Получена новая заявка!\n\n" f"⚡ Получена новая заявка!\n\n"
f"👤 Имя: {name}\n" f"👤 Имя (введено): {name}\n"
f"{vk_name_line}"
f"📞 Телефон: {format_phone_display(phone)}\n" f"📞 Телефон: {format_phone_display(phone)}\n"
f"🎯 Выбранная услуга: {question}\n" f"🎯 Выбранная услуга: {question}\n"
f"🕒 Удобное время звонка: {call_time}\n" f"🕒 Удобное время звонка: {call_time}\n"
@@ -1813,8 +1946,8 @@ class VKBotApplication:
lines += "\n💼 Должности (сотрудники):\n" lines += "\n💼 Должности (сотрудники):\n"
lines += ("".join(f" • [{i}] {l}\n" for i, l in workers) or "\n") lines += ("".join(f" • [{i}] {l}\n" for i, l in workers) or "\n")
kb = VkKeyboard(inline=True) kb = VkKeyboard(inline=True)
kb.add_button(" Вакансия игрок", color=VkKeyboardColor.POSITIVE) kb.add_button(" Вакансия игрок", color=VkKeyboardColor.PRIMARY)
kb.add_button("➕ Вакансия сотрудник", color=VkKeyboardColor.POSITIVE) kb.add_button("➕ Вакансия сотрудник", color=VkKeyboardColor.PRIMARY)
kb.add_line() kb.add_line()
kb.add_button("🗑 Удалить вакансию", color=VkKeyboardColor.NEGATIVE) kb.add_button("🗑 Удалить вакансию", color=VkKeyboardColor.NEGATIVE)
self.send_message( self.send_message(
@@ -2014,7 +2147,7 @@ class VKBotApplication:
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
self._send_admin_panel(user_id) self._send_admin_panel(user_id)
return return
self._handle_admin_add_admin(user_id, "админ доб " + pick_id(text)) self._handle_admin_add_admin(user_id, text) # передаём сырой текст (ссылка/имя/ID)
db.update_user_state(user_id, "FREE_CHAT") db.update_user_state(user_id, "FREE_CHAT")
return return
@@ -2358,7 +2491,7 @@ class VKBotApplication:
"Отправить или отменить?" "Отправить или отменить?"
) )
kb = VkKeyboard(inline=True) kb = VkKeyboard(inline=True)
kb.add_button("Отправить ✅", color=VkKeyboardColor.POSITIVE, payload={"cmd": "bc_send"}) kb.add_button("Отправить ✅", color=VkKeyboardColor.PRIMARY, payload={"cmd": "bc_send"})
kb.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE, payload={"cmd": "bc_cancel"}) kb.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE, payload={"cmd": "bc_cancel"})
self.send_message(user_id, preview, keyboard=kb.get_keyboard()) self.send_message(user_id, preview, keyboard=kb.get_keyboard())
@@ -2444,5 +2577,9 @@ class VKBotApplication:
if __name__ == "__main__": if __name__ == "__main__":
import sys
if not _acquire_single_instance_lock():
logger.error("Уже запущен другой экземпляр бота — выходим.")
sys.exit(0)
bot = VKBotApplication() bot = VKBotApplication()
bot.run() bot.run()
+5 -3
View File
@@ -15,7 +15,9 @@ def _load_env():
if "=" in line: if "=" in line:
key, val = line.split("=", 1) key, val = line.split("=", 1)
val = val.strip().strip('"').strip("'") val = val.strip().strip('"').strip("'")
os.environ[key.strip()] = val # Не перетираем переменные, уже заданные в реальном окружении
# (на проде токены приходят из env, .env — лишь фолбэк).
os.environ.setdefault(key.strip(), val)
except Exception as e: except Exception as e:
print(f"Warning: Failed to load .env file: {e}") print(f"Warning: Failed to load .env file: {e}")
@@ -101,7 +103,7 @@ FAQ_ANSWERS = {
"📞 Связаться с ИКП:\n" "📞 Связаться с ИКП:\n"
"• Email (поддержка): support@infocyber.pro\n" "• Email (поддержка): support@infocyber.pro\n"
"• Заявки: group-zakaz@infocyber.pro\n" "• Заявки: group-zakaz@infocyber.pro\n"
"• Telegram: @ikp_team\n" "• Telegram: https://t.me/ikp_team\n"
"• Сайт: https://infocyber.pro/" "• Сайт: https://infocyber.pro/"
), ),
"услуги": ( "услуги": (
@@ -119,7 +121,7 @@ FAQ_ANSWERS = {
"• ВКонтакте: https://vk.com/ikpro\n" "• ВКонтакте: https://vk.com/ikpro\n"
"• VK Live (трансляции): https://live.vkvideo.ru/ikp\n" "• VK Live (трансляции): https://live.vkvideo.ru/ikp\n"
"• Telegram: https://t.me/ikp_team\n" "• Telegram: https://t.me/ikp_team\n"
"Discord: https://discord.gg/z92mAw7KEe" "Lolka: https://vk.com/away.php?to=https%3A%2F%2Flolka.gg%2FlNfM3F9DD&utf=1"
) )
} }
+30 -7
View File
@@ -66,17 +66,32 @@ class DatabaseManager:
"1": { "1": {
"name": "Counter-Strike 2 🔫", "name": "Counter-Strike 2 🔫",
"price": "от 870 ₽ / занятие", "price": "от 870 ₽ / занятие",
"description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽." "description": "CS2: aim, раскидки, тактика. Разовое 870 ₽, абонементы от 2 790 ₽."
}, },
"2": { "2": {
"name": "Dota 2 🎮", "name": "Dota 2 🎮",
"price": "от 870 ₽ / занятие", "price": "от 870 ₽ / занятие",
"description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽." "description": "Dota 2: реплеи, позиционка, фарм, тимплей. Разовое 870 ₽, абонементы от 2 790 ₽."
}, },
"3": { "3": {
"name": "World of Tanks 🛡️", "name": "World of Tanks 🛡️",
"price": "от 870 ₽ / занятие", "price": "от 870 ₽ / занятие",
"description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽." "description": "WoT: позиционирование, сведение, тактика. Разовое 870 ₽, абонементы от 2 790 ₽."
},
"4": {
"name": "Counter-Strike 2 🔫 (групповой)",
"price": "от 870 ₽",
"description": "Тренировки в группе, командное взаимодействие."
},
"5": {
"name": "Dota 2 🎮 (групповой)",
"price": "от 870 ₽",
"description": "Тренировки в группе, командное взаимодействие."
},
"6": {
"name": "World of Tanks 🛡️ (групповой)",
"price": "от 870 ₽",
"description": "Тренировки в группе, командное взаимодействие."
} }
} }
for key, svc in default_services.items(): for key, svc in default_services.items():
@@ -338,12 +353,20 @@ class DatabaseManager:
) )
conn.commit() conn.commit()
def count_web_registrations(self) -> int: def next_web_registration_id(self) -> str:
"""Количество заявок с сайта (для генерации следующего ID независимо от Excel).""" """Следующий ID веб-заявки вида id_004 по МАКСИМАЛЬНОМУ существующему номеру.
Считаем по max(числовой суффикс), а не по COUNT: при удалении записей
COUNT+1 может совпасть с существующим id и нарушить PRIMARY KEY.
"""
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM web_registrations") # substr(id, 4) отрезает префикс 'id_' и приводит к числу
return cursor.fetchone()[0] 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, def add_lead(self, user_id: int, source: str, name: str, phone: str,
service: Optional[str] = None, call_time: Optional[str] = None, service: Optional[str] = None, call_time: Optional[str] = None,
+31 -5
View File
@@ -6,7 +6,7 @@ import random
import string import string
import threading import threading
from collections import defaultdict from collections import defaultdict
from http.server import HTTPServer, SimpleHTTPRequestHandler from http.server import HTTPServer, ThreadingHTTPServer, SimpleHTTPRequestHandler
from datetime import datetime from datetime import datetime
import sys import sys
@@ -72,12 +72,25 @@ RATE_LIMIT_MAX = 5
RATE_LIMIT_WINDOW = 600 # секунд (10 минут) RATE_LIMIT_WINDOW = 600 # секунд (10 минут)
_rate_lock = threading.Lock() _rate_lock = threading.Lock()
_rate_hits = defaultdict(list) _rate_hits = defaultdict(list)
# Сериализует приём заявок (генерация ID + запись), чтобы при многопоточности не было гонок
_submit_lock = threading.Lock()
_last_rate_cleanup = 0.0
def is_rate_limited(ip: str) -> bool: def is_rate_limited(ip: str) -> bool:
"""True, если с этого IP было слишком много заявок за окно времени.""" """True, если с этого IP было слишком много заявок за окно времени."""
global _last_rate_cleanup
now = time.time() now = time.time()
with _rate_lock: 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] hits = [t for t in _rate_hits[ip] if now - t < RATE_LIMIT_WINDOW]
if len(hits) >= RATE_LIMIT_MAX: if len(hits) >= RATE_LIMIT_MAX:
_rate_hits[ip] = hits _rate_hits[ip] = hits
@@ -194,7 +207,8 @@ def generate_next_id():
"""Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной.""" """Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной."""
if db: if db:
try: try:
return f"id_{db.count_web_registrations() + 1:03d}" # По max(номеру), а не по COUNT — иначе при удалении записей возможна коллизия PRIMARY KEY
return db.next_web_registration_id()
except Exception as e: except Exception as e:
print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.") print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.")
# Запасной вариант — по строкам Excel # Запасной вариант — по строкам Excel
@@ -253,14 +267,25 @@ def gen_promo():
def add_registration(data): def add_registration(data):
"""Сохраняет заявку: SQLite (источник правды) → Excel (нестрого) → уведомления. Возвращает промокод.""" """Сохраняет заявку: SQLite (источник правды) → Excel (нестрого) → уведомления. Возвращает промокод."""
with _submit_lock:
return _add_registration_locked(data)
def _add_registration_locked(data):
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
next_id = generate_next_id() next_id = generate_next_id()
# Промокод пока выключен флагом config.PROMO_ENABLED # Промокод пока выключен флагом config.PROMO_ENABLED
promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None
# 1. Запись в SQLite базу данных — основной приёмник, выполняется первым # 1. Запись в SQLite базу данных — основной приёмник, выполняется первым.
# Ошибка записи (например, коллизия ID) НЕ должна срывать остальные каналы
# доставки (Excel/VK/почта) — иначе заявка теряется целиком.
if db: 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') # Единый журнал заявок для статистики (source='web')
try: try:
db.add_lead( db.add_lead(
@@ -433,7 +458,8 @@ class FormHandler(SimpleHTTPRequestHandler):
def run_server(port=8000): def run_server(port=8000):
init_excel() # Pre-create Excel on startup init_excel() # Pre-create Excel on startup
server_address = ('', port) server_address = ('', port)
httpd = HTTPServer(server_address, FormHandler) # Многопоточный сервер: один медленный/зависший запрос не блокирует остальные.
httpd = ThreadingHTTPServer(server_address, FormHandler)
print(f"\n=======================================================") print(f"\n=======================================================")
print(f"Сервер ИКП запущен на http://localhost:{port}/") print(f"Сервер ИКП запущен на http://localhost:{port}/")
print(f"Вы можете открыть форму по адресу: http://localhost:{port}/index.html") print(f"Вы можете открыть форму по адресу: http://localhost:{port}/index.html")
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+27 -45
View File
@@ -14,7 +14,7 @@ def get_main_keyboard(is_admin: bool = False) -> str:
keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Заполнить заявку 📝", color=VkKeyboardColor.PRIMARY)
keyboard.add_line() keyboard.add_line()
# Карьера в ИКП (набор игроков и сотрудников) # Карьера в ИКП (набор игроков и сотрудников)
keyboard.add_button("Карьера в ИКП 💼", color=VkKeyboardColor.POSITIVE) keyboard.add_button("Карьера в ИКП 💼", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
# Кнопки FAQ и оператора # Кнопки FAQ и оператора
keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Частые вопросы ❓", color=VkKeyboardColor.SECONDARY)
@@ -22,7 +22,7 @@ def get_main_keyboard(is_admin: bool = False) -> str:
# Кнопка админ-панели — только для администраторов (отдельной строкой) # Кнопка админ-панели — только для администраторов (отдельной строкой)
if is_admin: if is_admin:
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.POSITIVE)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -39,35 +39,35 @@ def get_admin_keyboard(is_super: bool = False) -> str:
keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Рассылка 📣", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
if is_super: if is_super:
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.NEGATIVE) keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_svc_admin_keyboard() -> str: def get_svc_admin_keyboard() -> str:
"""Клавиатура раздела управления услугами (направлениями).""" """Клавиатура раздела управления услугами (направлениями)."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button(" Добавить услугу", color=VkKeyboardColor.POSITIVE) keyboard.add_button(" Добавить услугу", color=VkKeyboardColor.PRIMARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("✏️ Изменить услугу", color=VkKeyboardColor.SECONDARY) keyboard.add_button("✏️ Изменить услугу", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("🖼 Сменить картинку", color=VkKeyboardColor.SECONDARY) keyboard.add_button("🖼 Сменить картинку", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("🗑 Удалить услугу", color=VkKeyboardColor.NEGATIVE) keyboard.add_button("🗑 Удалить услугу", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
def get_faq_admin_keyboard() -> str: def get_faq_admin_keyboard() -> str:
"""Клавиатура раздела управления быстрыми ответами.""" """Клавиатура раздела управления быстрыми ответами."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button(" Добавить ответ", color=VkKeyboardColor.POSITIVE) keyboard.add_button(" Добавить ответ", color=VkKeyboardColor.PRIMARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("✏️ Изменить ответ", color=VkKeyboardColor.SECONDARY) keyboard.add_button("✏️ Изменить ответ", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("🗑 Удалить ответ", color=VkKeyboardColor.NEGATIVE) keyboard.add_button("🗑 Удалить ответ", color=VkKeyboardColor.NEGATIVE)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -96,40 +96,14 @@ def get_cancel_admin_keyboard() -> str:
def get_vacancy_choice_keyboard() -> str: def get_vacancy_choice_keyboard() -> str:
"""Выбор направления в разделе 'Карьера в ИКП'.""" """Выбор направления в разделе 'Карьера в ИКП'."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Игроком в состав 🎮", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Игроком в состав 🎮", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Сотрудником 💼", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Сотрудником 💼", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() 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: def get_vacancy_cancel_keyboard() -> str:
"""Клавиатура отмены отклика на свободном текстовом шаге.""" """Клавиатура отмены отклика на свободном текстовом шаге."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
@@ -155,7 +129,7 @@ def get_faq_keyboard() -> str:
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Наши соцсети 🔗", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Наши соцсети 🔗", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -174,11 +148,13 @@ def get_socials_keyboard() -> str:
keyboard.add_openlink_button("VK Live 📺", "https://live.vkvideo.ru/ikp") keyboard.add_openlink_button("VK Live 📺", "https://live.vkvideo.ru/ikp")
keyboard.add_line() keyboard.add_line()
keyboard.add_openlink_button("Telegram ✈️", "https://t.me/ikp_team") keyboard.add_openlink_button("Telegram ✈️", "https://t.me/ikp_team")
keyboard.add_openlink_button("Discord 🎧", "https://discord.gg/z92mAw7KEe") keyboard.add_openlink_button("Lolka 🎮", "https://vk.com/away.php?to=https%3A%2F%2Flolka.gg%2FlNfM3F9DD&utf=1")
keyboard.add_line() keyboard.add_line()
keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro") keyboard.add_openlink_button("ВКонтакте 🌐", "https://vk.com/ikpro")
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_openlink_button("Наш сайт 🔗", "https://infocyber.pro/")
keyboard.add_line()
keyboard.add_button("Назад в меню ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -210,15 +186,21 @@ def get_binary_keyboard(show_skip: bool = False) -> str:
def get_returning_keyboard() -> str: def get_returning_keyboard() -> str:
"""Клавиатура для возвращающегося клиента, чьи данные уже известны.""" """Нижняя клавиатура возвращающегося клиента: подтвердить или отменить."""
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Да, всё верно ✅", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Да, всё верно ✅", color=VkKeyboardColor.PRIMARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard()
def get_returning_changes_keyboard() -> str:
"""Инлайн-кнопки под сообщением: сменить имя / телефон / заполнить заново."""
keyboard = VkKeyboard(inline=True)
keyboard.add_button("Сменить имя 👤", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Сменить имя 👤", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Сменить телефон 📞", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Заполнить заново 🔄", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -228,8 +210,8 @@ def get_calldate_keyboard() -> str:
wd = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] wd = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
today = datetime.now() today = datetime.now()
keyboard = VkKeyboard(one_time=False) keyboard = VkKeyboard(one_time=False)
keyboard.add_button("Сегодня", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Сегодня", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Завтра", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Завтра", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
for i in (2, 3, 4): for i in (2, 3, 4):
d = today + timedelta(days=i) d = today + timedelta(days=i)
@@ -250,7 +232,7 @@ def get_calltime_keyboard() -> str:
keyboard.add_button("18:00", color=VkKeyboardColor.SECONDARY) keyboard.add_button("18:00", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("20:00", color=VkKeyboardColor.SECONDARY) keyboard.add_button("20:00", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Любое время", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Любое время", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE) keyboard.add_button("Отменить заполнение ❌", color=VkKeyboardColor.NEGATIVE)
return keyboard.get_keyboard() return keyboard.get_keyboard()
@@ -308,7 +290,7 @@ def get_edit_menu_keyboard() -> str:
keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Изменить Услугу 🎮", color=VkKeyboardColor.SECONDARY)
keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY) keyboard.add_button("Изменить Время 🕒", color=VkKeyboardColor.SECONDARY)
keyboard.add_line() keyboard.add_line()
keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.PRIMARY) keyboard.add_button("Назад к проверке ↩️", color=VkKeyboardColor.SECONDARY)
return keyboard.get_keyboard() return keyboard.get_keyboard()
+35
View File
@@ -3,6 +3,41 @@ chcp 65001 >nul
cd /d "%~dp0" cd /d "%~dp0"
title Бот ИКП (VK) title Бот ИКП (VK)
REM === Подготовка окружения при первом запуске ===
REM 1) Проверяем, что Python доступен
where python >nul 2>&1
if errorlevel 1 (
echo [ОШИБКА] Python не найден в PATH. Установите Python 3 и запустите снова.
pause
exit /b
)
REM 2) Нет .env — создаём из шаблона и открываем на заполнение
if not exist ".env" (
if exist ".env.example" (
copy /Y ".env.example" ".env" >nul
echo.
echo Создан файл .env из шаблона. Впишите VK_TOKEN, VK_GROUP_ID, ADMIN_IDS и др.,
echo сохраните файл и запустите бота снова.
notepad ".env"
pause
exit /b
) else (
echo [ОШИБКА] Нет ни .env, ни .env.example. Настройте конфигурацию вручную.
pause
exit /b
)
)
REM 3) Устанавливаем зависимости (если уже стоят — отработает быстро)
echo Проверка зависимостей...
python -m pip install -r requirements.txt >nul 2>&1
REM 4) Защита от нескольких экземпляров: глушим старые процессы бота перед стартом
REM (иначе два longpoll-инстанса отвечают на одни нажатия). В боте ещё есть 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 :loop
echo. echo.
echo [%date% %time%] Запуск бота ИКП... echo [%date% %time%] Запуск бота ИКП...
+34
View File
@@ -0,0 +1,34 @@
import os
import sys
import pytest
# Корень проекта в путь импорта
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from database import DatabaseManager
@pytest.fixture
def tmp_db(tmp_path):
"""Чистая БД во временном файле."""
return DatabaseManager(str(tmp_path / "test.db"))
@pytest.fixture
def app(tmp_path, monkeypatch):
"""Экземпляр бота в режиме эмуляции с временной БД и перехватом отправки."""
import bot
bot.config.VK_TOKEN = "your_vk_community_token_here" # эмулятор, без сети
test_db = bot.DatabaseManager(str(tmp_path / "bot.db"))
monkeypatch.setattr(bot, "db", test_db)
application = bot.VKBotApplication()
sent = []
application.send_message = (
lambda peer_id, message, keyboard=None, attachment=None, template=None:
sent.append({"to": peer_id, "msg": message, "kb": keyboard})
)
application._sent = sent
return application
+58
View File
@@ -0,0 +1,58 @@
def test_services_crud(tmp_db):
assert len(tmp_db.get_services()) >= 1 # сид
nid = tmp_db.add_service("X-услуга", "100 ₽", "описание")
assert nid in tmp_db.get_services()
tmp_db.update_service(nid, "price", "200 ₽")
assert tmp_db.get_services()[nid]["price"] == "200 ₽"
tmp_db.delete_service(nid)
assert nid not in tmp_db.get_services()
def test_custom_faq(tmp_db):
fid = tmp_db.add_custom_faq("привет", "Здравствуйте!")
assert tmp_db.match_custom_faq("ну привет всем") == "Здравствуйте!"
assert tmp_db.delete_custom_faq(fid)
assert tmp_db.match_custom_faq("привет") is None
def test_vacancy_options(tmp_db):
assert len(tmp_db.get_vacancy_options("player")) == 3
vid = tmp_db.add_vacancy_option("worker", "Аналитик")
assert any(l == "Аналитик" for _, l in tmp_db.get_vacancy_options("worker"))
tmp_db.delete_vacancy_option(vid)
assert not any(l == "Аналитик" for _, l in tmp_db.get_vacancy_options("worker"))
def test_applications_and_stats(tmp_db):
tmp_db.add_application(1, "player", {
"discipline": "CS2", "rank": "8", "role": "awp", "experience": "x", "contact": "@a"})
st = tmp_db.get_application_stats()
assert st["players"] == 1 and st["total"] == 1
rows = tmp_db.get_recent_applications()
assert tmp_db.set_application_status(rows[0]["id"], "принят")
def test_admins(tmp_db):
assert tmp_db.add_admin(555)
assert 555 in tmp_db.get_admins()
assert not tmp_db.add_admin(555) # дубль
assert tmp_db.remove_admin(555)
assert 555 not in tmp_db.get_admins()
def test_leads_and_vac_data(tmp_db):
tmp_db.add_lead(user_id=1, source="bot", name="A", phone="+79990001122",
service="CS2", call_time="завтра", promo=None, marketing=True)
s = tmp_db.get_lead_stats()
assert s["total"] == 1 and s["from_bot"] == 1 and s["marketing_yes"] == 1
tmp_db.set_vac_field(2, "discipline", "CS2")
assert tmp_db.get_vac_data(2)["discipline"] == "CS2"
tmp_db.clear_vac_data(2)
assert tmp_db.get_vac_data(2) == {}
def test_marketing_subscribers(tmp_db):
tmp_db.update_user_field(10, "consent_2", "Согласен")
tmp_db.update_user_field(11, "consent_2", "Не согласен")
subs = tmp_db.get_marketing_subscribers()
assert 10 in subs and 11 not in subs
+107
View File
@@ -0,0 +1,107 @@
import bot
def _state(uid):
return bot.db.get_user_data(uid)["state"]
def test_full_lead_flow(app):
u = 70001
assert (app.handle_message(u, "Заполнить заявку 📝"), _state(u))[1] == "FORM_NAME"
app.handle_message(u, "Иван"); assert _state(u) == "FORM_PHONE"
app.handle_message(u, "89990001122"); assert _state(u) == "FORM_QUESTION"
app.handle_message(u, "кс"); assert _state(u) == "FORM_CALLDATE"
app.handle_message(u, "Завтра"); assert _state(u) == "FORM_CALLTIME"
app.handle_message(u, "14:00"); assert _state(u) == "FORM_REVIEW"
app.handle_message(u, "Да, всё верно! ✅"); assert _state(u) == "FORM_CONSENT_1"
app.handle_message(u, "Согласен(а) и подтверждаю ✅"); assert _state(u) == "FORM_CONSENT_2"
app.handle_message(u, "Без рассылки ➡️"); assert _state(u) == "FREE_CHAT"
stats = bot.db.get_lead_stats()
assert stats["total"] == 1 and stats["from_bot"] == 1
def test_career_player_flow(app):
u = 70003
app.handle_message(u, "Карьера в ИКП 💼"); assert _state(u) == "VAC_CHOOSE"
app.handle_message(u, "Игроком в состав 🎮"); assert _state(u) == "VAC_P_DISCIPLINE"
app.handle_message(u, "Counter-Strike 2 🔫"); assert _state(u) == "VAC_P_RANK"
def test_flood_does_not_crash(app):
u = 70002
for _ in range(25):
app.handle_message(u, "спам")
# бот не падает; пользователь остаётся в FREE_CHAT (флуд/бан только игнорят)
assert _state(u) == "FREE_CHAT"
def test_admin_panel_visible_to_super(app):
admin = bot.config.SUPER_ADMIN_IDS[0]
app._sent.clear()
app.handle_message(admin, "Админка ⚙️")
assert any("Статистика" in (s["kb"] or "") for s in app._sent)
def test_non_admin_cannot_open_panel(app):
app._sent.clear()
app.handle_message(424242, "Админка ⚙️")
assert not any("Админ-панель" in s["msg"] for s in app._sent)
def _admin():
return bot.config.SUPER_ADMIN_IDS[0]
def test_admin_services_crud(app):
a = _admin()
app.handle_message(a, "Управление услугами ⚙️")
app.handle_message(a, " Добавить услугу")
app.handle_message(a, "Тест-у | 1 ₽ | описание")
nid = [k for k, v in bot.db.get_services().items() if v["name"] == "Тест-у"]
assert nid
n = nid[0]
app.handle_message(a, "✏️ Изменить услугу"); app.handle_message(a, f"{n}. Тест-у")
app.handle_message(a, "цена | 999 ₽")
assert bot.db.get_services()[n]["price"] == "999 ₽"
app.handle_message(a, "🗑 Удалить услугу"); app.handle_message(a, f"{n}. Тест-у")
assert n not in bot.db.get_services()
def test_admin_faq_crud(app):
a = _admin()
app.handle_message(a, "Быстрые ответы 💬")
app.handle_message(a, " Добавить ответ")
app.handle_message(a, "вопрос | ответ")
assert any(kw == "вопрос" for _, kw, _ in bot.db.get_custom_faq())
def test_admin_vacancy_crud(app):
a = _admin()
app.handle_message(a, "Вакансии 💼")
app.handle_message(a, " Вакансия игрок")
app.handle_message(a, "Valorant 🎯")
assert any(l == "Valorant 🎯" for _, l in bot.db.get_vacancy_options("player"))
def test_admin_add_remove_admin(app):
a = _admin()
app.handle_message(a, "Администраторы 👑")
app.handle_message(a, " Добавить админа")
app.handle_message(a, "888001")
assert 888001 in bot.db.get_admins()
assert app._is_admin(888001)
def test_broadcast_preview_and_cancel(app):
a = _admin()
app.handle_message(a, "рассылка Привет, акция!")
assert a in app._pending_broadcasts
app.handle_message(a, "Отмена ❌")
assert a not in app._pending_broadcasts
def test_stats_runs(app):
a = _admin()
app._sent.clear()
app.handle_message(a, "Статистика 📊")
assert any("Статистика" in s["msg"] for s in app._sent)
+34
View File
@@ -0,0 +1,34 @@
from validators import normalize_phone, format_phone_display, is_valid_name
def test_normalize_ru_8():
assert normalize_phone("89991234567") == "+79991234567"
def test_normalize_ru_formatted():
assert normalize_phone("+7 (999) 123-45-67") == "+79991234567"
def test_normalize_international():
assert normalize_phone("+380501234567") == "+380501234567"
def test_normalize_invalid():
assert normalize_phone("12345") is None
assert normalize_phone("abcdef") is None
def test_format_phone_display():
assert format_phone_display("+79991234567") == "+7 (999) 123-45-67"
assert format_phone_display("+380501234567") == "+380501234567" # межд. как есть
def test_name_valid():
assert is_valid_name("Иван-Петров")
assert is_valid_name("John")
def test_name_invalid():
assert not is_valid_name("Иван123")
assert not is_valid_name("!!!")
assert not is_valid_name("a")