Доработка VK-бота ИКП: контент, новые функции, защита и визуал
Контент и брендинг: - Реальные данные ИКP (infocyber.pro): услуги CS2/Dota 2/WoT, цены, FAQ, контакты, площадки - Приветствие и тексты приведены к бренду ИКП Новые функции: - Раздельные согласия (152-ФЗ): маркетинговая рассылка — отдельный необязательный шаг - Шаг «удобное время звонка» в форме заявки - Раздел «Карьера в ИКП»: набор игроков и сотрудников (таблица applications) - Админ-команды: статистика (стата) и рассылка по подписчикам с троттлингом и отпиской - Журнал заявок leads, «бот помнит клиента», международные телефоны, валидация имени - Email-уведомления о заявках и откликах (mailer.py); HR-адрес через HR_MAIL_TO Защита: - Анти-флуд по пользователю и кулдаун уведомлений оператору (анти-DDoS) - Веб-форма: серверная валидация, honeypot, рейт-лимит, CORS по ALLOWED_ORIGIN, SQLite-first Визуал: - Убрана Markdown-разметка (VK её не рендерит) - Логотип в приветствии, карусели услуг и «Карьеры» (фото 13:8), предзагрузка медиа - Кнопки-ссылки на соцсети - Веб-форма: анимации, конфетти на успехе Промокоды добавлены, но пока ВЫКЛЮЧЕНЫ флагом PROMO_ENABLED (по умолчанию false). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+179
-23
@@ -1,5 +1,6 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
class DatabaseManager:
|
||||
@@ -29,7 +30,7 @@ class DatabaseManager:
|
||||
""")
|
||||
|
||||
# Migration checks for existing databases
|
||||
for col in ["consent_1", "consent_2", "consent_3"]:
|
||||
for col in ["consent_1", "consent_2", "consent_3", "call_time", "vac_data"]:
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -63,24 +64,19 @@ class DatabaseManager:
|
||||
if cursor.fetchone()[0] == 0:
|
||||
default_services = {
|
||||
"1": {
|
||||
"name": "Тренировка по Dota 2 🎮",
|
||||
"price": "1 500 ₽/час",
|
||||
"description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль."
|
||||
"name": "Counter-Strike 2 🔫",
|
||||
"price": "от 870 ₽ / занятие",
|
||||
"description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
|
||||
},
|
||||
"2": {
|
||||
"name": "Тренировка по CS2 🔫",
|
||||
"price": "1 500 ₽/час",
|
||||
"description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие."
|
||||
"name": "Dota 2 🎮",
|
||||
"price": "от 870 ₽ / занятие",
|
||||
"description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
|
||||
},
|
||||
"3": {
|
||||
"name": "Пакет услуг 'Стандарт' 📦",
|
||||
"price": "7 500 ₽",
|
||||
"description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития."
|
||||
},
|
||||
"4": {
|
||||
"name": "Пакет услуг 'Pro' 🌟",
|
||||
"price": "14 000 ₽",
|
||||
"description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера."
|
||||
"name": "World of Tanks 🛡️",
|
||||
"price": "от 870 ₽ / занятие",
|
||||
"description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
|
||||
}
|
||||
}
|
||||
for key, svc in default_services.items():
|
||||
@@ -89,6 +85,38 @@ class DatabaseManager:
|
||||
(key, svc["name"], svc["price"], svc["description"])
|
||||
)
|
||||
|
||||
# Table for completed leads (заявки) — единый источник для статистики
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
source TEXT, -- 'bot' | 'web'
|
||||
name TEXT,
|
||||
phone TEXT,
|
||||
service TEXT,
|
||||
call_time TEXT,
|
||||
promo TEXT,
|
||||
marketing INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Table for HR/recruitment applications (отклики на вакансии и набор в состав)
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
track TEXT, -- 'player' | 'worker'
|
||||
discipline TEXT,
|
||||
position TEXT,
|
||||
rank TEXT,
|
||||
role TEXT,
|
||||
experience TEXT,
|
||||
contact TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
# Table for web registrations
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS web_registrations (
|
||||
@@ -112,7 +140,7 @@ class DatabaseManager:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT state, name, phone, question, consent_1, consent_2, consent_3 FROM user_states WHERE user_id = ?",
|
||||
"SELECT state, name, phone, question, consent_1, consent_2, consent_3, call_time FROM user_states WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
@@ -126,13 +154,14 @@ class DatabaseManager:
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"state": "FREE_CHAT",
|
||||
"name": None,
|
||||
"phone": None,
|
||||
"state": "FREE_CHAT",
|
||||
"name": None,
|
||||
"phone": None,
|
||||
"question": None,
|
||||
"consent_1": None,
|
||||
"consent_2": None,
|
||||
"consent_3": None
|
||||
"consent_3": None,
|
||||
"call_time": None
|
||||
}
|
||||
|
||||
def update_user_state(self, user_id: int, state: str):
|
||||
@@ -148,7 +177,7 @@ class DatabaseManager:
|
||||
|
||||
def update_user_field(self, user_id: int, field: str, value: Optional[str]):
|
||||
"""Update a specific field in the form."""
|
||||
allowed_fields = ["name", "phone", "question", "state", "consent_1", "consent_2", "consent_3"]
|
||||
allowed_fields = ["name", "phone", "question", "state", "consent_1", "consent_2", "consent_3", "call_time"]
|
||||
if field not in allowed_fields:
|
||||
raise ValueError(f"Invalid field name: {field}")
|
||||
|
||||
@@ -162,12 +191,25 @@ class DatabaseManager:
|
||||
conn.commit()
|
||||
|
||||
def reset_form(self, user_id: int):
|
||||
"""Reset form fields and return to free chat state."""
|
||||
"""Reset form progress and return to free chat, but KEEP name/phone for returning users."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE user_states SET state = 'FREE_CHAT', "
|
||||
"name = NULL, phone = NULL, question = NULL, "
|
||||
"question = NULL, call_time = NULL, vac_data = NULL, "
|
||||
"consent_1 = NULL, consent_2 = NULL, consent_3 = NULL "
|
||||
"WHERE user_id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def reset_form_full(self, user_id: int):
|
||||
"""Полностью очистить данные пользователя (включая имя/телефон) — для 'Заполнить заново'."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE user_states SET state = 'FREE_CHAT', "
|
||||
"name = NULL, phone = NULL, question = NULL, call_time = NULL, "
|
||||
"consent_1 = NULL, consent_2 = NULL, consent_3 = NULL "
|
||||
"WHERE user_id = ?",
|
||||
(user_id,)
|
||||
@@ -254,6 +296,120 @@ class DatabaseManager:
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def count_web_registrations(self) -> int:
|
||||
"""Количество заявок с сайта (для генерации следующего ID независимо от Excel)."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) FROM web_registrations")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def add_lead(self, user_id: int, source: str, name: str, phone: str,
|
||||
service: Optional[str] = None, call_time: Optional[str] = None,
|
||||
promo: Optional[str] = None, marketing: bool = False):
|
||||
"""Записать завершённую заявку в единый журнал leads (для статистики и истории)."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO leads (user_id, source, name, phone, service, call_time, promo, marketing) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, source, name, phone, service, call_time, promo, 1 if marketing else 0)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_marketing_subscribers(self) -> list:
|
||||
"""Список user_id, давших согласие на маркетинговую рассылку (consent_2 = 'Согласен')."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT user_id FROM user_states WHERE consent_2 = 'Согласен'")
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
def get_lead_stats(self) -> Dict[str, Any]:
|
||||
"""Сводная статистика по заявкам из журнала leads."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
def scalar(query, params=()):
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
stats = {
|
||||
"total": scalar("SELECT COUNT(*) FROM leads"),
|
||||
"today": scalar("SELECT COUNT(*) FROM leads WHERE date(created_at) = date('now', 'localtime')"),
|
||||
"last7": scalar("SELECT COUNT(*) FROM leads WHERE created_at >= datetime('now', '-7 days')"),
|
||||
"from_bot": scalar("SELECT COUNT(*) FROM leads WHERE source = 'bot'"),
|
||||
"from_web": scalar("SELECT COUNT(*) FROM leads WHERE source = 'web'"),
|
||||
"marketing_yes": scalar("SELECT COUNT(*) FROM leads WHERE marketing = 1"),
|
||||
}
|
||||
|
||||
cursor.execute(
|
||||
"SELECT service, COUNT(*) FROM leads WHERE service IS NOT NULL AND service != '' "
|
||||
"GROUP BY service ORDER BY COUNT(*) DESC"
|
||||
)
|
||||
stats["by_service"] = cursor.fetchall()
|
||||
return stats
|
||||
|
||||
def get_dialogue_stats(self) -> Dict[str, Any]:
|
||||
"""Статистика диалогов: уникальные пользователи и объём сообщений."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(DISTINCT user_id) FROM dialogue_logs")
|
||||
unique_users = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM dialogue_logs WHERE sender = 'user'")
|
||||
user_messages = cursor.fetchone()[0]
|
||||
return {"unique_users": unique_users, "user_messages": user_messages}
|
||||
|
||||
def get_vac_data(self, user_id: int) -> Dict[str, Any]:
|
||||
"""Черновик отклика на вакансию (хранится как JSON в user_states.vac_data)."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT vac_data FROM user_states WHERE user_id = ?", (user_id,))
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def set_vac_field(self, user_id: int, key: str, value: Optional[str]):
|
||||
"""Обновить одно поле в черновике отклика."""
|
||||
data = self.get_vac_data(user_id)
|
||||
data[key] = value
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO user_states (user_id, vac_data) VALUES (?, ?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET vac_data = excluded.vac_data, last_updated = CURRENT_TIMESTAMP",
|
||||
(user_id, json.dumps(data, ensure_ascii=False))
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def clear_vac_data(self, user_id: int):
|
||||
"""Очистить черновик отклика."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO user_states (user_id, vac_data) VALUES (?, NULL) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET vac_data = NULL, last_updated = CURRENT_TIMESTAMP",
|
||||
(user_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def add_application(self, user_id: int, track: str, data: dict):
|
||||
"""Сохранить завершённый отклик (игрок/сотрудник) в журнал applications."""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO applications (user_id, track, discipline, position, rank, role, experience, contact) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
user_id, track,
|
||||
data.get("discipline"), data.get("position"), data.get("rank"),
|
||||
data.get("role"), data.get("experience"), data.get("contact")
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def add_web_registration(self, reg_id: str, data: dict):
|
||||
"""Сохранить заявку с веб-формы в базу данных SQLite."""
|
||||
with self._get_connection() as conn:
|
||||
|
||||
Reference in New Issue
Block a user