Доработка 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:
Kenik
2026-06-21 21:54:31 +03:00
parent f38324645e
commit 79cc1ba38d
13 changed files with 1860 additions and 269 deletions
+24
View File
@@ -10,3 +10,27 @@ OPERATOR_PEER_ID=12345678
# Список ID администраторов (через запятую) для управления услугами через чат # Список ID администраторов (через запятую) для управления услугами через чат
ADMIN_IDS=12345678 ADMIN_IDS=12345678
# Разрешённый источник (Origin) для CORS веб-формы (домен сайта). Для локальной отладки можно '*'
ALLOWED_ORIGIN=https://infocyber.pro
# Промокоды на скидку. Пока выключены. Поставь true, когда захочешь их выдавать клиентам.
PROMO_ENABLED=false
# ===== Email (SMTP) для уведомлений о заявках =====
# Адрес SMTP-сервера почты (например, smtp.yandex.ru / smtp.gmail.com / smtp.mail.ru)
SMTP_HOST=
# Порт: 465 для SSL, 587 для STARTTLS
SMTP_PORT=465
# Логин (полный email) и пароль (для Яндекс/Gmail — пароль приложения, не основной!)
SMTP_USER=
SMTP_PASSWORD=
# От кого отправлять (по умолчанию = SMTP_USER)
MAIL_FROM=
# Куда отправлять заявки (можно несколько через запятую)
MAIL_TO=group-zakaz@infocyber.pro
# Куда отправлять отклики на вакансии/набор в состав. Пусто = на тот же адрес, что MAIL_TO.
# Можно вынести на отдельный HR-адрес, напр. hr@infocyber.pro
HR_MAIL_TO=
# true — SSL (порт 465), false — STARTTLS (порт 587)
SMTP_USE_SSL=true
+3
View File
@@ -16,3 +16,6 @@ __pycache__/
# Logs # Logs
*.log *.log
# Резервные копии исходных картинок (до кадрирования)
help_img/_orig/
+1104 -155
View File
File diff suppressed because it is too large Load Diff
+64 -19
View File
@@ -40,35 +40,80 @@ if admin_ids_raw:
else: else:
ADMIN_IDS = [OPERATOR_PEER_ID] ADMIN_IDS = [OPERATOR_PEER_ID]
# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки.
ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro")
# Промокоды на скидку. Пока ВЫКЛЮЧЕНЫ — включить можно, задав PROMO_ENABLED=true в .env.
# Когда выключено: бот и форма не показывают промокод, в уведомлениях/письмах его нет.
PROMO_ENABLED = os.getenv("PROMO_ENABLED", "false").strip().lower() in ("1", "true", "yes")
# Email (SMTP) configuration для уведомлений о заявках
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER
# Куда отправлять уведомления о новых заявках (можно несколько через запятую)
MAIL_TO = os.getenv("MAIL_TO", "group-zakaz@infocyber.pro")
# Куда отправлять отклики на вакансии / набор в состав.
# TODO: пока совпадает с MAIL_TO; возможно вынести на отдельный HR-адрес (напр. hr@infocyber.pro) — задать HR_MAIL_TO в .env.
HR_MAIL_TO = os.getenv("HR_MAIL_TO", "") or MAIL_TO
# True — порт 465 (SSL), False — STARTTLS (обычно порт 587)
SMTP_USE_SSL = os.getenv("SMTP_USE_SSL", "true").strip().lower() in ("1", "true", "yes")
# Standard replies and patterns # Standard replies and patterns
FAQ_ANSWERS = { FAQ_ANSWERS = {
"цены": "💰 Наши тарифы на киберспортивные тренировки:\n• Тренировка по Dota 2 🎮 — 1 500 ₽/час\n• Тренировка по CS2 🔫 — 1 500 ₽/час\n• Пакет 'Стандарт' 📦 (5 тренировок) — 7 500 ₽\n• Пакет 'Pro' 🌟 (10 тренировок + постоянная поддержка тренера) — 14 000 ₽\n\n🎁 ПОДАРОК: Всем новым ученикам дарим скидку 10% на первую индивидуальную тренировку! Заполните заявку через меню прямо сейчас!", "цены": (
"контакты": "📞 Связаться с нами:\n• Телефон: +7 (999) 123-45-67\n• Email: info@cyberpro.com\n• Адрес: г. Москва, Киберспортивная Арена 'Cyber Pro'\n• Режим работы: Ежедневно с 10:00 до 22:00", "💰 Тарифы ИКП на тренировки (направления: CS2, Dota 2, World of Tanks):\n\n"
"услуги": "⚙️ Мы предлагаем профессиональное обучение по киберспортивным дисциплинам:\n1. Индивидуальные тренировки по Dota 2 с разбором реплеев и позиционки\n2. Персональные занятия по CS2 (aim, раскидки гранат, командная тактика)\n3. Выгодные абонементы на 5 и 10 занятий с постоянным сопровождением тренера", "• Разовая тренировка — 870 ₽\n"
"о компании": "🏢 Мы — школа киберспорта 'Cyber Pro'. Помогаем игрокам любого уровня повышать свой MMR/Faceit Elo, развивать игровое мышление и побеждать на турнирах с 2022 года!\n\nНаши тренеры — действующие и экс-профессиональные игроки с 7000+ MMR и опытом игры на PRO-сцене." "• Абонемент на 4 занятия (групповой) — 2 790 ₽\n"
"• Абонемент на 8 занятий (групповой) — 4 990 ₽\n"
"• Абонемент на 4 занятия (индивидуальный) — 3 290 ₽\n"
"• Абонемент на 8 занятий (индивидуальный) — 5 990 ₽\n\n"
"Оставьте заявку через меню — менеджер подберёт оптимальный формат и направление!"
),
"контакты": (
"📞 Связаться с ИКП:\n"
"• Email (поддержка): support@infocyber.pro\n"
"• Заявки: group-zakaz@infocyber.pro\n"
"• Telegram: @ikp_team\n"
"• Сайт: https://infocyber.pro/"
),
"услуги": (
"⚙️ ИКП — тренировки по киберспортивным дисциплинам:\n\n"
"1. Counter-Strike 2 — aim, раскидки гранат, командная тактика\n"
"2. Dota 2 — разбор реплеев, позиционка, фарм и тимплей\n"
"3. World of Tanks — позиционирование, сведение, тактика боёв\n\n"
"Форматы: разовые занятия, групповые и индивидуальные абонементы (4 и 8 занятий)."
),
"о компании": (
"🏢 ИКП — Игра. Киберспорт. Профессионалы.\n\n"
"Мощная экосистема для взрывного роста игроков: лучшие тренеры и глубокая data-аналитика. "
"Организация основана 1 сентября 2025 года.\n\n"
"Мы на площадках:\n"
"• ВКонтакте: https://vk.com/ikpro\n"
"• VK Live (трансляции): https://live.vkvideo.ru/ikp\n"
"• Telegram: https://t.me/ikp_team\n"
"• Discord: https://discord.gg/z92mAw7KEe"
)
} }
# Доступные услуги и тарифы для выбора в форме заявки # Доступные услуги (направления) для выбора в форме заявки
SERVICES = { SERVICES = {
"1": { "1": {
"name": "Тренировка по Dota 2 🎮", "name": "Counter-Strike 2 🔫",
"price": "1 500 ₽/час", "price": "от 870 ₽ / занятие",
"description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." "description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
}, },
"2": { "2": {
"name": "Тренировка по CS2 🔫", "name": "Dota 2 🎮",
"price": "1 500 ₽/час", "price": "от 870 ₽ / занятие",
"description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." "description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
}, },
"3": { "3": {
"name": "Пакет услуг 'Стандарт' 📦", "name": "World of Tanks 🛡️",
"price": "7 500 ₽", "price": "от 870 ₽ / занятие",
"description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." "description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
},
"4": {
"name": "Пакет услуг 'Pro' 🌟",
"price": "14 000 ₽",
"description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера."
} }
} }
+176 -20
View File
@@ -1,5 +1,6 @@
import sqlite3 import sqlite3
import os import os
import json
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
class DatabaseManager: class DatabaseManager:
@@ -29,7 +30,7 @@ class DatabaseManager:
""") """)
# Migration checks for existing databases # 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: try:
cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT") cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -63,24 +64,19 @@ class DatabaseManager:
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
default_services = { default_services = {
"1": { "1": {
"name": "Тренировка по Dota 2 🎮", "name": "Counter-Strike 2 🔫",
"price": "1 500 ₽/час", "price": "от 870 ₽ / занятие",
"description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." "description": "Тренировки по CS2: aim, раскидки гранат, командная тактика. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
}, },
"2": { "2": {
"name": "Тренировка по CS2 🔫", "name": "Dota 2 🎮",
"price": "1 500 ₽/час", "price": "от 870 ₽ / занятие",
"description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." "description": "Тренировки по Dota 2: разбор реплеев, позиционка, фарм и тимплей. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
}, },
"3": { "3": {
"name": "Пакет услуг 'Стандарт' 📦", "name": "World of Tanks 🛡️",
"price": "7 500 ₽", "price": "от 870 ₽ / занятие",
"description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." "description": "Тренировки по World of Tanks: позиционирование, сведение, тактика боёв. Разовое занятие 870 ₽, абонементы от 2 790 ₽."
},
"4": {
"name": "Пакет услуг 'Pro' 🌟",
"price": "14 000 ₽",
"description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера."
} }
} }
for key, svc in default_services.items(): for key, svc in default_services.items():
@@ -89,6 +85,38 @@ class DatabaseManager:
(key, svc["name"], svc["price"], svc["description"]) (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 # Table for web registrations
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS web_registrations ( CREATE TABLE IF NOT EXISTS web_registrations (
@@ -112,7 +140,7 @@ class DatabaseManager:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( 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,) (user_id,)
) )
row = cursor.fetchone() row = cursor.fetchone()
@@ -132,7 +160,8 @@ class DatabaseManager:
"question": None, "question": None,
"consent_1": None, "consent_1": None,
"consent_2": None, "consent_2": None,
"consent_3": None "consent_3": None,
"call_time": None
} }
def update_user_state(self, user_id: int, state: str): 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]): def update_user_field(self, user_id: int, field: str, value: Optional[str]):
"""Update a specific field in the form.""" """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: if field not in allowed_fields:
raise ValueError(f"Invalid field name: {field}") raise ValueError(f"Invalid field name: {field}")
@@ -162,12 +191,25 @@ class DatabaseManager:
conn.commit() conn.commit()
def reset_form(self, user_id: int): 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: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"UPDATE user_states SET state = 'FREE_CHAT', " "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 " "consent_1 = NULL, consent_2 = NULL, consent_3 = NULL "
"WHERE user_id = ?", "WHERE user_id = ?",
(user_id,) (user_id,)
@@ -254,6 +296,120 @@ class DatabaseManager:
) )
conn.commit() 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): def add_web_registration(self, reg_id: str, data: dict):
"""Сохранить заявку с веб-формы в базу данных SQLite.""" """Сохранить заявку с веб-формы в базу данных SQLite."""
with self._get_connection() as conn: with self._get_connection() as conn:
+11
View File
@@ -33,6 +33,12 @@
<p class="form-subtitle">Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП</p> <p class="form-subtitle">Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП</p>
<form id="ikpForm" novalidate autocomplete="on"> <form id="ikpForm" novalidate autocomplete="on">
<!-- Honeypot: скрытое поле-ловушка для ботов. Реальные пользователи его не видят и не заполняют. -->
<div class="hp-field" aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
<label for="website">Не заполняйте это поле</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<!-- Name Field --> <!-- Name Field -->
<div class="form-group"> <div class="form-group">
<label for="name" class="form-label">Имя <span class="required-star">*</span></label> <label for="name" class="form-label">Имя <span class="required-star">*</span></label>
@@ -143,6 +149,11 @@
Наш киберспортивный координатор свяжется с вами в ближайшее время, чтобы открыть доступ к тестовым Наш киберспортивный координатор свяжется с вами в ближайшее время, чтобы открыть доступ к тестовым
материалам и записать на бесплатный урок. материалам и записать на бесплатный урок.
</p> </p>
<div class="promo-box" id="promoBox">
<span class="promo-label">🎁 Ваш промокод на скидку 10%</span>
<span class="promo-code" id="promoCode"></span>
<span class="promo-hint">Назовите его менеджеру при записи</span>
</div>
<button id="resetBtn" class="reset-btn">Вернуться к форме</button> <button id="resetBtn" class="reset-btn">Вернуться к форме</button>
</div> </div>
</div> </div>
+41 -3
View File
@@ -173,6 +173,28 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
/* ====================================================
Confetti (салют при успешной отправке)
==================================================== */
function launchConfetti() {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const colors = ['#ff7a00', '#00d2ff', '#ffffff', '#ff9233', '#ffd166'];
const count = 90;
for (let i = 0; i < count; i++) {
const piece = document.createElement('div');
piece.className = 'confetti-piece';
piece.style.left = Math.random() * 100 + 'vw';
piece.style.background = colors[Math.floor(Math.random() * colors.length)];
piece.style.animationDelay = (Math.random() * 0.5) + 's';
piece.style.animationDuration = (2.2 + Math.random() * 1.6) + 's';
const size = 6 + Math.random() * 8;
piece.style.width = size + 'px';
piece.style.height = (size * 0.5) + 'px';
document.body.appendChild(piece);
setTimeout(() => piece.remove(), 4200);
}
}
/* ==================================================== /* ====================================================
Form Submission Form Submission
==================================================== */ ==================================================== */
@@ -194,6 +216,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (isNameValid && isPhoneValid && isEmailValid && isVKValid && isOfertaValid && isPolicyValid) { if (isNameValid && isPhoneValid && isEmailValid && isVKValid && isOfertaValid && isPolicyValid) {
// Gather form data // Gather form data
const websiteInput = document.getElementById('website'); // honeypot
const formData = { const formData = {
name: nameInput.value.trim(), name: nameInput.value.trim(),
phone: phoneInput.value, phone: phoneInput.value,
@@ -201,17 +224,19 @@ document.addEventListener('DOMContentLoaded', () => {
vk: vkInput.value.trim(), vk: vkInput.value.trim(),
agreeOferta: agreeOfertaInput.checked, agreeOferta: agreeOfertaInput.checked,
agreePolicy: agreePolicyInput.checked, agreePolicy: agreePolicyInput.checked,
agreeNewsletter: agreeNewsletterInput.checked agreeNewsletter: agreeNewsletterInput.checked,
website: websiteInput ? websiteInput.value : '' // должно быть пустым у людей
}; };
// Disable submit button during request // Disable submit button during request
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const originalBtnText = submitBtn.textContent; const originalBtnText = submitBtn.textContent;
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.classList.add('loading');
submitBtn.textContent = 'Отправка...'; submitBtn.textContent = 'Отправка...';
// Send to our backend server (supports running on localhost:8000) // Отправка на тот же сервер, что отдаёт форму (относительный путь = тот же origin, без CORS)
fetch('http://localhost:8000/submit', { fetch('/submit', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -232,6 +257,18 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}) })
.then(data => { .then(data => {
// Показываем персональный промокод, если сервер его вернул
if (data && data.promo) {
const promoCode = document.getElementById('promoCode');
const promoBox = document.getElementById('promoBox');
if (promoCode && promoBox) {
promoCode.textContent = data.promo;
promoBox.style.display = 'flex';
}
}
// Салют 🎉
launchConfetti();
// Success State: Animate form fading out and success window fading in // Success State: Animate form fading out and success window fading in
formContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; formContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
formContainer.style.opacity = '0'; formContainer.style.opacity = '0';
@@ -263,6 +300,7 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
.finally(() => { .finally(() => {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.classList.remove('loading');
submitBtn.textContent = originalBtnText; submitBtn.textContent = originalBtnText;
}); });
} else { } else {
+200 -55
View File
@@ -1,5 +1,11 @@
import os import os
import re
import json import json
import time
import random
import string
import threading
from collections import defaultdict
from http.server import HTTPServer, SimpleHTTPRequestHandler from http.server import HTTPServer, SimpleHTTPRequestHandler
from datetime import datetime from datetime import datetime
import sys import sys
@@ -36,6 +42,12 @@ except Exception as e:
db = None db = None
config = None config = None
try:
import mailer
except Exception as e:
print(f"Предупреждение: Не удалось загрузить модуль mailer: {e}")
mailer = None
try: try:
import vk_api import vk_api
from vk_api.utils import get_random_id from vk_api.utils import get_random_id
@@ -46,6 +58,60 @@ except ImportError:
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx") FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx")
# Разрешённый источник для CORS (берётся из config, '*' только для локальной отладки)
ALLOWED_ORIGIN = getattr(config, "ALLOWED_ORIGIN", "*") if config else "*"
# ---------------------------------------------------------------
# Защита формы: валидация, рейт-лимит и honeypot
# ---------------------------------------------------------------
NAME_RE = re.compile(r"^[A-Za-zА-Яа-яЁё][A-Za-zА-Яа-яЁё \-']{1,49}$")
EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
# Простой in-memory рейт-лимит по IP: не более N заявок за окно
RATE_LIMIT_MAX = 5
RATE_LIMIT_WINDOW = 600 # секунд (10 минут)
_rate_lock = threading.Lock()
_rate_hits = defaultdict(list)
def is_rate_limited(ip: str) -> bool:
"""True, если с этого IP было слишком много заявок за окно времени."""
now = time.time()
with _rate_lock:
hits = [t for t in _rate_hits[ip] if now - t < RATE_LIMIT_WINDOW]
if len(hits) >= RATE_LIMIT_MAX:
_rate_hits[ip] = hits
return True
hits.append(now)
_rate_hits[ip] = hits
return False
def validate_submission(data: dict):
"""Серверная валидация заявки. Возвращает (ok: bool, error: str|None)."""
name = (data.get("name") or "").strip()
phone_digits = re.sub(r"\D", "", data.get("phone") or "")
email = (data.get("email") or "").strip()
vk = (data.get("vk") or "").strip().lower()
if not NAME_RE.match(name):
return False, "Имя должно содержать только буквы (2–50 символов)."
# Российский (11 цифр) или международный (10–15 цифр) номер
if not (10 <= len(phone_digits) <= 15):
return False, "Некорректный номер телефона."
if not EMAIL_RE.match(email):
return False, "Некорректный e-mail."
if not (vk and ("vk.com" in vk or "vkontakte.ru" in vk)):
return False, "Ссылка должна вести на vk.com или vkontakte.ru."
if not data.get("agreeOferta") or not data.get("agreePolicy"):
return False, "Необходимо согласие с офертой и политикой обработки данных."
return True, None
def is_spam(data: dict) -> bool:
"""Honeypot: скрытое поле 'website' должно оставаться пустым у реальных пользователей."""
return bool((data.get("website") or "").strip())
def init_excel(): def init_excel():
"""Create the Excel file with styled headers if it doesn't exist.""" """Create the Excel file with styled headers if it doesn't exist."""
recreate = False recreate = False
@@ -124,10 +190,25 @@ def get_next_user_id(ws):
except (IndexError, ValueError): except (IndexError, ValueError):
return f"id_{max_row:03d}" return f"id_{max_row:03d}"
def add_registration(data): def generate_next_id():
"""Append a new form submission row into the Excel sheet.""" """Генерирует следующий ID заявки. Источник правды — SQLite; Excel используется как запасной."""
init_excel() if db:
try:
return f"id_{db.count_web_registrations() + 1:03d}"
except Exception as e:
print(f"Не удалось получить счётчик из SQLite ({e}), используем Excel как запасной источник.")
# Запасной вариант — по строкам Excel
try:
init_excel()
wb = load_workbook(FILE_NAME)
return get_next_user_id(wb.active)
except Exception:
return f"id_{int(time.time())}"
def append_to_excel(next_id, data, current_time):
"""Дописывает строку в Excel. Вынесено отдельно: ошибка здесь НЕ срывает приём заявки."""
init_excel()
try: try:
wb = load_workbook(FILE_NAME) wb = load_workbook(FILE_NAME)
ws = wb.active ws = wb.active
@@ -137,11 +218,6 @@ def add_registration(data):
wb = load_workbook(FILE_NAME) wb = load_workbook(FILE_NAME)
ws = wb.active ws = wb.active
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Auto-generate next user ID
next_id = get_next_user_id(ws)
row = [ row = [
next_id, next_id,
data.get("name", "").strip(), data.get("name", "").strip(),
@@ -156,36 +232,84 @@ def add_registration(data):
ws.append(row) ws.append(row)
# Align data cells
new_row_index = ws.max_row new_row_index = ws.max_row
data_align_left = Alignment(horizontal="left", vertical="center") data_align_left = Alignment(horizontal="left", vertical="center")
data_align_center = Alignment(horizontal="center", vertical="center") data_align_center = Alignment(horizontal="center", vertical="center")
for col_idx in range(1, len(row) + 1): for col_idx in range(1, len(row) + 1):
cell = ws.cell(row=new_row_index, column=col_idx) cell = ws.cell(row=new_row_index, column=col_idx)
# Left align Name (2), Email (4), VK Link (5). Center align others.
if col_idx in [2, 4, 5]: if col_idx in [2, 4, 5]:
cell.alignment = data_align_left cell.alignment = data_align_left
else: else:
cell.alignment = data_align_center cell.alignment = data_align_center
wb.save(FILE_NAME) wb.save(FILE_NAME)
print(f"[{current_time}] Добавлена новая запись в Excel: ID={row[0]}, Имя={row[1]}, Телефон={row[2]}") print(f"[{current_time}] Добавлена запись в Excel: ID={row[0]}, Имя={row[1]}, Телефон={row[2]}")
# 1. Запись в SQLite базу данных
def gen_promo():
"""Персональный промокод на скидку — выдаётся клиенту на экране 'Спасибо'."""
return "ИКП-" + "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
def add_registration(data):
"""Сохраняет заявку: SQLite (источник правды) → Excel (нестрого) → уведомления. Возвращает промокод."""
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
next_id = generate_next_id()
# Промокод пока выключен флагом config.PROMO_ENABLED
promo = gen_promo() if (config and getattr(config, "PROMO_ENABLED", False)) else None
# 1. Запись в SQLite базу данных — основной приёмник, выполняется первым
if db: if db:
db.add_web_registration(next_id, data)
# Единый журнал заявок для статистики (source='web')
try: try:
db.add_web_registration(next_id, data) db.add_lead(
print(f"[{current_time}] Заявка {next_id} успешно сохранена в SQLite (таблица web_registrations).") user_id=None, source="web",
except Exception as db_err: name=data.get("name", "").strip(), phone=data.get("phone", "").strip(),
print(f"[{current_time}] Ошибка записи заявки в SQLite: {db_err}") service=None, call_time=None, promo=promo,
marketing=bool(data.get("agreeNewsletter"))
)
except Exception as lead_err:
print(f"[{current_time}] Не удалось записать лид в журнал: {lead_err}")
print(f"[{current_time}] Заявка {next_id} сохранена в SQLite (web_registrations + leads).")
# 2. Отправка уведомления оператору в VK # 2. Дублирование в Excel — НЕ критично: если файл открыт/заблокирован, заявка всё равно принята
try:
append_to_excel(next_id, data, current_time)
except PermissionError:
print(f"[{current_time}] ⚠️ Excel заблокирован (открыт в программе). Заявка сохранена в SQLite, Excel пропущен.")
except Exception as xl_err:
print(f"[{current_time}] ⚠️ Ошибка записи в Excel (пропущено): {xl_err}")
# 3. Отправка уведомления оператору в VK
try: try:
send_vk_operator_notification(next_id, data) send_vk_operator_notification(next_id, data)
except Exception as vk_err: except Exception as vk_err:
print(f"[{current_time}] Ошибка при отправке уведомления оператору: {vk_err}") print(f"[{current_time}] Ошибка при отправке уведомления оператору: {vk_err}")
# 3. Отправка уведомления на корпоративную почту (group-zakaz@infocyber.pro)
if mailer:
try:
email_body = (
"Новая заявка с сайта.\n\n"
f"ID заявки: {next_id}\n"
f"Имя: {data.get('name', '').strip()}\n"
f"Телефон: {data.get('phone', '').strip()}\n"
f"E-mail: {data.get('email', '').strip()}\n"
f"Профиль VK: {data.get('vk', '').strip()}\n\n"
f"Согласия:\n"
f" - Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n"
f" - Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n"
f" - Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n\n"
+ (f"Промокод клиента: {promo}\n" if promo else "")
+ f"Дата: {current_time}\n"
)
mailer.send_email(f"Новая заявка с сайта — {data.get('name', '').strip()}", email_body)
except Exception as mail_err:
print(f"[{current_time}] Ошибка при отправке email: {mail_err}")
return promo
def send_vk_operator_notification(reg_id, data): def send_vk_operator_notification(reg_id, data):
"""Отправить уведомление о новой веб-заявке в VK-чат оператора.""" """Отправить уведомление о новой веб-заявке в VK-чат оператора."""
@@ -238,50 +362,71 @@ class FormHandler(SimpleHTTPRequestHandler):
rel_path = os.path.relpath(path, os.getcwd()) rel_path = os.path.relpath(path, os.getcwd())
return os.path.join(SCRIPT_DIR, rel_path) return os.path.join(SCRIPT_DIR, rel_path)
def _client_ip(self) -> str:
"""IP клиента с учётом обратного прокси (X-Forwarded-For)."""
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
return self.client_address[0] if self.client_address else "unknown"
def _send_json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self): def do_POST(self):
if self.path == '/submit': if self.path != '/submit':
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
try:
data = json.loads(post_data.decode('utf-8'))
add_registration(data)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*') # Prevent CORS errors
self.end_headers()
response = {"status": "success", "message": "Заявка успешно записана!"}
self.wfile.write(json.dumps(response).encode('utf-8'))
except PermissionError:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
response = {
"status": "error",
"message": "Таблица Excel заблокирована. Пожалуйста, закройте файл 'registration_for_training.xlsx' в Excel и отправьте форму заново."
}
self.wfile.write(json.dumps(response).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
response = {"status": "error", "message": str(e)}
self.wfile.write(json.dumps(response).encode('utf-8'))
else:
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
return
# Защита от флуда по IP
ip = self._client_ip()
if is_rate_limited(ip):
print(f"[RATE LIMIT] Слишком много заявок с IP {ip}.")
self._send_json(429, {"status": "error", "message": "Слишком много заявок. Попробуйте позже."})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
# Ограничение размера тела запроса (защита от больших payload'ов)
if content_length <= 0 or content_length > 10000:
self._send_json(400, {"status": "error", "message": "Некорректный размер запроса."})
return
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
# Honeypot: если скрытое поле заполнено — это бот. Делаем вид, что всё ок, но не сохраняем.
if is_spam(data):
print(f"[SPAM] Honeypot сработал для IP {ip}, заявка отброшена.")
self._send_json(200, {"status": "success", "message": "Заявка принята."})
return
# Серверная валидация
ok, error = validate_submission(data)
if not ok:
self._send_json(400, {"status": "error", "message": error})
return
promo = add_registration(data)
self._send_json(200, {"status": "success", "message": "Заявка успешно записана!", "promo": promo})
except json.JSONDecodeError:
self._send_json(400, {"status": "error", "message": "Некорректный формат данных."})
except Exception as e:
print(f"[ERROR] Ошибка обработки заявки: {e}")
self._send_json(500, {"status": "error", "message": "Внутренняя ошибка сервера. Попробуйте позже."})
def do_OPTIONS(self): def do_OPTIONS(self):
# Respond to CORS preflight requests # Respond to CORS preflight requests
self.send_response(204) self.send_response(204)
self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Access-Control-Allow-Origin', ALLOWED_ORIGIN)
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers() self.end_headers()
+159
View File
@@ -483,3 +483,162 @@ main {
font-size: 0.95rem; font-size: 0.95rem;
} }
} }
/* ----------------------------------------------------
11. VISUAL POLISH (анимации и «живой» неон)
---------------------------------------------------- */
/* Плавное появление карточки при загрузке */
@keyframes cardIn {
from { opacity: 0; transform: translateY(24px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.card {
animation: cardIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Бегущий градиент в верхней акцент-линии */
@keyframes accentFlow {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
.card::before {
background: linear-gradient(90deg,
var(--cyan-accent) 0%, var(--primary-orange) 50%, var(--cyan-accent) 100%);
background-size: 200% 100%;
animation: accentFlow 4s linear infinite;
}
/* Каскадное появление полей формы */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
#formContainer .form-group,
#formContainer .checkbox-group,
#formContainer .submit-btn {
animation: fadeUp 0.5s ease both;
}
#formContainer .form-group:nth-of-type(1) { animation-delay: 0.10s; }
#formContainer .form-group:nth-of-type(2) { animation-delay: 0.16s; }
#formContainer .form-group:nth-of-type(3) { animation-delay: 0.22s; }
#formContainer .form-group:nth-of-type(4) { animation-delay: 0.28s; }
#formContainer .checkbox-group { animation-delay: 0.34s; }
#formContainer .submit-btn { animation-delay: 0.42s; }
/* Мягкое пульсирующее свечение логотипа */
@keyframes logoGlow {
0%, 100% { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); }
50% { box-shadow: 0 8px 30px rgba(255, 122, 0, 0.35); }
}
.logo-img {
animation: logoGlow 3.5s ease-in-out infinite;
}
/* Световой блик, пробегающий по кнопке при наведении */
.submit-btn {
position: relative;
overflow: hidden;
}
.submit-btn::after {
content: '';
position: absolute;
top: 0;
left: -120%;
width: 60%;
height: 100%;
background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.45), transparent);
transform: skewX(-20deg);
transition: left 0.6s ease;
}
.submit-btn:hover::after {
left: 130%;
}
/* Спиннер в кнопке во время отправки (класс .loading добавляется из script.js) */
@keyframes spin { to { transform: rotate(360deg); } }
.submit-btn.loading {
pointer-events: none;
opacity: 0.85;
}
.submit-btn.loading::before {
content: '';
width: 18px;
height: 18px;
margin-right: 10px;
border: 2.5px solid rgba(10, 15, 31, 0.35);
border-top-color: var(--text-dark);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* Уважаем системную настройку «уменьшить движение» */
@media (prefers-reduced-motion: reduce) {
.card,
.card::before,
#formContainer .form-group,
#formContainer .checkbox-group,
#formContainer .submit-btn,
.logo-img {
animation: none !important;
}
.submit-btn::after { display: none; }
}
/* ----------------------------------------------------
12. ПРОМОКОД НА ЭКРАНЕ УСПЕХА
---------------------------------------------------- */
.promo-box {
display: none;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
background: rgba(255, 122, 0, 0.08);
border: 1.5px dashed var(--primary-orange);
border-radius: 14px;
padding: 16px 20px;
margin: 0 0 24px;
animation: scaleIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.promo-label {
font-size: 0.78rem;
color: var(--text-gray);
text-transform: uppercase;
letter-spacing: 1px;
text-align: center;
}
.promo-code {
font-family: var(--font-headings);
font-size: 1.55rem;
font-weight: 800;
color: var(--primary-orange);
letter-spacing: 1.5px;
}
.promo-hint {
font-size: 0.76rem;
color: var(--text-gray);
text-align: center;
}
/* ----------------------------------------------------
13. КОНФЕТТИ (салют при успешной отправке)
---------------------------------------------------- */
.confetti-piece {
position: fixed;
top: -14px;
z-index: 9999;
border-radius: 2px;
pointer-events: none;
animation-name: confetti-fall;
animation-timing-function: cubic-bezier(0.2, 0.6, 0.4, 1);
animation-fill-mode: forwards;
}
@keyframes confetti-fall {
0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
100% { transform: translateY(105vh) rotate(720deg); opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.confetti-piece { display: none; }
.promo-box { animation: none; }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+61
View File
@@ -0,0 +1,61 @@
"""Отправка email-уведомлений о заявках на корпоративную почту.
Доступы берутся из .env (см. .env.example). Если SMTP не настроен,
функция тихо пропускает отправку и возвращает False, не ломая основной поток.
"""
import smtplib
import logging
from typing import Optional
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
import config
logger = logging.getLogger("VK_Bot_IKP.mailer")
def is_configured() -> bool:
"""Проверяет, заданы ли минимальные параметры SMTP для отправки писем."""
return bool(
getattr(config, "SMTP_HOST", "")
and getattr(config, "SMTP_USER", "")
and getattr(config, "SMTP_PASSWORD", "")
and getattr(config, "MAIL_TO", "")
)
def send_email(subject: str, body: str, mail_to: Optional[str] = None) -> bool:
"""Отправляет письмо. По умолчанию на config.MAIL_TO; можно указать другой адрес (например, HR)."""
if not is_configured():
logger.info("[MAIL] Пропущено: SMTP не настроен в .env (SMTP_HOST/SMTP_USER/SMTP_PASSWORD/MAIL_TO).")
return False
host = config.SMTP_HOST
port = int(getattr(config, "SMTP_PORT", 465))
user = config.SMTP_USER
password = config.SMTP_PASSWORD
mail_from = getattr(config, "MAIL_FROM", "") or user
mail_to = mail_to or config.MAIL_TO
use_ssl = bool(getattr(config, "SMTP_USE_SSL", True))
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = Header(subject, "utf-8")
msg["From"] = formataddr((str(Header("Бот ИКП", "utf-8")), mail_from))
msg["To"] = mail_to
try:
if use_ssl:
with smtplib.SMTP_SSL(host, port, timeout=15) as server:
server.login(user, password)
server.sendmail(mail_from, [a.strip() for a in mail_to.split(",")], msg.as_string())
else:
with smtplib.SMTP(host, port, timeout=15) as server:
server.starttls()
server.login(user, password)
server.sendmail(mail_from, [a.strip() for a in mail_to.split(",")], msg.as_string())
logger.info(f"[MAIL] Письмо отправлено на {mail_to}: {subject}")
return True
except Exception as e:
logger.error(f"[MAIL] Ошибка отправки письма: {e}")
return False