Compare commits
13 Commits
ba0f43169c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2860aec9 | |||
| 0aaf285d54 | |||
| ffdd7fc1fc | |||
| 26b6df77ef | |||
| f14b8769e5 | |||
| 0ec849d80f | |||
| fc3361c7ea | |||
| 60ef76024e | |||
| a7ae2cec21 | |||
| 33906e7e7d | |||
| 667ca4d4f0 | |||
| 7ebcb851e7 | |||
| 46a7d2571f |
@@ -17,5 +17,8 @@ __pycache__/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Single-instance lock-файл бота (рантайм)
|
||||||
|
bot.lock
|
||||||
|
|
||||||
# Резервные копии исходных картинок (до кадрирования)
|
# Резервные копии исходных картинок (до кадрирования)
|
||||||
help_img/_orig/
|
help_img/_orig/
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+30
-4
@@ -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:
|
||||||
|
try:
|
||||||
db.add_web_registration(next_id, data)
|
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
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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%] Запуск бота ИКП...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user