Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ecbd7aa94 | |||
| e41db35f13 | |||
| 0085195941 | |||
| c814eca596 | |||
| b766d445b4 | |||
| 3ff411b554 | |||
| 5ce69fb66c | |||
| 79ec619360 | |||
| 40cfcbd45f | |||
| 5efc0aca8d | |||
| 4429b99a55 | |||
| 19bade27b6 | |||
| fc79da8f83 | |||
| 6d4dd16caf | |||
| 77f314be3f | |||
| 5d88b8899e | |||
| 86f8115de1 | |||
| 0e162ac9a1 | |||
| bf958e1c12 | |||
| f5ce327bbf | |||
| 4bf7f98540 | |||
| 577d342bdd | |||
| f9cdaec75a | |||
| e509c0cfd1 | |||
| 8f3c3b8b83 | |||
| 9ded7a513d | |||
| d2ca750a2d | |||
| 24daec1c09 | |||
| 81c1162ca1 |
@@ -0,0 +1,15 @@
|
|||||||
|
# В образ не нужны:
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
bot.lock
|
||||||
|
bot_database.db
|
||||||
|
*.xlsx
|
||||||
|
data/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
help_img/_orig/
|
||||||
|
# .env не копируем в образ — настройки прокидываются через env_file в docker-compose
|
||||||
|
.env
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# НАСТРОЙКА БОТА «Кибершкола БАЗА» — файл .env
|
||||||
|
# ============================================================================
|
||||||
|
# Это ШАБЛОН. Что делать:
|
||||||
|
# 1) Скопируйте этот файл рядом и назовите ровно .env (без .example).
|
||||||
|
# Проще: запустите start_bot.bat — он сам создаст .env и откроет его в
|
||||||
|
# Блокноте. Заполните, сохраните, запустите start_bot.bat снова.
|
||||||
|
# 2) Заполните строки из раздела 1 и 2 (помечены «ОБЯЗАТЕЛЬНО»). Остальное —
|
||||||
|
# по желанию.
|
||||||
|
#
|
||||||
|
# ПРАВИЛА ЗАПОЛНЕНИЯ (важно!):
|
||||||
|
# • Формат строки: ПЕРЕМЕННАЯ=значение — без пробелов вокруг «=», без кавычек.
|
||||||
|
# • НЕ пишите комментарии в одной строке со значением. Комментарий — только
|
||||||
|
# на отдельной строке, начинается с #.
|
||||||
|
# • Числовые поля (ID, порт) не оставляйте пустыми — впишите число.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 1. ПОДКЛЮЧЕНИЕ К СООБЩЕСТВУ (ОБЯЗАТЕЛЬНО) ──────────────
|
||||||
|
# VK_TOKEN — ключ доступа ВАШЕГО сообщества:
|
||||||
|
# Сообщество → Управление → Работа с API → Ключи доступа → Создать ключ
|
||||||
|
# (право «Сообщения сообщества») → скопировать сюда.
|
||||||
|
# Там же: Работа с API → Long Poll API → включить, версия — последняя.
|
||||||
|
VK_TOKEN=your_vk_community_token_here
|
||||||
|
|
||||||
|
# VK_GROUP_ID — числовой ID сообщества (только цифры). Замените 0 на свой ID.
|
||||||
|
# Узнать ID: Управление → Работа с API (виден там), либо через vk.com.
|
||||||
|
VK_GROUP_ID=0
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 2. КОМУ ПРИХОДЯТ ЗАЯВКИ (ОБЯЗАТЕЛЬНО) ──────────────────
|
||||||
|
# OPERATOR_PEER_ID — VK ID человека, которому падают заявки/запросы клиентов.
|
||||||
|
# Узнать свой ID можно, написав боту @idbot во ВКонтакте.
|
||||||
|
OPERATOR_PEER_ID=350793273
|
||||||
|
|
||||||
|
# ADMIN_IDS — у кого есть «Админка» в боте (через запятую, только цифры).
|
||||||
|
# Обычно тот же ID, что OPERATOR_PEER_ID. Пример: 350793273,12345678
|
||||||
|
ADMIN_IDS=350793273,25076348,9782434,255661162
|
||||||
|
|
||||||
|
# (Необязательно) Несколько операторов — заявки уйдут ВСЕМ. Через запятую.
|
||||||
|
# OPERATOR_PEER_IDS=350793273,12345678
|
||||||
|
|
||||||
|
# (Необязательно) Часы работы — вне их клиенту пишем «ответим утром».
|
||||||
|
WORK_HOUR_START=9
|
||||||
|
WORK_HOUR_END=21
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 3. ПОЧТА ДЛЯ ЗАЯВОК (по желанию) ──────────────────────
|
||||||
|
# Заполните — и каждая заявка продублируется на почту. Оставите пусто — бот
|
||||||
|
# работает как обычно, просто письма не шлёт.
|
||||||
|
# SMTP_HOST — сервер почты, напр. smtp.yandex.ru / smtp.mail.ru / smtp.gmail.com
|
||||||
|
SMTP_HOST=
|
||||||
|
# SMTP_PORT — 465 для SSL (обычно), 587 для STARTTLS
|
||||||
|
SMTP_PORT=465
|
||||||
|
# SMTP_USER — полный email-логин
|
||||||
|
SMTP_USER=
|
||||||
|
# SMTP_PASSWORD — ПАРОЛЬ ПРИЛОЖЕНИЯ (для Яндекс/Gmail), не основной пароль!
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
# MAIL_FROM — от кого (пусто = как SMTP_USER)
|
||||||
|
MAIL_FROM=
|
||||||
|
# MAIL_TO — куда слать заявки (можно несколько через запятую)
|
||||||
|
MAIL_TO=group-zakaz@infocyber.pro
|
||||||
|
# SMTP_USE_SSL — true для порта 465, false для 587
|
||||||
|
SMTP_USE_SSL=true
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 4. ССЫЛКИ КНОПОК ЗАПИСИ/ОПЛАТЫ ────────────────────────
|
||||||
|
# Куда ведут кнопки в боте. Их также можно менять прямо из «Админки → Ссылки».
|
||||||
|
# UTM-метки бот добавляет сам.
|
||||||
|
LINK_TRIAL=https://infocyber.pro/form/index.html
|
||||||
|
LINK_TARIFFS=https://infocyber.pro/#prices
|
||||||
|
LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2
|
||||||
|
LINK_GROUP_8=https://infocyber.pro/form/index.html?service_id=web_3
|
||||||
|
LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1
|
||||||
|
LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
|
||||||
|
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
|
||||||
|
# LINK_SUMMER — летний интенсив (замените, когда будет своя страница)
|
||||||
|
LINK_SUMMER=https://infocyber.pro/form/index.html
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 5. ЭКСТРЕННЫЙ СТОП /favi-stop (по желанию) ─────────────
|
||||||
|
# Команда /favi-stop у супер-админа отключает бота по паролю. Хранится ХЭШ
|
||||||
|
# пароля, а не сам пароль. Пусто — команда отключена.
|
||||||
|
# Сгенерировать хэш своего пароля (замените ВАШ_ПАРОЛЬ):
|
||||||
|
# py -3.11 -c "import hashlib,os,sys; s=os.urandom(16); print('pbkdf2_sha256$240000$'+s.hex()+'$'+hashlib.pbkdf2_hmac('sha256',sys.argv[1].encode(),s,240000).hex())" "ВАШ_ПАРОЛЬ"
|
||||||
|
FAVI_STOP_HASH=
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── ПРОЧЕЕ (можно не трогать) ─────────────────────────────
|
||||||
|
ALLOWED_ORIGIN=https://infocyber.pro
|
||||||
|
PROMO_ENABLED=false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# СУПЕР-АДМИН (кто добавляет админов и делает /favi-stop) задаётся НЕ здесь,
|
||||||
|
# а в коде — файл config.py, список SUPER_ADMIN_IDS.
|
||||||
|
# Готово? Запускайте start_bot.bat.
|
||||||
|
# ============================================================================
|
||||||
+73
-24
@@ -1,42 +1,72 @@
|
|||||||
# VK API Configuration
|
# ============================================================================
|
||||||
# Токен сообщества ВКонтакте (с правами на управление сообщениями)
|
# НАСТРОЙКА БОТА «Кибершкола БАЗА» — файл .env
|
||||||
|
# ============================================================================
|
||||||
|
# Это ШАБЛОН. Что делать:
|
||||||
|
# 1) Скопируйте этот файл рядом и назовите ровно .env (без .example).
|
||||||
|
# Проще: запустите start_bot.bat — он сам создаст .env и откроет его в
|
||||||
|
# Блокноте. Заполните, сохраните, запустите start_bot.bat снова.
|
||||||
|
# 2) Заполните строки из раздела 1 и 2 (помечены «ОБЯЗАТЕЛЬНО»). Остальное —
|
||||||
|
# по желанию.
|
||||||
|
#
|
||||||
|
# ПРАВИЛА ЗАПОЛНЕНИЯ (важно!):
|
||||||
|
# • Формат строки: ПЕРЕМЕННАЯ=значение — без пробелов вокруг «=», без кавычек.
|
||||||
|
# • НЕ пишите комментарии в одной строке со значением. Комментарий — только
|
||||||
|
# на отдельной строке, начинается с #.
|
||||||
|
# • Числовые поля (ID, порт) не оставляйте пустыми — впишите число.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 1. ПОДКЛЮЧЕНИЕ К СООБЩЕСТВУ (ОБЯЗАТЕЛЬНО) ──────────────
|
||||||
|
# VK_TOKEN — ключ доступа ВАШЕГО сообщества:
|
||||||
|
# Сообщество → Управление → Работа с API → Ключи доступа → Создать ключ
|
||||||
|
# (право «Сообщения сообщества») → скопировать сюда.
|
||||||
|
# Там же: Работа с API → Long Poll API → включить, версия — последняя.
|
||||||
VK_TOKEN=your_vk_community_token_here
|
VK_TOKEN=your_vk_community_token_here
|
||||||
|
|
||||||
# Идентификатор (ID) сообщества ВКонтакте (только цифры)
|
# VK_GROUP_ID — числовой ID сообщества (только цифры). Замените 0 на свой ID.
|
||||||
|
# Узнать ID: Управление → Работа с API (виден там), либо через vk.com.
|
||||||
VK_GROUP_ID=0
|
VK_GROUP_ID=0
|
||||||
|
|
||||||
# ID оператора / администратора ВКонтакте для отправки лидов и оповещений (только цифры)
|
|
||||||
OPERATOR_PEER_ID=12345678
|
|
||||||
|
|
||||||
# Список ID администраторов (через запятую) для управления услугами через чат
|
# ───────────────── 2. КОМУ ПРИХОДЯТ ЗАЯВКИ (ОБЯЗАТЕЛЬНО) ──────────────────
|
||||||
ADMIN_IDS=12345678
|
# OPERATOR_PEER_ID — VK ID человека, которому падают заявки/запросы клиентов.
|
||||||
|
# Узнать свой ID можно, написав боту @idbot во ВКонтакте.
|
||||||
|
OPERATOR_PEER_ID=0
|
||||||
|
|
||||||
# Разрешённый источник (Origin) для CORS веб-формы (домен сайта). Для локальной отладки можно '*'
|
# ADMIN_IDS — у кого есть «Админка» в боте (через запятую, только цифры).
|
||||||
ALLOWED_ORIGIN=https://infocyber.pro
|
# Обычно тот же ID, что OPERATOR_PEER_ID. Пример: 350793273,12345678
|
||||||
|
ADMIN_IDS=0
|
||||||
|
|
||||||
# Промокоды на скидку. Пока выключены. Поставь true, когда захочешь их выдавать клиентам.
|
# (Необязательно) Несколько операторов — заявки уйдут ВСЕМ. Через запятую.
|
||||||
PROMO_ENABLED=false
|
# OPERATOR_PEER_IDS=350793273,12345678
|
||||||
|
|
||||||
# ===== Email (SMTP) для уведомлений о заявках =====
|
# (Необязательно) Часы работы — вне их клиенту пишем «ответим утром».
|
||||||
# Адрес SMTP-сервера почты (например, smtp.yandex.ru / smtp.gmail.com / smtp.mail.ru)
|
WORK_HOUR_START=9
|
||||||
|
WORK_HOUR_END=21
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 3. ПОЧТА ДЛЯ ЗАЯВОК (по желанию) ──────────────────────
|
||||||
|
# Заполните — и каждая заявка продублируется на почту. Оставите пусто — бот
|
||||||
|
# работает как обычно, просто письма не шлёт.
|
||||||
|
# SMTP_HOST — сервер почты, напр. smtp.yandex.ru / smtp.mail.ru / smtp.gmail.com
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
# Порт: 465 для SSL, 587 для STARTTLS
|
# SMTP_PORT — 465 для SSL (обычно), 587 для STARTTLS
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
# Логин (полный email) и пароль (для Яндекс/Gmail — пароль приложения, не основной!)
|
# SMTP_USER — полный email-логин
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
|
# SMTP_PASSWORD — ПАРОЛЬ ПРИЛОЖЕНИЯ (для Яндекс/Gmail), не основной пароль!
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
# От кого отправлять (по умолчанию = SMTP_USER)
|
# MAIL_FROM — от кого (пусто = как SMTP_USER)
|
||||||
MAIL_FROM=
|
MAIL_FROM=
|
||||||
# Куда отправлять заявки (можно несколько через запятую)
|
# MAIL_TO — куда слать заявки (можно несколько через запятую)
|
||||||
MAIL_TO=group-zakaz@infocyber.pro
|
MAIL_TO=group-zakaz@infocyber.pro
|
||||||
# Куда отправлять отклики на вакансии/набор в состав. Пусто = на тот же адрес, что MAIL_TO.
|
# SMTP_USE_SSL — true для порта 465, false для 587
|
||||||
# Можно вынести на отдельный HR-адрес, напр. hr@infocyber.pro
|
|
||||||
HR_MAIL_TO=
|
|
||||||
# true — SSL (порт 465), false — STARTTLS (порт 587)
|
|
||||||
SMTP_USE_SSL=true
|
SMTP_USE_SSL=true
|
||||||
|
|
||||||
# --- Ссылки Кибершколы БАЗА (кнопки записи/оплаты в боте) ---
|
|
||||||
# UTM добавляются кодом автоматически; здесь только базовые URL. Замените при необходимости.
|
# ───────────────── 4. ССЫЛКИ КНОПОК ЗАПИСИ/ОПЛАТЫ ────────────────────────
|
||||||
|
# Куда ведут кнопки в боте. Их также можно менять прямо из «Админки → Ссылки».
|
||||||
|
# UTM-метки бот добавляет сам.
|
||||||
LINK_TRIAL=https://infocyber.pro/form/index.html
|
LINK_TRIAL=https://infocyber.pro/form/index.html
|
||||||
LINK_TARIFFS=https://infocyber.pro/#prices
|
LINK_TARIFFS=https://infocyber.pro/#prices
|
||||||
LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2
|
LINK_GROUP_4=https://infocyber.pro/form/index.html?service_id=web_2
|
||||||
@@ -44,5 +74,24 @@ LINK_GROUP_8=https://infocyber.pro/form/index.html?service_id=web_3
|
|||||||
LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1
|
LINK_IND_1=https://infocyber.pro/form/index.html?service_id=web_1
|
||||||
LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
|
LINK_IND_4=https://infocyber.pro/form/index.html?service_id=web_4
|
||||||
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
|
LINK_IND_8=https://infocyber.pro/form/index.html?service_id=web_5
|
||||||
# Летний интенсив: отдельной страницы на сайте нет — ведём на общую форму (замените, когда появится)
|
# LINK_SUMMER — летний интенсив (замените, когда будет своя страница)
|
||||||
LINK_SUMMER=https://infocyber.pro/form/index.html
|
LINK_SUMMER=https://infocyber.pro/form/index.html
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── 5. ЭКСТРЕННЫЙ СТОП /favi-stop (по желанию) ─────────────
|
||||||
|
# Команда /favi-stop у супер-админа отключает бота по паролю. Хранится ХЭШ
|
||||||
|
# пароля, а не сам пароль. Пусто — команда отключена.
|
||||||
|
# Сгенерировать хэш своего пароля (замените ВАШ_ПАРОЛЬ):
|
||||||
|
# py -3.11 -c "import hashlib,os,sys; s=os.urandom(16); print('pbkdf2_sha256$240000$'+s.hex()+'$'+hashlib.pbkdf2_hmac('sha256',sys.argv[1].encode(),s,240000).hex())" "ВАШ_ПАРОЛЬ"
|
||||||
|
FAVI_STOP_HASH=
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── ПРОЧЕЕ (можно не трогать) ─────────────────────────────
|
||||||
|
ALLOWED_ORIGIN=https://infocyber.pro
|
||||||
|
PROMO_ENABLED=false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# СУПЕР-АДМИН (кто добавляет админов и делает /favi-stop) задаётся НЕ здесь,
|
||||||
|
# а в коде — файл config.py, список SUPER_ADMIN_IDS.
|
||||||
|
# Готово? Запускайте start_bot.bat.
|
||||||
|
# ============================================================================
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
# Configuration files containing credentials
|
# .env НАМЕРЕННО добавлен в репозиторий как ГОТОВЫЙ шаблон (ID уже вписаны,
|
||||||
.env
|
# токен — заглушка). ВАЖНО: реальный VK_TOKEN вписывается только локально на
|
||||||
|
# машине с ботом и в репозиторий пушить его НЕ нужно.
|
||||||
|
|
||||||
# Python byte-compiled files
|
# Python byte-compiled files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Образ бота «Кибершкола БАЗА» (VK). Python 3.11.
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Не писать .pyc, логи — сразу в вывод (docker logs)
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Сначала зависимости — так слой кэшируется и пересборка быстрее
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Код бота
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Каталог для базы (монтируется как volume, чтобы данные не терялись)
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
ENV DB_PATH=/app/data/bot_database.db
|
||||||
|
|
||||||
|
# Запуск бота (long poll, слушает VK)
|
||||||
|
CMD ["python", "-u", "bot.py"]
|
||||||
@@ -7,6 +7,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -22,6 +24,9 @@ except ImportError:
|
|||||||
vk_api = None
|
vk_api = None
|
||||||
|
|
||||||
from database import DatabaseManager
|
from database import DatabaseManager
|
||||||
|
from reminders import parse_reminder_datetime, compute_next_occurrence, \
|
||||||
|
server_utc_offset_hours, convert_time
|
||||||
|
from datetime import datetime
|
||||||
import config
|
import config
|
||||||
import mailer
|
import mailer
|
||||||
|
|
||||||
@@ -32,8 +37,9 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("VK_Bot_IKP")
|
logger = logging.getLogger("VK_Bot_IKP")
|
||||||
|
|
||||||
# Инициализация базы данных сессий
|
# Инициализация базы данных сессий.
|
||||||
db = DatabaseManager()
|
# Путь можно переопределить через DB_PATH (нужно для Docker: база в volume).
|
||||||
|
db = DatabaseManager(os.getenv("DB_PATH") or "bot_database.db")
|
||||||
|
|
||||||
# Анти-флуд: максимум сообщений от одного пользователя за окно времени
|
# Анти-флуд: максимум сообщений от одного пользователя за окно времени
|
||||||
FLOOD_MAX_MSGS = 12
|
FLOOD_MAX_MSGS = 12
|
||||||
@@ -44,6 +50,16 @@ BAN_WINDOW_SEC = 60
|
|||||||
BAN_SEC = 600
|
BAN_SEC = 600
|
||||||
# Сколько секунд кэшировать список рантайм-админов из БД
|
# Сколько секунд кэшировать список рантайм-админов из БД
|
||||||
ADMINS_CACHE_TTL = 30
|
ADMINS_CACHE_TTL = 30
|
||||||
|
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
|
||||||
|
REMINDER_CHECK_INTERVAL_SEC = 60
|
||||||
|
|
||||||
|
REMINDER_PERIOD_LABELS = {
|
||||||
|
"once": "разово",
|
||||||
|
"weekly": "каждую неделю",
|
||||||
|
"monthly": "каждый месяц",
|
||||||
|
"yearly": "каждый год",
|
||||||
|
}
|
||||||
|
REMINDER_PERIOD_BY_LABEL = {v: k for k, v in REMINDER_PERIOD_LABELS.items()}
|
||||||
|
|
||||||
from keyboards import * # все get_*_keyboard
|
from keyboards import * # все get_*_keyboard
|
||||||
|
|
||||||
@@ -99,6 +115,7 @@ class VKBotApplication:
|
|||||||
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
|
self._flood_warned: Dict[int, float] = {} # user_id -> когда предупредили о флуде
|
||||||
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._favi_pending: Dict[int, dict] = {} # супер-админы, вводящие пароль /favi-stop
|
||||||
self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
|
self._last_antiflood_cleanup: float = 0.0 # когда последний раз чистили словари анти-флуда
|
||||||
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
|
# Кэш рантайм-админов (из БД), чтобы не бить в SQLite на каждое сообщение
|
||||||
self._admins_cache: Optional[set] = None
|
self._admins_cache: Optional[set] = None
|
||||||
@@ -202,10 +219,72 @@ class VKBotApplication:
|
|||||||
return ids if ids else [config.OPERATOR_PEER_ID]
|
return ids if ids else [config.OPERATOR_PEER_ID]
|
||||||
|
|
||||||
def _notify_operators(self, message: str):
|
def _notify_operators(self, message: str):
|
||||||
"""Отправляет сообщение всем операторам из списка."""
|
"""Отправляет сообщение операторам из списка (кроме тех, кто отключил себе уведомления)."""
|
||||||
for op_id in self._operator_ids():
|
for op_id in self._operator_ids():
|
||||||
|
if db.get_profile(op_id).get("notify_muted"):
|
||||||
|
continue
|
||||||
self.send_message(op_id, message)
|
self.send_message(op_id, message)
|
||||||
|
|
||||||
|
def _menu_flags(self) -> dict:
|
||||||
|
"""Текущие флаги показа кнопок главного меню (из БД, дефолты — из config)."""
|
||||||
|
return {k: db.get_flag(k, default) for k, (_label, default) in config.MENU_FEATURES.items()}
|
||||||
|
|
||||||
|
def _menu_kb(self, user_id: int) -> str:
|
||||||
|
"""Главное меню с учётом включённых/выключенных кнопок."""
|
||||||
|
return get_main_keyboard(is_admin=self._is_admin(user_id), flags=self._menu_flags())
|
||||||
|
|
||||||
|
# ---------- Экстренное отключение бота (/favi-stop) ----------
|
||||||
|
@staticmethod
|
||||||
|
def _verify_favi_password(attempt: str) -> bool:
|
||||||
|
"""Сверяет введённый пароль с хэшем PBKDF2 из config (константное сравнение)."""
|
||||||
|
stored = getattr(config, "FAVI_STOP_HASH", "") or ""
|
||||||
|
try:
|
||||||
|
algo, iters, salt_hex, hash_hex = stored.split("$")
|
||||||
|
if algo != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", attempt.encode("utf-8"),
|
||||||
|
bytes.fromhex(salt_hex), int(iters))
|
||||||
|
return hmac.compare_digest(dk.hex(), hash_hex)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _start_favi_flow(self, user_id: int, action: str):
|
||||||
|
"""Запуск сценария ввода пароля для экстренного стоп/старт (супер-админ)."""
|
||||||
|
if not getattr(config, "FAVI_STOP_HASH", ""):
|
||||||
|
self.send_message(user_id, "⚠️ Экстренный выключатель не настроен (нет FAVI_STOP_HASH в .env).")
|
||||||
|
return
|
||||||
|
self._favi_pending[user_id] = {"action": action, "tries": 0}
|
||||||
|
verb = "ОТКЛЮЧЕНИЯ" if action == "stop" else "включения"
|
||||||
|
self.send_message(user_id, f"🔒 Введите пароль для {verb} бота одним сообщением.\n"
|
||||||
|
"Отмена — напишите «отмена».")
|
||||||
|
|
||||||
|
def _handle_favi_password(self, user_id: int, text: str):
|
||||||
|
ctx = self._favi_pending.get(user_id)
|
||||||
|
if not ctx:
|
||||||
|
return
|
||||||
|
if text.strip().lower() in ("отмена", "cancel", "/cancel"):
|
||||||
|
self._favi_pending.pop(user_id, None)
|
||||||
|
self.send_message(user_id, "Отменено.")
|
||||||
|
return
|
||||||
|
if self._verify_favi_password(text.strip()):
|
||||||
|
self._favi_pending.pop(user_id, None)
|
||||||
|
if ctx["action"] == "stop":
|
||||||
|
db.set_flag("bot_disabled", True)
|
||||||
|
logger.warning(f"Бот ЭКСТРЕННО ОТКЛЮЧЁН супер-админом {user_id}")
|
||||||
|
self.send_message(user_id, "🛑 Бот ЭКСТРЕННО ОТКЛЮЧЁН — клиентам он больше не отвечает.\n"
|
||||||
|
"Включить обратно: команда /favi-start")
|
||||||
|
else:
|
||||||
|
db.set_flag("bot_disabled", False)
|
||||||
|
logger.warning(f"Бот снова включён супер-админом {user_id}")
|
||||||
|
self.send_message(user_id, "✅ Бот снова включён и отвечает клиентам.")
|
||||||
|
return
|
||||||
|
ctx["tries"] += 1
|
||||||
|
if ctx["tries"] >= 3:
|
||||||
|
self._favi_pending.pop(user_id, None)
|
||||||
|
self.send_message(user_id, "⛔ Неверный пароль. Попытки исчерпаны — повторите команду заново.")
|
||||||
|
else:
|
||||||
|
self.send_message(user_id, f"❌ Неверный пароль. Осталось попыток: {3 - ctx['tries']}")
|
||||||
|
|
||||||
def _vk_name(self, user_id: int) -> Optional[str]:
|
def _vk_name(self, user_id: int) -> Optional[str]:
|
||||||
"""Реальное имя пользователя из VK (для сверки с введёнными данными)."""
|
"""Реальное имя пользователя из VK (для сверки с введёнными данными)."""
|
||||||
if not self.vk or user_id <= 0:
|
if not self.vk or user_id <= 0:
|
||||||
@@ -223,12 +302,12 @@ class VKBotApplication:
|
|||||||
def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
|
def _resolve_vk_user_id(self, raw: str) -> Optional[int]:
|
||||||
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
|
"""Из ссылки / короткого имени / ID получает числовой VK user_id (или None).
|
||||||
|
|
||||||
Принимает что угодно: https://vk.com/gruzdevas, vk.com/id123, @gruzdevas,
|
Принимает что угодно: https://vk.com/gruzdevas, https://vk.ru/gruzdevas,
|
||||||
gruzdevas, 123456. Короткое имя резолвится через VK API.
|
m.vk.com/id123, @gruzdevas, gruzdevas, 123456. Короткое имя резолвится через VK API.
|
||||||
"""
|
"""
|
||||||
s = (raw or "").strip()
|
s = (raw or "").strip()
|
||||||
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
|
s = re.sub(r"^https?://", "", s, flags=re.I) # убираем протокол
|
||||||
s = re.sub(r"^(m\.)?vk\.com/", "", s, flags=re.I) # убираем домен
|
s = re.sub(r"^(www\.|m\.)?vk\.(com|ru)/", "", s, flags=re.I) # убираем домен (vk.com / vk.ru)
|
||||||
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
|
s = s.split("?")[0].split("/")[0].strip().strip("@").strip() # берём первый сегмент
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
@@ -256,6 +335,7 @@ class VKBotApplication:
|
|||||||
def _send_admin_panel(self, user_id: int):
|
def _send_admin_panel(self, user_id: int):
|
||||||
"""Главный экран админ-панели с кнопками разделов."""
|
"""Главный экран админ-панели с кнопками разделов."""
|
||||||
is_super = self._is_super_admin(user_id)
|
is_super = self._is_super_admin(user_id)
|
||||||
|
muted = bool(db.get_profile(user_id).get("notify_muted"))
|
||||||
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
|
extra = "\n• 👑 Администраторы — добавить/удалить (только супер-админ)" if is_super else ""
|
||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -264,11 +344,60 @@ class VKBotApplication:
|
|||||||
"• 📊 Статистика — заявки и диалоги\n"
|
"• 📊 Статистика — заявки и диалоги\n"
|
||||||
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
|
"• 💬 Быстрые ответы — авто-ответы по ключевым словам\n"
|
||||||
"• 📣 Рассылка — сообщение подписчикам\n"
|
"• 📣 Рассылка — сообщение подписчикам\n"
|
||||||
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты"
|
"• 🔗 Ссылки — куда ведут кнопки записи/оплаты\n"
|
||||||
|
"• 🧩 Кнопки меню — показать/скрыть кнопки (интенсив, вебинар и др.)\n"
|
||||||
|
"• 🔔 Уведомления — вкл/выкл уведомления о заявках лично для вас"
|
||||||
+ extra,
|
+ extra,
|
||||||
keyboard=get_admin_keyboard(is_super=is_super)
|
keyboard=get_admin_keyboard(is_super=is_super, muted=muted)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _toggle_admin_notifications(self, user_id: int):
|
||||||
|
"""Переключает лично для этого админа приём уведомлений о заявках/клиентах."""
|
||||||
|
muted = not bool(db.get_profile(user_id).get("notify_muted"))
|
||||||
|
db.set_profile_field(user_id, "notify_muted", muted)
|
||||||
|
if muted:
|
||||||
|
txt = ("🔕 Уведомления от клиентов ОТКЛЮЧЕНЫ лично для вас.\n"
|
||||||
|
"Заявки и сообщения от клиентов приходить не будут (у других операторов — как раньше).\n"
|
||||||
|
"Включить обратно — кнопкой «Уведомления» в админке.")
|
||||||
|
else:
|
||||||
|
txt = "🔔 Уведомления от клиентов ВКЛЮЧЕНЫ — заявки снова будут приходить вам."
|
||||||
|
self.send_message(user_id, txt,
|
||||||
|
keyboard=get_admin_keyboard(is_super=self._is_super_admin(user_id), muted=muted))
|
||||||
|
|
||||||
|
def _send_features_admin(self, user_id: int):
|
||||||
|
"""Раздел «Кнопки меню»: показать/скрыть кнопки главного меню (нажатие — переключение)."""
|
||||||
|
kb = VkKeyboard(inline=True)
|
||||||
|
lines = []
|
||||||
|
for i, (key, (label, default)) in enumerate(config.MENU_FEATURES.items()):
|
||||||
|
on = db.get_flag(key, default)
|
||||||
|
btn = f"{label} — {'вкл ✅' if on else 'выкл ⛔'}"
|
||||||
|
lines.append(btn)
|
||||||
|
if i:
|
||||||
|
kb.add_line()
|
||||||
|
kb.add_button(btn, color=VkKeyboardColor.POSITIVE if on else VkKeyboardColor.NEGATIVE)
|
||||||
|
kb.add_line()
|
||||||
|
kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"🧩 Кнопки главного меню\n\n"
|
||||||
|
"Нажмите на пункт, чтобы показать/скрыть его в меню у клиентов:\n\n"
|
||||||
|
+ "\n".join(lines),
|
||||||
|
keyboard=kb.get_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_feature(self, user_id: int, text: str):
|
||||||
|
"""Переключает показ кнопки меню по её подписи."""
|
||||||
|
base = text.split(" — ")[0].strip()
|
||||||
|
for key, (label, default) in config.MENU_FEATURES.items():
|
||||||
|
if base == label:
|
||||||
|
new = not db.get_flag(key, default)
|
||||||
|
db.set_flag(key, new)
|
||||||
|
self.send_message(user_id, f"«{label}» теперь {'показывается в меню ✅' if new else 'скрыта из меню ⛔'}.")
|
||||||
|
self._send_features_admin(user_id)
|
||||||
|
return
|
||||||
|
self.send_message(user_id, "Не понял, какую кнопку переключить.")
|
||||||
|
self._send_features_admin(user_id)
|
||||||
|
|
||||||
def _send_admins_panel(self, user_id: int):
|
def _send_admins_panel(self, user_id: int):
|
||||||
"""Раздел управления администраторами (только супер-админ)."""
|
"""Раздел управления администраторами (только супер-админ)."""
|
||||||
if not self._is_super_admin(user_id):
|
if not self._is_super_admin(user_id):
|
||||||
@@ -344,6 +473,103 @@ class VKBotApplication:
|
|||||||
"(админов из .env и супер-админов отсюда снять нельзя).")
|
"(админов из .env и супер-админов отсюда снять нельзя).")
|
||||||
self._send_admins_panel(user_id)
|
self._send_admins_panel(user_id)
|
||||||
|
|
||||||
|
def _all_admin_ids(self) -> list:
|
||||||
|
"""Полный список текущих админов: супер-админы + .env + добавленные в рантайме,
|
||||||
|
без дублей, отсортирован (используется для «все админы» и нумерации в мастере)."""
|
||||||
|
ids = set(getattr(config, "SUPER_ADMIN_IDS", []))
|
||||||
|
ids |= set(getattr(config, "ADMIN_IDS", []))
|
||||||
|
ids |= self._runtime_admins()
|
||||||
|
return sorted(ids)
|
||||||
|
|
||||||
|
def _admin_display(self, uid: int) -> str:
|
||||||
|
"""ФИО из VK + ссылка, если имя удалось получить, иначе только ссылка
|
||||||
|
(используется в мастере напоминалок, чтобы не кликать по каждому id)."""
|
||||||
|
name = self._vk_name(uid)
|
||||||
|
return f"{name} (vk.com/id{uid})" if name else f"vk.com/id{uid}"
|
||||||
|
|
||||||
|
def _parse_tz_offset(self, raw: str) -> Optional[int]:
|
||||||
|
"""Парсит смещение от UTC: либо чистое число '+3'/'-5'/'0' (ручной ввод),
|
||||||
|
либо 'Город, UTC+N' (клик по кнопке get_timezone_keyboard). None, если
|
||||||
|
не удалось распознать или число вне диапазона реальных поясов (-12..+14)."""
|
||||||
|
raw = raw.strip()
|
||||||
|
m = re.search(r"UTC\s*([+-]?\d{1,2})\s*$", raw, re.I)
|
||||||
|
if not m:
|
||||||
|
m = re.match(r"^([+-]?\d{1,2})$", raw)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
val = int(m.group(1))
|
||||||
|
return val if -12 <= val <= 14 else None
|
||||||
|
|
||||||
|
def _require_reminder_tz(self, user_id: int) -> bool:
|
||||||
|
"""Гейт часового пояса: если у админа он ещё не задан — переводит его
|
||||||
|
в мастер ввода пояса и возвращает False (вызывающий должен просто
|
||||||
|
return). Если пояс уже задан — возвращает True, можно продолжать."""
|
||||||
|
if db.get_admin_tz_offset(user_id) is not None:
|
||||||
|
return True
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"🌐 Прежде чем пользоваться напоминалками, укажите свой часовой пояс.\n"
|
||||||
|
"Выберите город кнопкой ниже, либо введите смещение от UTC вручную "
|
||||||
|
"(например, +3), если своего города в списке нет.",
|
||||||
|
keyboard=get_timezone_keyboard()
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _handle_set_timezone(self, user_id: int, text: str):
|
||||||
|
"""Команда 'часовой пояс <±N>' — сменить уже заданный пояс в любой момент."""
|
||||||
|
raw = text[len("часовой пояс"):].strip()
|
||||||
|
offset = self._parse_tz_offset(raw)
|
||||||
|
if offset is None:
|
||||||
|
self.send_message(user_id, "⚠️ Формат: часовой пояс +3 (число от -12 до +14).")
|
||||||
|
return
|
||||||
|
db.set_admin_tz_offset(user_id, offset)
|
||||||
|
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
|
||||||
|
|
||||||
|
def _send_reminders_admin(self, user_id: int):
|
||||||
|
"""Раздел «Напоминалки»: список активных + инструкции.
|
||||||
|
Вызывается только после прохождения гейта часового пояса
|
||||||
|
(см. _require_reminder_tz) — тут он гарантированно уже задан."""
|
||||||
|
rows = db.get_active_reminders()
|
||||||
|
viewer_offset = db.get_admin_tz_offset(user_id)
|
||||||
|
if viewer_offset is None:
|
||||||
|
viewer_offset = 0
|
||||||
|
server_offset = server_utc_offset_hours()
|
||||||
|
listing = ""
|
||||||
|
for r in rows:
|
||||||
|
server_dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
viewer_dt = convert_time(server_dt, server_offset, viewer_offset)
|
||||||
|
who = "всем админам" if r["recipients"] == "all" else \
|
||||||
|
", ".join(self._admin_display(uid) for uid in r["recipients"])
|
||||||
|
period = REMINDER_PERIOD_LABELS.get(r["recurrence"], r["recurrence"])
|
||||||
|
short = (r["text"][:50] + "…") if len(r["text"]) > 50 else r["text"]
|
||||||
|
pause_mark = "⏸ (на паузе) " if r["paused"] else ""
|
||||||
|
listing += (f"🔹 [{r['id']}] {pause_mark}{viewer_dt.strftime('%d.%m.%Y %H:%M')} "
|
||||||
|
f"(UTC{viewer_offset:+d}, {period}) → {who}\n"
|
||||||
|
f" «{short}»\n")
|
||||||
|
if not listing:
|
||||||
|
listing = "Активных напоминаний нет.\n"
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"⏰ Напоминалки\n\n"
|
||||||
|
f"{listing}\n"
|
||||||
|
"ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз "
|
||||||
|
"зайти сюда и указать свой часовой пояс.\n\n"
|
||||||
|
"Управляйте кнопками ниже: ➕ добавить • 🗑 удалить • ⏸/▶ пауза • 🌐 сменить часовой пояс",
|
||||||
|
keyboard=get_reminders_admin_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_reminder_delete(self, user_id: int, text: str):
|
||||||
|
arg = text[len("напоминалка удалить"):].strip()
|
||||||
|
if not arg.isdigit():
|
||||||
|
self.send_message(user_id, "⚠️ Укажите номер: напоминалка удалить 3")
|
||||||
|
return
|
||||||
|
if db.delete_reminder(int(arg)):
|
||||||
|
self.send_message(user_id, f"✅ Напоминание №{arg} удалено.")
|
||||||
|
else:
|
||||||
|
self.send_message(user_id, f"⚠️ Напоминание №{arg} не найдено.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
|
||||||
def _cleanup_antiflood(self):
|
def _cleanup_antiflood(self):
|
||||||
"""Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно."""
|
"""Периодически выметает протухшие записи анти-флуда, чтобы словари не росли бесконечно."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -368,6 +594,26 @@ class VKBotApplication:
|
|||||||
text_lower = text_clean.lower()
|
text_lower = text_clean.lower()
|
||||||
attachments = attachments or []
|
attachments = attachments or []
|
||||||
|
|
||||||
|
# Сообщения без текста (стикер/фото/голосовое/гифка/сервисное) не распознаются
|
||||||
|
# и раньше вызывали фолбэк «не понял запрос». Бот текстовый — просто игнорируем.
|
||||||
|
if not text_clean:
|
||||||
|
return
|
||||||
|
|
||||||
|
# === Экстренный выключатель бота (только супер-админ) ===
|
||||||
|
if self._is_super_admin(user_id):
|
||||||
|
if user_id in self._favi_pending:
|
||||||
|
self._handle_favi_password(user_id, text_clean)
|
||||||
|
return
|
||||||
|
if text_lower == "/favi-stop":
|
||||||
|
self._start_favi_flow(user_id, "stop")
|
||||||
|
return
|
||||||
|
if text_lower == "/favi-start":
|
||||||
|
self._start_favi_flow(user_id, "start")
|
||||||
|
return
|
||||||
|
# Если бот в аварийном режиме — не отвечаем никому (супер-админ обработан выше)
|
||||||
|
if db.get_flag("bot_disabled", False):
|
||||||
|
return
|
||||||
|
|
||||||
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
|
# Периодическая чистка in-memory словарей анти-флуда (не на каждое сообщение)
|
||||||
self._cleanup_antiflood()
|
self._cleanup_antiflood()
|
||||||
|
|
||||||
@@ -454,6 +700,79 @@ class VKBotApplication:
|
|||||||
elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]:
|
elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]:
|
||||||
self._send_links_admin(user_id)
|
self._send_links_admin(user_id)
|
||||||
return
|
return
|
||||||
|
elif text_lower in ["⏰ напоминалки", "напоминалки"]:
|
||||||
|
if self._require_reminder_tz(user_id):
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
elif text_lower == "➕ добавить":
|
||||||
|
if not self._require_reminder_tz(user_id):
|
||||||
|
return
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_DATE")
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"⏰ Новое напоминание.\nВведите дату и время: ДД.ММ.ГГГГ ЧЧ:ММ\n"
|
||||||
|
"Например: 15.03.2027 09:00",
|
||||||
|
keyboard=get_cancel_admin_keyboard()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif text_lower.startswith("напоминалка удалить"):
|
||||||
|
if not self._require_reminder_tz(user_id):
|
||||||
|
return
|
||||||
|
self._handle_reminder_delete(user_id, text_clean)
|
||||||
|
return
|
||||||
|
elif text_lower in ["🗑 удалить напоминание", "удалить напоминание"]:
|
||||||
|
if not self._require_reminder_tz(user_id):
|
||||||
|
return
|
||||||
|
rows = db.get_active_reminders()
|
||||||
|
if not rows:
|
||||||
|
self.send_message(user_id, "Активных напоминаний нет.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
items = [(r["id"], r["text"]) for r in rows]
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_DEL_PICK")
|
||||||
|
self.send_message(user_id, "🗑 Какое напоминание удалить? Выберите:",
|
||||||
|
keyboard=get_pick_keyboard(items))
|
||||||
|
return
|
||||||
|
elif text_lower in ["⏸/▶ пауза", "пауза"]:
|
||||||
|
if not self._require_reminder_tz(user_id):
|
||||||
|
return
|
||||||
|
rows = db.get_active_reminders()
|
||||||
|
if not rows:
|
||||||
|
self.send_message(user_id, "Активных напоминаний нет.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
items = [
|
||||||
|
(r["id"], ("▶ Возобновить: " if r["paused"] else "⏸ На паузу: ") + r["text"])
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_PAUSE_PICK")
|
||||||
|
self.send_message(user_id, "⏸/▶ Какое напоминание переключить? Выберите:",
|
||||||
|
keyboard=get_pick_keyboard(items))
|
||||||
|
return
|
||||||
|
elif text_lower in ["🌐 часовой пояс", "часовой пояс"]:
|
||||||
|
current = db.get_admin_tz_offset(user_id)
|
||||||
|
note = f" Сейчас: UTC{current:+d}." if current is not None else ""
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_TZ_SETUP")
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
f"🌐 Выберите новый часовой пояс кнопкой ниже, либо введите смещение "
|
||||||
|
f"от UTC вручную (например, +3).{note}",
|
||||||
|
keyboard=get_timezone_keyboard()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif text_lower.startswith("часовой пояс"):
|
||||||
|
self._handle_set_timezone(user_id, text_clean)
|
||||||
|
return
|
||||||
|
elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]:
|
||||||
|
self._toggle_admin_notifications(user_id)
|
||||||
|
return
|
||||||
|
elif text_lower in ["кнопки меню 🧩", "кнопки меню", "разделы", "разделы 🧩"]:
|
||||||
|
self._send_features_admin(user_id)
|
||||||
|
return
|
||||||
|
elif (" — вкл" in text_lower or " — выкл" in text_lower) and text.split(" — ")[0].strip() in [
|
||||||
|
lbl for lbl, _ in config.MENU_FEATURES.values()]:
|
||||||
|
self._toggle_feature(user_id, text_clean)
|
||||||
|
return
|
||||||
elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]:
|
elif text_lower in ["✏️ изменить ссылку", "изменить ссылку"]:
|
||||||
items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS]
|
items = [(k, config.LINK_LABELS.get(k, k)) for k in config.LINK_LABELS]
|
||||||
db.update_user_state(user_id, "ADMIN_LINK_PICK")
|
db.update_user_state(user_id, "ADMIN_LINK_PICK")
|
||||||
@@ -503,7 +822,7 @@ class VKBotApplication:
|
|||||||
elif text_lower in ["в главное меню ↩️"]:
|
elif text_lower in ["в главное меню ↩️"]:
|
||||||
self.send_message(
|
self.send_message(
|
||||||
user_id, "🏠 Главное меню.",
|
user_id, "🏠 Главное меню.",
|
||||||
keyboard=get_main_keyboard(is_admin=True)
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -565,7 +884,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
|
"Произошла непредвиденная ошибка в системе. Возвращаю вас в главное меню.",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------
|
# ------------------------------------------
|
||||||
@@ -576,27 +895,31 @@ class VKBotApplication:
|
|||||||
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
|
"""Главное меню Кибершколы БАЗА: воронка подбора, интенсив, цены, вопрос."""
|
||||||
|
|
||||||
# 1. Подобрать тренировку — запуск воронки
|
# 1. Подобрать тренировку — запуск воронки
|
||||||
if text_lower in ["подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
|
if db.get_flag("pickup", True) and text_lower in [
|
||||||
|
"подобрать тренировку 🎯", "подобрать тренировку", "подобрать заново 🎯",
|
||||||
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
|
"подобрать", "подбор", "записаться", "тренировки", "занятия"]:
|
||||||
self._baza_start_funnel(user_id)
|
self._baza_start_funnel(user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
|
# 1.5 Регистрация на вебинар (текстовый триггер; основной вход — callback-кнопка из поста)
|
||||||
if text_lower in ["записаться на вебинар", "вебинар", "хочу на вебинар", "регистрация на вебинар"]:
|
if db.get_flag("webinar", False) and text_lower in [
|
||||||
|
"записаться на вебинар 🎓", "записаться на вебинар", "вебинар",
|
||||||
|
"хочу на вебинар", "регистрация на вебинар"]:
|
||||||
self._handle_webinar_register(user_id)
|
self._handle_webinar_register(user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2. Летний интенсив CS2 (кнопка меню)
|
# 2. Летний интенсив CS2 (кнопка меню + кодовые слова) — только если раздел включён
|
||||||
|
if db.get_flag("intensive", True):
|
||||||
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
|
if text_lower in ["летний интенсив cs2 🔥", "летний интенсив cs2", "летний интенсив"]:
|
||||||
self._baza_start_intensive(user_id, via_keyword=False)
|
self._baza_start_intensive(user_id, via_keyword=False)
|
||||||
return
|
return
|
||||||
# 2.1 Кодовые слова рекламной кампании (лето/интенсив и т.п.) — сразу в ветку интенсива
|
|
||||||
if any(t in text_lower for t in config.SUMMER_TRIGGERS):
|
if any(t in text_lower for t in config.SUMMER_TRIGGERS):
|
||||||
self._baza_start_intensive(user_id, via_keyword=True)
|
self._baza_start_intensive(user_id, via_keyword=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3. Посмотреть цены
|
# 3. Посмотреть цены
|
||||||
if text_lower in ["посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
|
if db.get_flag("prices", True) and text_lower in [
|
||||||
|
"посмотреть цены 💰", "посмотреть цены", "цены", "цена", "стоимость", "прайс"]:
|
||||||
self._baza_show_prices(user_id)
|
self._baza_show_prices(user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -612,9 +935,13 @@ class VKBotApplication:
|
|||||||
"Открыть тарифы 💳")
|
"Открыть тарифы 💳")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 4. Задать вопрос — просим написать вопрос одним сообщением (§9)
|
# 4. Связь с человеком — всегда доступна (оператор/менеджер)
|
||||||
if text_lower in ["задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬",
|
if text_lower in ["оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
|
||||||
"оператор", "менеджер", "человек", "позвать оператора", "связь с менеджером"]:
|
self._baza_ask_question(user_id)
|
||||||
|
return
|
||||||
|
# 4.1 Кнопка «Задать вопрос» — если раздел включён
|
||||||
|
if db.get_flag("question", True) and text_lower in [
|
||||||
|
"задать вопрос 💬", "задать вопрос", "вопрос", "задать вопрос менеджеру 💬"]:
|
||||||
self._baza_ask_question(user_id)
|
self._baza_ask_question(user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -623,7 +950,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
"🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -636,7 +963,7 @@ class VKBotApplication:
|
|||||||
# 8. Быстрые ответы по ключевым словам (редактируются админом)
|
# 8. Быстрые ответы по ключевым словам (редактируются админом)
|
||||||
quick = db.match_custom_faq(text_lower)
|
quick = db.match_custom_faq(text_lower)
|
||||||
if quick:
|
if quick:
|
||||||
self.send_message(user_id, quick, keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
|
self.send_message(user_id, quick, keyboard=self._menu_kb(user_id))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 9. Пасхалка автора (запрет на удаление)
|
# 9. Пасхалка автора (запрет на удаление)
|
||||||
@@ -647,7 +974,7 @@ class VKBotApplication:
|
|||||||
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
|
"Меня создал Favi — автор бота. Он любит киберспорт и вкусно поесть. 🍔🍕🍣\n\n"
|
||||||
"Желаю хороших впечатлений! От этого бота\n\n"
|
"Желаю хороших впечатлений! От этого бота\n\n"
|
||||||
"https://vk.com/favilonia",
|
"https://vk.com/favilonia",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
|
keyboard=self._menu_kb(user_id),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -655,7 +982,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
|
"Пока не получилось определить запрос. Выберите подходящий вариант или напишите вопрос менеджеру.",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
@@ -698,7 +1025,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
config.GREETING_BAZA,
|
config.GREETING_BAZA,
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)),
|
keyboard=self._menu_kb(user_id),
|
||||||
attachment=self.get_logo_attachment(user_id)
|
attachment=self.get_logo_attachment(user_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -709,7 +1036,7 @@ class VKBotApplication:
|
|||||||
db.update_user_state(user_id, "FREE_CHAT")
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
self.send_message(
|
self.send_message(
|
||||||
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
|
if text_lower in ["задать вопрос менеджеру 💬", "задать вопрос менеджеру", "вопрос менеджеру",
|
||||||
@@ -1170,7 +1497,7 @@ class VKBotApplication:
|
|||||||
"посмотреть другие тренировки", "вернуться в меню ↩️"]:
|
"посмотреть другие тренировки", "вернуться в меню ↩️"]:
|
||||||
db.update_user_state(user_id, "FREE_CHAT")
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
self.send_message(user_id, "🏠 Главное меню Кибершколы БАЗА. Чем помочь?",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id)))
|
keyboard=self._menu_kb(user_id))
|
||||||
return
|
return
|
||||||
db.set_profile_field(user_id, "message", text.strip())
|
db.set_profile_field(user_id, "message", text.strip())
|
||||||
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
|
self.send_message(user_id, "Спасибо! Вопрос передан менеджеру.")
|
||||||
@@ -1273,7 +1600,10 @@ class VKBotApplication:
|
|||||||
logger.info(f"Фото загружено в VK: {result['attachment']}")
|
logger.info(f"Фото загружено в VK: {result['attachment']}")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка загрузки фото {image_path} в VK: {e}")
|
# Кэшируем неудачу, чтобы не долбить VK и лог на каждое приветствие
|
||||||
|
# (напр. у токена нет прав на фото — [15] Access denied).
|
||||||
|
self._photo_cache[image_path] = None
|
||||||
|
logger.warning(f"Фото {os.path.basename(image_path)} не загружено (шлём без него): {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1314,7 +1644,7 @@ class VKBotApplication:
|
|||||||
f"⏰ {config.WEBINAR_TIME}\n\n"
|
f"⏰ {config.WEBINAR_TIME}\n\n"
|
||||||
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
|
"Ссылку на эфир пришлём прямо в этот чат. До встречи! 🎮\n\n"
|
||||||
"А пока можешь посмотреть направления и цены — кнопки ниже 👇",
|
"А пока можешь посмотреть направления и цены — кнопки ниже 👇",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
|
logger.info(f"Webinar registration: user {user_id} ({'repeat' if already else 'new'})")
|
||||||
|
|
||||||
@@ -1326,7 +1656,7 @@ class VKBotApplication:
|
|||||||
self.send_message(
|
self.send_message(
|
||||||
user_id,
|
user_id,
|
||||||
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
|
"🚪 Диалог с оператором завершен. Вы вернулись к автоматическому ассистенту.",
|
||||||
keyboard=get_main_keyboard(is_admin=self._is_admin(user_id))
|
keyboard=self._menu_kb(user_id)
|
||||||
)
|
)
|
||||||
# Уведомляем операторов о закрытии диалога
|
# Уведомляем операторов о закрытии диалога
|
||||||
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
|
self._notify_operators(f"ℹ️ Пользователь vk.com/id{user_id} завершил сессию общения с оператором.")
|
||||||
@@ -1466,6 +1796,128 @@ class VKBotApplication:
|
|||||||
db.update_user_state(user_id, "FREE_CHAT")
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ---------- Напоминалки ----------
|
||||||
|
if state == "ADMIN_REMINDER_TZ_SETUP":
|
||||||
|
offset = self._parse_tz_offset(text)
|
||||||
|
if offset is None:
|
||||||
|
self.send_message(user_id, "⚠️ Выберите город кнопкой ниже, либо введите число "
|
||||||
|
"от -12 до +14 вручную, например +3 или -5.",
|
||||||
|
keyboard=get_timezone_keyboard())
|
||||||
|
return
|
||||||
|
db.set_admin_tz_offset(user_id, offset)
|
||||||
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
|
self.send_message(user_id, f"✅ Часовой пояс сохранён: UTC{offset:+d}.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_DEL_PICK":
|
||||||
|
rid = pick_id(text)
|
||||||
|
if not rid.isdigit() or not db.delete_reminder(int(rid)):
|
||||||
|
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
|
||||||
|
return
|
||||||
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
|
self.send_message(user_id, f"✅ Напоминание №{rid} удалено.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_PAUSE_PICK":
|
||||||
|
rid = pick_id(text)
|
||||||
|
rows_by_id = {r["id"]: r for r in db.get_active_reminders()}
|
||||||
|
if not rid.isdigit() or int(rid) not in rows_by_id:
|
||||||
|
self.send_message(user_id, "⚠️ Выберите напоминание кнопкой ниже.")
|
||||||
|
return
|
||||||
|
new_paused = not rows_by_id[int(rid)]["paused"]
|
||||||
|
db.set_reminder_paused(int(rid), new_paused)
|
||||||
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
f"✅ Напоминание №{rid} " +
|
||||||
|
("поставлено на паузу." if new_paused else "снято с паузы.")
|
||||||
|
)
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_DATE":
|
||||||
|
dt = parse_reminder_datetime(text)
|
||||||
|
if not dt:
|
||||||
|
self.send_message(user_id, "⚠️ Формат: ДД.ММ.ГГГГ ЧЧ:ММ (например, 15.03.2027 09:00)",
|
||||||
|
keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
admin_offset = db.get_admin_tz_offset(user_id)
|
||||||
|
if admin_offset is None:
|
||||||
|
admin_offset = 0
|
||||||
|
server_dt = convert_time(dt, admin_offset, server_utc_offset_hours())
|
||||||
|
if server_dt <= datetime.now():
|
||||||
|
self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.",
|
||||||
|
keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
self._admin_ctx[user_id] = {"remind_at": server_dt.strftime("%Y-%m-%d %H:%M:%S")}
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_TEXT")
|
||||||
|
self.send_message(user_id, "✏️ Введите текст напоминания:", keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_TEXT":
|
||||||
|
if not text.strip():
|
||||||
|
self.send_message(user_id, "⚠️ Текст не может быть пустым.", keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
self._admin_ctx.setdefault(user_id, {})["text"] = text.strip()
|
||||||
|
admins = self._all_admin_ids()
|
||||||
|
self._admin_ctx[user_id]["admins_list"] = admins
|
||||||
|
listing = "\n".join(f"{i + 1}. {self._admin_display(a)}" for i, a in enumerate(admins))
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_RECIPIENTS")
|
||||||
|
self.send_message(
|
||||||
|
user_id,
|
||||||
|
"👥 Кому отправлять напоминание?\n"
|
||||||
|
f"{listing}\n\n"
|
||||||
|
"Введите номер (например, 2), несколько через запятую (1,3) или 0 — все админы.",
|
||||||
|
keyboard=get_cancel_admin_keyboard()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_RECIPIENTS":
|
||||||
|
ctx = self._admin_ctx.get(user_id, {})
|
||||||
|
admins = ctx.get("admins_list", [])
|
||||||
|
raw = text.strip()
|
||||||
|
if raw == "0":
|
||||||
|
recipients = "all"
|
||||||
|
else:
|
||||||
|
picks = [p.strip() for p in raw.split(",") if p.strip()]
|
||||||
|
if not picks or not all(p.isdigit() for p in picks):
|
||||||
|
self.send_message(user_id, "⚠️ Введите номера через запятую или 0 для всех админов.",
|
||||||
|
keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
indices = [int(p) for p in picks]
|
||||||
|
if any(i < 1 or i > len(admins) for i in indices):
|
||||||
|
self.send_message(user_id, "⚠️ Есть номер вне списка. Проверьте и введите снова.",
|
||||||
|
keyboard=get_cancel_admin_keyboard())
|
||||||
|
return
|
||||||
|
recipients = sorted({admins[i - 1] for i in indices})
|
||||||
|
ctx["recipients"] = recipients
|
||||||
|
db.update_user_state(user_id, "ADMIN_REMINDER_PERIOD")
|
||||||
|
self.send_message(user_id, "🔁 Периодичность напоминания:", keyboard=get_reminder_period_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == "ADMIN_REMINDER_PERIOD":
|
||||||
|
recurrence = REMINDER_PERIOD_BY_LABEL.get(text_lower)
|
||||||
|
if not recurrence:
|
||||||
|
self.send_message(user_id, "⚠️ Выберите период кнопкой ниже.",
|
||||||
|
keyboard=get_reminder_period_keyboard())
|
||||||
|
return
|
||||||
|
ctx = self._admin_ctx.pop(user_id, {})
|
||||||
|
remind_at = ctx.get("remind_at")
|
||||||
|
rtext = ctx.get("text")
|
||||||
|
recipients = ctx.get("recipients")
|
||||||
|
if not (remind_at and rtext and recipients is not None):
|
||||||
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
|
self.send_message(user_id, "⚠️ Что-то пошло не так, начните заново кнопкой «➕ Добавить».")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
db.add_reminder(rtext, remind_at, recurrence, recipients, user_id)
|
||||||
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
|
self.send_message(user_id, "✅ Напоминание создано.")
|
||||||
|
self._send_reminders_admin(user_id)
|
||||||
|
return
|
||||||
|
|
||||||
# Неизвестное ADMIN_-состояние — сброс
|
# Неизвестное ADMIN_-состояние — сброс
|
||||||
self._admin_ctx.pop(user_id, None)
|
self._admin_ctx.pop(user_id, None)
|
||||||
db.update_user_state(user_id, "FREE_CHAT")
|
db.update_user_state(user_id, "FREE_CHAT")
|
||||||
@@ -1648,8 +2100,40 @@ class VKBotApplication:
|
|||||||
time.sleep(0.4) # троттлинг против лимитов VK
|
time.sleep(0.4) # троттлинг против лимитов VK
|
||||||
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
|
self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.")
|
||||||
|
|
||||||
|
def _check_due_reminders(self):
|
||||||
|
"""Проверяет и рассылает напоминания, чьё время наступило.
|
||||||
|
Вызывается фоновым потоком `_reminders_loop`, но выделена отдельно,
|
||||||
|
чтобы её можно было дёргать напрямую из тестов без запуска потока."""
|
||||||
|
now = datetime.now()
|
||||||
|
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
for r in db.get_due_reminders(now_str):
|
||||||
|
targets = self._all_admin_ids() if r["recipients"] == "all" else r["recipients"]
|
||||||
|
body = f"⏰ Напоминание:\n{r['text']}"
|
||||||
|
for uid in targets:
|
||||||
|
try:
|
||||||
|
self.send_message(uid, body)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось отправить напоминание {r['id']} пользователю {uid}: {e}")
|
||||||
|
if r["recurrence"] == "once":
|
||||||
|
db.deactivate_reminder(r["id"])
|
||||||
|
else:
|
||||||
|
anchor = datetime.strptime(r["remind_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
next_dt = compute_next_occurrence(anchor, r["recurrence"], now)
|
||||||
|
db.update_reminder_next_at(r["id"], next_dt.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
def _reminders_loop(self):
|
||||||
|
"""Фоновый цикл: раз в REMINDER_CHECK_INTERVAL_SEC секунд проверяет и шлёт напоминания.
|
||||||
|
Обрыв на одной итерации (например, сбой отправки) не должен останавливать цикл."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._check_due_reminders()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка обработки напоминаний: {e}")
|
||||||
|
time.sleep(REMINDER_CHECK_INTERVAL_SEC)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
|
"""Запуск бесконечного цикла прослушивания событий VK LongPoll."""
|
||||||
|
threading.Thread(target=self._reminders_loop, daemon=True).start()
|
||||||
if not self.longpoll:
|
if not self.longpoll:
|
||||||
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
|
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
|
||||||
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
|
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
|
||||||
@@ -1672,7 +2156,6 @@ class VKBotApplication:
|
|||||||
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
|
logger.info("Бот успешно запущен в режиме прослушивания LongPoll...")
|
||||||
# Старт-пинг администратору — лёгкий сигнal «бот жив»
|
# Старт-пинг администратору — лёгкий сигнal «бот жив»
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
|
||||||
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
|
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
for admin_id in getattr(config, "ADMIN_IDS", []):
|
for admin_id in getattr(config, "ADMIN_IDS", []):
|
||||||
self.send_message(admin_id, f"✅ Бот Кибершколы БАЗА запущен и на связи ({ts}).")
|
self.send_message(admin_id, f"✅ Бот Кибершколы БАЗА запущен и на связи ({ts}).")
|
||||||
@@ -1686,6 +2169,12 @@ class VKBotApplication:
|
|||||||
if event.type == VkBotEventType.MESSAGE_NEW:
|
if event.type == VkBotEventType.MESSAGE_NEW:
|
||||||
message_obj = event.obj.message
|
message_obj = event.obj.message
|
||||||
user_id = message_obj["from_id"]
|
user_id = message_obj["from_id"]
|
||||||
|
peer_id = message_obj.get("peer_id", user_id)
|
||||||
|
# Работаем только в личке. Сообщения из бесед/групповых чатов
|
||||||
|
# (peer_id != from_id) игнорируем — иначе бот шлёт людям в ЛС
|
||||||
|
# «не понял запрос» на каждое их сообщение в беседе.
|
||||||
|
if peer_id != user_id:
|
||||||
|
continue
|
||||||
text = message_obj["text"]
|
text = message_obj["text"]
|
||||||
attachments = message_obj.get("attachments", [])
|
attachments = message_obj.get("attachments", [])
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -23,14 +23,26 @@ def _load_env():
|
|||||||
|
|
||||||
_load_env()
|
_load_env()
|
||||||
|
|
||||||
|
|
||||||
|
def _int_env(key: str, default) -> int:
|
||||||
|
"""Безопасно читает целое из .env: пустое/кривое значение → default (без краха)."""
|
||||||
|
raw = os.getenv(key, None)
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return int(default)
|
||||||
|
try:
|
||||||
|
return int(str(raw).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return int(default)
|
||||||
|
|
||||||
|
|
||||||
# VK API Configuration
|
# VK API Configuration
|
||||||
# In production, these should be loaded from environment variables or a secure .env file.
|
# In production, these should be loaded from environment variables or a secure .env file.
|
||||||
VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here")
|
VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here") or "your_vk_community_token_here"
|
||||||
GROUP_ID = int(os.getenv("VK_GROUP_ID", "0")) # Community ID (must be integer)
|
GROUP_ID = _int_env("VK_GROUP_ID", 0) # Community ID (must be integer)
|
||||||
|
|
||||||
# Operator / Admin configuration
|
# Operator / Admin configuration
|
||||||
# ID of the user or chat where notifications about new leads and operator requests will be sent
|
# ID of the user or chat where notifications about new leads and operator requests will be sent
|
||||||
OPERATOR_PEER_ID = int(os.getenv("OPERATOR_PEER_ID", "12345678")) # Change to actual VK user ID of operator
|
OPERATOR_PEER_ID = _int_env("OPERATOR_PEER_ID", 12345678) # Change to actual VK user ID of operator
|
||||||
|
|
||||||
# Parse ADMIN_IDS as a list of integers from comma-separated env values
|
# Parse ADMIN_IDS as a list of integers from comma-separated env values
|
||||||
admin_ids_raw = os.getenv("ADMIN_IDS")
|
admin_ids_raw = os.getenv("ADMIN_IDS")
|
||||||
@@ -49,6 +61,11 @@ else:
|
|||||||
SUPER_ADMIN_IDS = [350793273]
|
SUPER_ADMIN_IDS = [350793273]
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# Экстренное отключение бота супер-админом командой /favi-stop.
|
||||||
|
# Хранится ТОЛЬКО хэш пароля (PBKDF2), не сам пароль. Задаётся в .env: FAVI_STOP_HASH.
|
||||||
|
# Сгенерировать новый хэш: см. README/USER_GUIDE. Пусто — команда отключена.
|
||||||
|
FAVI_STOP_HASH = os.getenv("FAVI_STOP_HASH", "")
|
||||||
|
|
||||||
# Ссылки на юридические документы (инлайн-кнопки в боте)
|
# Ссылки на юридические документы (инлайн-кнопки в боте)
|
||||||
OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf")
|
OFFER_URL = os.getenv("OFFER_URL", "https://infocyber.pro/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf")
|
||||||
POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf")
|
POLICY_URL = os.getenv("POLICY_URL", "https://infocyber.pro/form/Политика%20ПД.pdf")
|
||||||
@@ -64,8 +81,8 @@ else:
|
|||||||
OPERATOR_PEER_IDS = [OPERATOR_PEER_ID]
|
OPERATOR_PEER_IDS = [OPERATOR_PEER_ID]
|
||||||
|
|
||||||
# Часы работы (для статуса оператора). Вне этого интервала — «ответим утром».
|
# Часы работы (для статуса оператора). Вне этого интервала — «ответим утром».
|
||||||
WORK_HOUR_START = int(os.getenv("WORK_HOUR_START", "9"))
|
WORK_HOUR_START = _int_env("WORK_HOUR_START", 9)
|
||||||
WORK_HOUR_END = int(os.getenv("WORK_HOUR_END", "21"))
|
WORK_HOUR_END = _int_env("WORK_HOUR_END", 21)
|
||||||
|
|
||||||
# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки.
|
# Разрешённый источник (Origin) для CORS веб-формы. '*' допустим только для локальной отладки.
|
||||||
ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro")
|
ALLOWED_ORIGIN = os.getenv("ALLOWED_ORIGIN", "https://infocyber.pro")
|
||||||
@@ -76,7 +93,7 @@ PROMO_ENABLED = os.getenv("PROMO_ENABLED", "false").strip().lower() in ("1", "tr
|
|||||||
|
|
||||||
# Email (SMTP) configuration для уведомлений о заявках
|
# Email (SMTP) configuration для уведомлений о заявках
|
||||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
SMTP_PORT = _int_env("SMTP_PORT", 465)
|
||||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER
|
MAIL_FROM = os.getenv("MAIL_FROM", "") or SMTP_USER
|
||||||
@@ -121,6 +138,16 @@ LINK_LABELS = {
|
|||||||
"summer": "Летний интенсив CS2",
|
"summer": "Летний интенсив CS2",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Кнопки/разделы главного меню: показать/скрыть (админка) ---
|
||||||
|
# key: (человеческое название, показывать ли по умолчанию)
|
||||||
|
MENU_FEATURES = {
|
||||||
|
"pickup": ("Подобрать тренировку 🎯", True),
|
||||||
|
"intensive": ("Летний интенсив CS2 🔥", True),
|
||||||
|
"prices": ("Посмотреть цены 💰", True),
|
||||||
|
"question": ("Задать вопрос 💬", True),
|
||||||
|
"webinar": ("Записаться на вебинар 🎓", False),
|
||||||
|
}
|
||||||
|
|
||||||
# --- Триггеры запуска (регистронезависимо) ---
|
# --- Триггеры запуска (регистронезависимо) ---
|
||||||
MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
|
MAIN_TRIGGERS = ["начать", "тренировки", "занятия", "цена", "стоимость", "записаться",
|
||||||
"привет", "здравствуйте", "старт", "меню"]
|
"привет", "здравствуйте", "старт", "меню"]
|
||||||
|
|||||||
+147
@@ -183,6 +183,45 @@ class DatabaseManager:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Table for feature flags (показ/скрытие кнопок меню и разделов)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_flags (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL, -- якорная дата первого срабатывания, не меняется
|
||||||
|
next_at TEXT NOT NULL, -- следующее срабатывание, двигается вперёд
|
||||||
|
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
paused INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Миграция: колонка paused для уже существующих БД (созданных до этой фичи)
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Table for per-admin settings (пока только часовой пояс для напоминалок)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
tz_offset INTEGER NOT NULL, -- смещение от UTC в часах, напр. 3 для Москвы
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
|
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
|
||||||
@@ -212,6 +251,26 @@ class DatabaseManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# ---------- Фиче-флаги (показ/скрытие кнопок меню) ----------
|
||||||
|
def get_flag(self, key: str, default: bool = True) -> bool:
|
||||||
|
"""Включён ли раздел/кнопка. Если в БД нет записи — возвращает default."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT enabled FROM bot_flags WHERE key = ?", (key,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return bool(row[0]) if row is not None else default
|
||||||
|
|
||||||
|
def set_flag(self, key: str, enabled: bool):
|
||||||
|
"""Включить/выключить раздел или кнопку."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO bot_flags (key, enabled) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
(key, 1 if enabled else 0)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def get_user_data(self, user_id: int) -> Dict[str, Any]:
|
def get_user_data(self, user_id: int) -> Dict[str, Any]:
|
||||||
"""Retrieve user state and collected data."""
|
"""Retrieve user state and collected data."""
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
@@ -577,6 +636,94 @@ class DatabaseManager:
|
|||||||
cursor.execute("SELECT user_id FROM admins ORDER BY user_id")
|
cursor.execute("SELECT user_id FROM admins ORDER BY user_id")
|
||||||
return [r[0] for r in cursor.fetchall()]
|
return [r[0] for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
# --- Напоминалки для админов (дата/время, периодичность) ---
|
||||||
|
def add_reminder(self, text: str, remind_at: str, recurrence: str,
|
||||||
|
recipients, created_by: int) -> int:
|
||||||
|
"""recipients — список vk_id (list[int]) либо строка 'all'."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO reminders (text, remind_at, next_at, recurrence, recipients, created_by) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(text, remind_at, remind_at, recurrence, json.dumps(recipients), created_by)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def _rows_with_parsed_recipients(self, cursor) -> list:
|
||||||
|
rows = [dict(r) for r in cursor.fetchall()]
|
||||||
|
for r in rows:
|
||||||
|
r["recipients"] = json.loads(r["recipients"])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def get_active_reminders(self) -> list:
|
||||||
|
"""Активные напоминания для отображения в админке, по next_at."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
|
||||||
|
"FROM reminders WHERE active = 1 ORDER BY next_at"
|
||||||
|
)
|
||||||
|
return self._rows_with_parsed_recipients(cursor)
|
||||||
|
|
||||||
|
def get_due_reminders(self, now_str: str) -> list:
|
||||||
|
"""Активные напоминания (не на паузе), чьё время (next_at) уже наступило."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, text, remind_at, next_at, recurrence, recipients, created_by, paused "
|
||||||
|
"FROM reminders WHERE active = 1 AND paused = 0 AND next_at <= ?", (now_str,)
|
||||||
|
)
|
||||||
|
return self._rows_with_parsed_recipients(cursor)
|
||||||
|
|
||||||
|
def update_reminder_next_at(self, reminder_id: int, next_at: str):
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE reminders SET next_at = ? WHERE id = ?", (next_at, reminder_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def deactivate_reminder(self, reminder_id: int):
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE reminders SET active = 0 WHERE id = ?", (reminder_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def delete_reminder(self, reminder_id: int) -> bool:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def set_reminder_paused(self, reminder_id: int, paused: bool):
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("UPDATE reminders SET paused = ? WHERE id = ?",
|
||||||
|
(1 if paused else 0, reminder_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# --- Часовой пояс админов (для корректного показа/ввода времени напоминалок) ---
|
||||||
|
def get_admin_tz_offset(self, user_id: int) -> Optional[int]:
|
||||||
|
"""Смещение от UTC в часах, которое задал себе админ, или None, если ещё не задавал."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT tz_offset FROM admin_settings WHERE user_id = ?", (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
def set_admin_tz_offset(self, user_id: int, tz_offset: int):
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO admin_settings (user_id, tz_offset) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(user_id) DO UPDATE SET tz_offset = excluded.tz_offset, "
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
(user_id, tz_offset)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# --- Быстрые ответы (custom FAQ) ---
|
# --- Быстрые ответы (custom FAQ) ---
|
||||||
def add_custom_faq(self, keyword: str, answer: str) -> int:
|
def add_custom_faq(self, keyword: str, answer: str) -> int:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Запуск бота «Кибершкола БАЗА» в Docker.
|
||||||
|
#
|
||||||
|
# Быстрый старт (в папке с этим файлом):
|
||||||
|
# 1) Заполните .env (VK_TOKEN и VK_GROUP_ID — обязательно).
|
||||||
|
# 2) docker compose up -d --build # собрать и запустить в фоне
|
||||||
|
# 3) docker compose logs -f # смотреть логи (Ctrl+C — выйти из логов)
|
||||||
|
# Остановить: docker compose down
|
||||||
|
# Обновить код: git pull && docker compose up -d --build
|
||||||
|
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
image: baza-bot
|
||||||
|
container_name: baza-bot
|
||||||
|
restart: unless-stopped # авто-перезапуск при сбое и после ребута сервера
|
||||||
|
env_file:
|
||||||
|
- .env # настройки берутся из .env (токен, ID и пр.)
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/app/data/bot_database.db
|
||||||
|
volumes:
|
||||||
|
- baza-bot-data:/app/data # база данных сохраняется между перезапусками
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
baza-bot-data:
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
|||||||
|
# Настраиваемые напоминалки для админов — дизайн
|
||||||
|
|
||||||
|
Дата: 2026-09-02
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Админам нужно, чтобы бот сам напоминал о разовых или периодических событиях (например,
|
||||||
|
об окончании аренды домена/сервера): дата и время, текст, кому из админов отправить,
|
||||||
|
периодичность (разово / каждую неделю / каждый месяц / каждый год).
|
||||||
|
|
||||||
|
## Права доступа
|
||||||
|
|
||||||
|
Создавать, просматривать и удалять напоминания может **любой админ** (из `ADMIN_IDS`
|
||||||
|
или добавленный в рантайме, см. `_is_admin` в [bot.py](../../../bot.py)). Список
|
||||||
|
напоминаний — общий для всех админов (как общая доска задач), отдельного разграничения
|
||||||
|
«моё / чужое» нет.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
Новая таблица SQLite `reminders` (создаётся в `Database._init_db()` в
|
||||||
|
[database.py](../../../database.py), по образцу существующих `admins`/`custom_faq`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
remind_at TEXT NOT NULL, -- "YYYY-MM-DD HH:MM:SS", исходное (якорное) время
|
||||||
|
next_at TEXT NOT NULL, -- "YYYY-MM-DD HH:MM:SS", следующее срабатывание
|
||||||
|
recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly'
|
||||||
|
recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all"
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`remind_at` — неизменное якорное время первого срабатывания (день месяца/год для
|
||||||
|
пересчёта периодов), `next_at` — то, что реально проверяет планировщик и что двигается
|
||||||
|
вперёд после каждой отправки.
|
||||||
|
|
||||||
|
CRUD-методы в `Database` (по аналогии с `add_admin`/`remove_admin`/`get_admins`):
|
||||||
|
`add_reminder(...)`, `get_active_reminders()`, `get_due_reminders(now_str)`,
|
||||||
|
`update_reminder_next_at(id, next_at)`, `deactivate_reminder(id)`, `delete_reminder(id)`.
|
||||||
|
|
||||||
|
## Планировщик
|
||||||
|
|
||||||
|
Отдельный фоновый поток `threading.Thread(target=self._reminders_loop, daemon=True)`,
|
||||||
|
запускается в `run()` рядом с уже существующими фоновыми потоками (прогрев медиа,
|
||||||
|
рассылка). Цикл:
|
||||||
|
|
||||||
|
```
|
||||||
|
while True:
|
||||||
|
now = datetime.now() # локальное серверное время, как везде в проекте (без TZ-логики)
|
||||||
|
for r in db.get_due_reminders(now):
|
||||||
|
отправить текст всем recipients (self.send_message)
|
||||||
|
if r.recurrence == 'once':
|
||||||
|
db.deactivate_reminder(r.id)
|
||||||
|
else:
|
||||||
|
next_at = compute_next(r.remind_at, r.recurrence, after=now)
|
||||||
|
db.update_reminder_next_at(r.id, next_at)
|
||||||
|
time.sleep(60)
|
||||||
|
```
|
||||||
|
|
||||||
|
`compute_next` считает **следующее срабатывание строго после now**, отталкиваясь от
|
||||||
|
якорной даты `remind_at` (а не от предыдущего `next_at`) — так периодичность не
|
||||||
|
дрейфует из-за клампинга коротких месяцев:
|
||||||
|
|
||||||
|
- `weekly`: `remind_at + 7*N дней`, ближайшее значение `> now`;
|
||||||
|
- `monthly`: тот же день месяца, что и в `remind_at`; если в целевом месяце такого дня
|
||||||
|
нет — последний день этого месяца (31.01 → 28/29.02 → 31.03, а не 28.03);
|
||||||
|
- `yearly`: тот же день/месяц; 29 февраля в невисокосный год → 28 февраля.
|
||||||
|
|
||||||
|
**Пропуск при простое бота**: если бот был выключен и время срабатывания уже прошло —
|
||||||
|
при первом же тике после старта напоминание досылается один раз, после чего сразу
|
||||||
|
пересчитывается следующее *будущее* срабатывание (не досылается «пачка» пропущенных
|
||||||
|
периодов).
|
||||||
|
|
||||||
|
## UI — мастер в админ-панели
|
||||||
|
|
||||||
|
Новая кнопка «⏰ Напоминалки» в `get_admin_keyboard` ([keyboards.py](../../../keyboards.py)).
|
||||||
|
|
||||||
|
**Список** (`_send_reminders_admin`): для каждого активного напоминания — id, дата
|
||||||
|
следующего срабатывания, периодичность, текст (обрезанный превью), получатели.
|
||||||
|
Плюс инструкция: «напоминалка добавить» / «напоминалка удалить <id>».
|
||||||
|
|
||||||
|
**Добавление** — пошаговый мастер состояний (по образцу `ADMIN_FAQ_ADD` в
|
||||||
|
[bot.py](../../../bot.py)):
|
||||||
|
|
||||||
|
1. `ADMIN_REMINDER_DATE` — ввод `ДД.ММ.ГГГГ ЧЧ:ММ`, валидация формата и что дата не в
|
||||||
|
прошлом;
|
||||||
|
2. `ADMIN_REMINDER_TEXT` — текст напоминания;
|
||||||
|
3. `ADMIN_REMINDER_RECIPIENTS` — нумерованный список текущих админов + пункт
|
||||||
|
«0 — все админы»; можно ввести несколько номеров через запятую (например «1,3»);
|
||||||
|
4. `ADMIN_REMINDER_PERIOD` — кнопки «Разово / Каждую неделю / Каждый месяц / Каждый год»;
|
||||||
|
5. подтверждение и запись в БД (`add_reminder`), возврат к списку напоминаний.
|
||||||
|
|
||||||
|
Отмена мастера на любом шаге — существующим паттерном («отмена»/кнопка отмены →
|
||||||
|
`get_cancel_admin_keyboard`).
|
||||||
|
|
||||||
|
**Удаление** — по id, текстовой командой «напоминалка удалить <id>», как удаление
|
||||||
|
админа (`db.delete_reminder`).
|
||||||
|
|
||||||
|
**Сознательное упрощение (YAGNI)**: редактирования существующего напоминания нет —
|
||||||
|
только добавить/удалить, как сейчас устроено управление списком админов. Чтобы
|
||||||
|
поправить напоминание — удалить и создать заново.
|
||||||
|
|
||||||
|
## Изменяемые файлы
|
||||||
|
|
||||||
|
- [database.py](../../../database.py) — таблица `reminders` + CRUD-методы.
|
||||||
|
- [bot.py](../../../bot.py) — мастер `ADMIN_REMINDER_*`, обработчики команд, фоновый
|
||||||
|
поток-планировщик, запуск потока в `run()`.
|
||||||
|
- [keyboards.py](../../../keyboards.py) — кнопка «⏰ Напоминалки» в админ-клавиатуре,
|
||||||
|
клавиатура выбора периодичности.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Юнит-тесты в `tests/` (по образцу `tests/test_database.py`):
|
||||||
|
- CRUD напоминаний в БД;
|
||||||
|
- расчёт `compute_next` для всех 3 периодичностей, включая крайние случаи (31 число,
|
||||||
|
29 февраля);
|
||||||
|
- логика «пропущенного» напоминания при простое (одна отправка + пересчёт в будущее).
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Напоминалки: пауза и часовой пояс — дизайн
|
||||||
|
|
||||||
|
Дата: 2026-09-05
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Фича «Напоминалки» (см. [2026-09-02-admin-reminders-design.md](2026-09-02-admin-reminders-design.md))
|
||||||
|
уже работает, но не хватает двух вещей:
|
||||||
|
|
||||||
|
1. Нельзя временно отключить напоминание без удаления (например, аренду продлили
|
||||||
|
раньше срока, но напоминание пригодится в следующий раз).
|
||||||
|
2. Дата/время вводятся и хранятся в серверном времени без учёта того, что
|
||||||
|
настраивающий напоминание админ может физически находиться в другом часовом
|
||||||
|
поясе — время срабатывания может «уехать» на несколько часов от того, что
|
||||||
|
админ имел в виду.
|
||||||
|
|
||||||
|
## Часовой пояс
|
||||||
|
|
||||||
|
### Хранение
|
||||||
|
|
||||||
|
Новая таблица SQLite `admin_settings`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
tz_offset INTEGER NOT NULL, -- смещение от UTC в часах, напр. 3 для Москвы
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Диапазон значений: −12..+14 (реальные часовые пояса Земли).
|
||||||
|
|
||||||
|
### Гейт при входе в раздел
|
||||||
|
|
||||||
|
Любое обращение к разделу «⏰ Напоминалки» (просмотр списка, «➕ Добавить»,
|
||||||
|
«🗑 Удалить», «⏸/▶ Пауза») сначала проверяет `db.get_admin_tz_offset(user_id)`.
|
||||||
|
Если пояс не задан — бот переводит админа в состояние `ADMIN_REMINDER_TZ_SETUP`
|
||||||
|
и просит ввести смещение («Ваш часовой пояс относительно UTC, например +3 —
|
||||||
|
Москва, +5 — Екатеринбург, 0 — UTC»). После сохранения бот показывает то, за
|
||||||
|
чем админ изначально пришёл (список напоминаний) — без необходимости повторно
|
||||||
|
жать исходную кнопку.
|
||||||
|
|
||||||
|
Пояс можно сменить позже в любой момент командой `часовой пояс <±N>`.
|
||||||
|
|
||||||
|
### Конвертация при создании (вход → сервер)
|
||||||
|
|
||||||
|
Дата/время, введённые автором в шаге `ADMIN_REMINDER_DATE`, интерпретируются
|
||||||
|
как его локальное время и переводятся в серверное перед сохранением:
|
||||||
|
|
||||||
|
```
|
||||||
|
server_dt = admin_dt + timedelta(hours=(server_utc_offset - author_tz_offset))
|
||||||
|
```
|
||||||
|
|
||||||
|
где `server_utc_offset = datetime.now().astimezone().utcoffset().total_seconds() / 3600`
|
||||||
|
(собственное смещение сервера от UTC, вычисляется на лету — часовой пояс сервера
|
||||||
|
нигде явно не настроен и может быть любым).
|
||||||
|
|
||||||
|
`remind_at`/`next_at` в БД как хранили серверное время, так и хранят —
|
||||||
|
**планировщик (`_check_due_reminders`) и расчёт периодичности
|
||||||
|
(`compute_next_occurrence`) не меняются вообще.**
|
||||||
|
|
||||||
|
### Отображение в общем списке (сервер → зритель)
|
||||||
|
|
||||||
|
Список напоминаний общий для всех админов. Время каждой строки пересчитывается
|
||||||
|
под часовой пояс **того, кто сейчас смотрит список** (используя его собственный
|
||||||
|
`tz_offset`, который уже гарантированно задан благодаря гейту):
|
||||||
|
|
||||||
|
```
|
||||||
|
viewer_dt = server_dt + timedelta(hours=(viewer_tz_offset - server_utc_offset))
|
||||||
|
```
|
||||||
|
|
||||||
|
Рядом с временем — пометка `(UTC+N)`. В тексте раздела — короткая приписка:
|
||||||
|
«ℹ️ Чтобы время в списке отображалось верно у всех, каждому нужно один раз
|
||||||
|
зайти сюда и указать свой часовой пояс».
|
||||||
|
|
||||||
|
**Важно**: срабатывание напоминания НЕ зависит от того, установили ли
|
||||||
|
получатели свой часовой пояс — момент отправки уже зафиксирован в серверном
|
||||||
|
времени при создании. Часовой пояс получателей влияет только на то, как ОНИ
|
||||||
|
видят время в списке, когда сами зайдут в раздел.
|
||||||
|
|
||||||
|
### Общие функции (в `reminders.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def server_utc_offset_hours() -> float:
|
||||||
|
"""Собственное смещение сервера от UTC в часах (для конвертации между
|
||||||
|
часовым поясом админа и серверным временем хранения)."""
|
||||||
|
|
||||||
|
def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
|
||||||
|
"""Переводит наивный datetime из одного часового пояса (смещение от UTC
|
||||||
|
в часах) в другой."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пауза
|
||||||
|
|
||||||
|
### Хранение
|
||||||
|
|
||||||
|
В таблицу `reminders` добавляется колонка:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE reminders ADD COLUMN paused INTEGER NOT NULL DEFAULT 0
|
||||||
|
```
|
||||||
|
|
||||||
|
(миграция через `try/except sqlite3.OperationalError`, как другие миграции в
|
||||||
|
`database.py`, чтобы не ломать уже существующую БД).
|
||||||
|
|
||||||
|
### Логика
|
||||||
|
|
||||||
|
`get_due_reminders` добавляет условие `AND paused = 0` — напоминание на паузе
|
||||||
|
не попадает в выборку планировщика, но остаётся в `get_active_reminders` для
|
||||||
|
отображения и управления.
|
||||||
|
|
||||||
|
Новые методы `DatabaseManager`: `set_reminder_paused(reminder_id: int, paused: bool)`.
|
||||||
|
|
||||||
|
**Возобновление не требует пересчёта дат** — просто снимает флаг `paused`.
|
||||||
|
Если время уже прошло, пока напоминание было на паузе, срабатывает то же
|
||||||
|
правило, что и при простое бота (уже реализовано в Task 5): разовое — пришлёт
|
||||||
|
один раз при ближайшей проверке и деактивируется; периодическое — пришлёт раз
|
||||||
|
и само пересчитает следующее срабатывание в будущее (`compute_next_occurrence`
|
||||||
|
уже умеет пропускать пропущенные периоды). Никакой новой логики для этого не
|
||||||
|
требуется.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Одна кнопка **«⏸/▶ Пауза»** в разделе «Напоминалки» (кнопка добавляется в
|
||||||
|
`get_reminders_admin_keyboard`). Открывает список выбора (как удаление, через
|
||||||
|
`get_pick_keyboard`), где подпись каждого пункта отражает текущее состояние:
|
||||||
|
|
||||||
|
- активное → `"⏸ Поставить на паузу: <текст>"`
|
||||||
|
- на паузе → `"▶ Возобновить: <текст>"`
|
||||||
|
|
||||||
|
Выбор пункта переключает флаг на противоположный. Новое состояние мастера:
|
||||||
|
`ADMIN_REMINDER_PAUSE_PICK`.
|
||||||
|
|
||||||
|
В списке напоминаний (`_send_reminders_admin`) у напоминаний на паузе —
|
||||||
|
пометка `⏸ (на паузе)` перед текстом.
|
||||||
|
|
||||||
|
## Изменяемые файлы
|
||||||
|
|
||||||
|
- [database.py](../../../database.py) — таблица `admin_settings` + CRUD
|
||||||
|
(`get_admin_tz_offset`, `set_admin_tz_offset`); колонка `paused` в
|
||||||
|
`reminders` + `set_reminder_paused`; фильтр `paused = 0` в `get_due_reminders`.
|
||||||
|
- [reminders.py](../../../reminders.py) — `server_utc_offset_hours()`,
|
||||||
|
`convert_time()`.
|
||||||
|
- [bot.py](../../../bot.py) — гейт часового пояса перед разделом «Напоминалки»,
|
||||||
|
состояние `ADMIN_REMINDER_TZ_SETUP`, конвертация в `ADMIN_REMINDER_DATE`,
|
||||||
|
пересчёт для отображения в `_send_reminders_admin`, команда
|
||||||
|
`часовой пояс <±N>`, кнопка и состояние `ADMIN_REMINDER_PAUSE_PICK`.
|
||||||
|
- [keyboards.py](../../../keyboards.py) — кнопка «⏸/▶ Пауза» в
|
||||||
|
`get_reminders_admin_keyboard`.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- `tests/test_reminders.py`: `server_utc_offset_hours` (проверка типа/разумного
|
||||||
|
диапазона −12..14), `convert_time` для нескольких смещений и границы суток.
|
||||||
|
- `tests/test_database.py`: CRUD `admin_settings`; `paused` — установка,
|
||||||
|
фильтрация в `get_due_reminders`, попадание в `get_active_reminders`.
|
||||||
|
- `tests/test_flow.py`: гейт часового пояса при первом входе в раздел (список
|
||||||
|
не показывается, пока пояс не задан); конвертация введённой даты в серверное
|
||||||
|
время при известном смещении; пауза/возобновление через мастер меняет
|
||||||
|
фактическую отправку (`_check_due_reminders` не шлёт напоминание на паузе,
|
||||||
|
шлёт после возобновления).
|
||||||
+92
-12
@@ -7,18 +7,34 @@ import config
|
|||||||
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
|
# КЛАВИАТУРЫ ДЛЯ ИНТЕРФЕЙСА (UX/UI ВКонтакте)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def get_main_keyboard(is_admin: bool = False) -> str:
|
def get_main_keyboard(is_admin: bool = False, flags: dict = None) -> str:
|
||||||
"""Главное меню Кибершколы БАЗА. Для админа добавляется кнопка «Админка ⚙️»."""
|
"""Главное меню Кибершколы БАЗА. flags — какие кнопки показывать (по умолчанию — все,
|
||||||
|
кроме вебинара). Для админа добавляется кнопка «Админка ⚙️»."""
|
||||||
|
f = {"pickup": True, "intensive": True, "prices": True, "question": True, "webinar": False}
|
||||||
|
if flags:
|
||||||
|
f.update(flags)
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
keyboard.add_button("Подобрать тренировку 🎯", color=VkKeyboardColor.PRIMARY)
|
has = False
|
||||||
|
|
||||||
|
def row(label, color=VkKeyboardColor.SECONDARY):
|
||||||
|
nonlocal has
|
||||||
|
if has:
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
keyboard.add_button("Летний интенсив CS2 🔥", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button(label, color=color)
|
||||||
keyboard.add_line()
|
has = True
|
||||||
keyboard.add_button("Посмотреть цены 💰", color=VkKeyboardColor.SECONDARY)
|
|
||||||
keyboard.add_button("Задать вопрос 💬", color=VkKeyboardColor.SECONDARY)
|
if f.get("pickup"):
|
||||||
|
row("Подобрать тренировку 🎯", VkKeyboardColor.PRIMARY)
|
||||||
|
if f.get("intensive"):
|
||||||
|
row("Летний интенсив CS2 🔥")
|
||||||
|
if f.get("webinar"):
|
||||||
|
row("Записаться на вебинар 🎓")
|
||||||
|
if f.get("prices"):
|
||||||
|
row("Посмотреть цены 💰")
|
||||||
|
if f.get("question"):
|
||||||
|
row("Задать вопрос 💬")
|
||||||
if is_admin:
|
if is_admin:
|
||||||
keyboard.add_line()
|
row("Админка ⚙️", VkKeyboardColor.POSITIVE)
|
||||||
keyboard.add_button("Админка ⚙️", color=VkKeyboardColor.POSITIVE)
|
|
||||||
return keyboard.get_keyboard()
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
@@ -34,15 +50,20 @@ def _kb(rows, back=False, ask_manager=False, back_menu=True):
|
|||||||
back_menu — кнопка «В главное меню ↩️».
|
back_menu — кнопка «В главное меню ↩️».
|
||||||
"""
|
"""
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
for i, row in enumerate(rows):
|
has = False # была ли уже добавлена хоть одна кнопка (чтобы не создавать пустой ряд)
|
||||||
if i:
|
for row in rows:
|
||||||
|
if has:
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
for label in row:
|
for label in row:
|
||||||
keyboard.add_button(label, color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button(label, color=VkKeyboardColor.SECONDARY)
|
||||||
|
has = True
|
||||||
if ask_manager:
|
if ask_manager:
|
||||||
|
if has:
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
keyboard.add_button("Задать вопрос менеджеру 💬", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Задать вопрос менеджеру 💬", color=VkKeyboardColor.SECONDARY)
|
||||||
|
has = True
|
||||||
if back or back_menu:
|
if back or back_menu:
|
||||||
|
if has:
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
if back:
|
if back:
|
||||||
keyboard.add_button("Назад ⬅️", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Назад ⬅️", color=VkKeyboardColor.SECONDARY)
|
||||||
@@ -142,7 +163,7 @@ def get_baza_prices_keyboard() -> str:
|
|||||||
return keyboard.get_keyboard()
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
def get_admin_keyboard(is_super: bool = False) -> str:
|
def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str:
|
||||||
"""Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы»."""
|
"""Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы»."""
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY)
|
||||||
@@ -151,6 +172,12 @@ def get_admin_keyboard(is_super: bool = False) -> 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.SECONDARY)
|
||||||
|
keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔",
|
||||||
|
color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("⏰ Напоминалки", color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_line()
|
||||||
if is_super:
|
if is_super:
|
||||||
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY)
|
keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY)
|
||||||
keyboard.add_line()
|
keyboard.add_line()
|
||||||
@@ -170,6 +197,45 @@ def get_faq_admin_keyboard() -> str:
|
|||||||
return keyboard.get_keyboard()
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
|
def get_reminders_admin_keyboard() -> str:
|
||||||
|
"""Клавиатура раздела «Напоминалки». Обычная (не инлайн) клавиатура — важно:
|
||||||
|
она заменяет собой нижнюю клавиатуру предыдущего шага мастера (например,
|
||||||
|
«Разово / Каждую неделю / …»), иначе старые кнопки остаются висеть после
|
||||||
|
завершения мастера."""
|
||||||
|
keyboard = VkKeyboard(one_time=False)
|
||||||
|
keyboard.add_button("➕ Добавить", color=VkKeyboardColor.PRIMARY)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("🗑 Удалить напоминание", color=VkKeyboardColor.NEGATIVE)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("⏸/▶ Пауза", color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("🌐 Часовой пояс", color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY)
|
||||||
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone_keyboard() -> str:
|
||||||
|
"""Клавиатура выбора часового пояса: частые российские города-пояса
|
||||||
|
кнопками (кнопка шлёт 'Город, UTC±N' — _parse_tz_offset вытаскивает
|
||||||
|
смещение из этого текста), плюс можно просто ввести своё смещение
|
||||||
|
вручную, если города в списке нет."""
|
||||||
|
cities = [
|
||||||
|
("Калининград", 2), ("Москва", 3), ("Самара", 4),
|
||||||
|
("Екатеринбург", 5), ("Омск", 6), ("Красноярск", 7),
|
||||||
|
("Иркутск", 8), ("Якутск", 9), ("Владивосток", 10),
|
||||||
|
("Магадан", 11), ("Камчатка", 12),
|
||||||
|
]
|
||||||
|
keyboard = VkKeyboard(one_time=False)
|
||||||
|
for i, (city, offset) in enumerate(cities):
|
||||||
|
if i and i % 2 == 0:
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button(f"{city}, UTC+{offset}", color=VkKeyboardColor.SECONDARY)
|
||||||
|
keyboard.add_line()
|
||||||
|
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
|
||||||
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
def get_pick_keyboard(items: list) -> str:
|
def get_pick_keyboard(items: list) -> str:
|
||||||
"""Клавиатура выбора из списка пар (id, label). Кнопка отправляет '<id>. <label>'."""
|
"""Клавиатура выбора из списка пар (id, label). Кнопка отправляет '<id>. <label>'."""
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
@@ -192,6 +258,20 @@ def get_cancel_admin_keyboard() -> str:
|
|||||||
return keyboard.get_keyboard()
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
|
def get_reminder_period_keyboard() -> str:
|
||||||
|
"""Клавиатура выбора периодичности напоминания (шаг мастера ADMIN_REMINDER_PERIOD)."""
|
||||||
|
keyboard = VkKeyboard(one_time=False)
|
||||||
|
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_line()
|
||||||
|
keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE)
|
||||||
|
return keyboard.get_keyboard()
|
||||||
|
|
||||||
|
|
||||||
def get_operator_keyboard() -> str:
|
def get_operator_keyboard() -> str:
|
||||||
"""Клавиатура при активном диалоге с оператором."""
|
"""Клавиатура при активном диалоге с оператором."""
|
||||||
keyboard = VkKeyboard(one_time=False)
|
keyboard = VkKeyboard(one_time=False)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Расчёт дат для напоминалок админов: парсинг ввода и следующее срабатывание.
|
||||||
|
|
||||||
|
Не зависит от bot.py/database.py — чистая логика дат, легко тестируется отдельно.
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DT_FORMAT = "%Y-%m-%d %H:%M:%S" # формат хранения в БД
|
||||||
|
INPUT_FORMAT = "%d.%m.%Y %H:%M" # формат ввода админом
|
||||||
|
|
||||||
|
RECURRENCES = ("once", "weekly", "monthly", "yearly")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reminder_datetime(text: str) -> Optional[datetime]:
|
||||||
|
"""Парсит дату/время в формате ДД.ММ.ГГГГ ЧЧ:ММ. None, если формат неверный."""
|
||||||
|
text = text.strip()
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, INPUT_FORMAT)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_day(year: int, month: int, day: int) -> int:
|
||||||
|
"""День месяца, не превышающий число дней в этом месяце
|
||||||
|
(31.01 -> 28/29.02, 31.03 и т.п. — без ползучего дрейфа, см. compute_next_occurrence)."""
|
||||||
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
|
return min(day, last_day)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_next_occurrence(anchor: datetime, recurrence: str, after: datetime) -> datetime:
|
||||||
|
"""Следующее срабатывание строго после `after`, отталкиваясь от якорной даты `anchor`
|
||||||
|
(а не от предыдущего срабатывания) — так периодичность не «дрейфует» из-за клампинга
|
||||||
|
коротких месяцев. Если пропущено несколько периодов (бот был выключен) — промежуточные
|
||||||
|
пропускаются, возвращается ближайшее будущее относительно `after`.
|
||||||
|
"""
|
||||||
|
if recurrence not in RECURRENCES:
|
||||||
|
raise ValueError(f"Неизвестная периодичность: {recurrence}")
|
||||||
|
|
||||||
|
if recurrence == "weekly":
|
||||||
|
candidate = anchor
|
||||||
|
while candidate <= after:
|
||||||
|
candidate += timedelta(days=7)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
if recurrence == "monthly":
|
||||||
|
year, month = anchor.year, anchor.month
|
||||||
|
candidate = anchor
|
||||||
|
while candidate <= after:
|
||||||
|
month += 1
|
||||||
|
if month > 12:
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
day = _clamp_day(year, month, anchor.day)
|
||||||
|
candidate = candidate.replace(year=year, month=month, day=day)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# yearly
|
||||||
|
year = anchor.year
|
||||||
|
candidate = anchor
|
||||||
|
while candidate <= after:
|
||||||
|
year += 1
|
||||||
|
day = _clamp_day(year, anchor.month, anchor.day)
|
||||||
|
candidate = candidate.replace(year=year, day=day)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def server_utc_offset_hours() -> float:
|
||||||
|
"""Собственное смещение сервера от UTC в часах. Часовой пояс сервера нигде
|
||||||
|
явно не настроен (может быть любым в зависимости от хостинга), поэтому
|
||||||
|
вычисляется на лету через локальную таймзону ОС."""
|
||||||
|
offset = datetime.now().astimezone().utcoffset()
|
||||||
|
return offset.total_seconds() / 3600 if offset else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def convert_time(dt: datetime, from_offset: float, to_offset: float) -> datetime:
|
||||||
|
"""Переводит наивный datetime из одного часового пояса (смещение от UTC
|
||||||
|
в часах) в другой — просто сдвигает время на разницу смещений."""
|
||||||
|
return dt + timedelta(hours=(to_offset - from_offset))
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
vk_api>=11.9
|
vk_api>=11.9
|
||||||
requests>=2.28
|
requests>=2.28
|
||||||
openpyxl>=3.1
|
|
||||||
Pillow>=10.0
|
|
||||||
|
|||||||
@@ -56,3 +56,53 @@ def test_marketing_subscribers(tmp_db):
|
|||||||
tmp_db.update_user_field(11, "consent_2", "Не согласен")
|
tmp_db.update_user_field(11, "consent_2", "Не согласен")
|
||||||
subs = tmp_db.get_marketing_subscribers()
|
subs = tmp_db.get_marketing_subscribers()
|
||||||
assert 10 in subs and 11 not in subs
|
assert 10 in subs and 11 not in subs
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminders_crud(tmp_db):
|
||||||
|
rid = tmp_db.add_reminder("Продлить домен", "2027-03-15 09:00:00", "yearly", [1, 2], created_by=1)
|
||||||
|
active = tmp_db.get_active_reminders()
|
||||||
|
assert any(r["id"] == rid for r in active)
|
||||||
|
r = next(r for r in active if r["id"] == rid)
|
||||||
|
assert r["recipients"] == [1, 2] and r["recurrence"] == "yearly"
|
||||||
|
|
||||||
|
due = tmp_db.get_due_reminders("2027-03-15 09:00:00")
|
||||||
|
assert any(r["id"] == rid for r in due)
|
||||||
|
assert tmp_db.get_due_reminders("2027-03-14 09:00:00") == []
|
||||||
|
|
||||||
|
tmp_db.update_reminder_next_at(rid, "2028-03-15 09:00:00")
|
||||||
|
r2 = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert r2["next_at"] == "2028-03-15 09:00:00"
|
||||||
|
|
||||||
|
tmp_db.deactivate_reminder(rid)
|
||||||
|
assert not any(r["id"] == rid for r in tmp_db.get_active_reminders())
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminders_all_recipients_and_delete(tmp_db):
|
||||||
|
rid = tmp_db.add_reminder("Оплатить сервер", "2027-01-01 09:00:00", "once", "all", created_by=1)
|
||||||
|
r = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert r["recipients"] == "all"
|
||||||
|
assert tmp_db.delete_reminder(rid)
|
||||||
|
assert not tmp_db.delete_reminder(rid) # уже удалено, повторное удаление — False
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_tz_offset(tmp_db):
|
||||||
|
assert tmp_db.get_admin_tz_offset(999) is None
|
||||||
|
tmp_db.set_admin_tz_offset(999, 3)
|
||||||
|
assert tmp_db.get_admin_tz_offset(999) == 3
|
||||||
|
tmp_db.set_admin_tz_offset(999, -5) # перезапись существующего значения
|
||||||
|
assert tmp_db.get_admin_tz_offset(999) == -5
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_pause_excludes_from_due_but_stays_visible(tmp_db):
|
||||||
|
rid = tmp_db.add_reminder("Продлить домен", "2020-01-01 09:00:00", "once", "all", created_by=1)
|
||||||
|
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # просрочено — должно найтись
|
||||||
|
|
||||||
|
tmp_db.set_reminder_paused(rid, True)
|
||||||
|
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") == [] # на паузе — не должно
|
||||||
|
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert row["paused"] == 1 # но в общем списке всё ещё видно
|
||||||
|
|
||||||
|
tmp_db.set_reminder_paused(rid, False)
|
||||||
|
assert tmp_db.get_due_reminders("2020-01-01 09:00:00") # сняли паузу — снова попадает в due
|
||||||
|
row = next(r for r in tmp_db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert row["paused"] == 0
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import bot
|
import bot
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
def _state(uid):
|
def _state(uid):
|
||||||
@@ -74,6 +75,18 @@ def test_baza_records_lead_once(app):
|
|||||||
assert any("Групповой" in name for name, _ in s["by_service"])
|
assert any("Групповой" in name for name, _ in s["by_service"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_message_ignored(app):
|
||||||
|
"""Пустые сообщения (стикер/фото/голосовое) не вызывают фолбэк — бот молчит."""
|
||||||
|
u = 70060
|
||||||
|
app._sent.clear()
|
||||||
|
app.handle_message(u, "")
|
||||||
|
app.handle_message(u, " ")
|
||||||
|
assert app._sent == []
|
||||||
|
# обычный нераспознанный текст фолбэк всё же даёт
|
||||||
|
app.handle_message(u, "абвгдейка")
|
||||||
|
assert any("Пока не получилось" in s["msg"] for s in app._sent)
|
||||||
|
|
||||||
|
|
||||||
def test_baza_question_ticket(app):
|
def test_baza_question_ticket(app):
|
||||||
"""§9: клиенту и в карточку оператору уходит номер заявки (тикет)."""
|
"""§9: клиенту и в карточку оператору уходит номер заявки (тикет)."""
|
||||||
u = 70040
|
u = 70040
|
||||||
@@ -88,6 +101,7 @@ def test_baza_question_ticket(app):
|
|||||||
def test_webinar_registration(app):
|
def test_webinar_registration(app):
|
||||||
"""Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно)."""
|
"""Ветка вебинара: регистрация, метка, остаётся в боте (меню доступно)."""
|
||||||
u = 70041
|
u = 70041
|
||||||
|
bot.db.set_flag("webinar", True) # вебинар по умолчанию скрыт — включаем
|
||||||
app.handle_message(u, "записаться на вебинар")
|
app.handle_message(u, "записаться на вебинар")
|
||||||
assert "webinar_registered" in bot.db.get_profile(u).get("metki", [])
|
assert "webinar_registered" in bot.db.get_profile(u).get("metki", [])
|
||||||
assert _state(u) == "FREE_CHAT"
|
assert _state(u) == "FREE_CHAT"
|
||||||
@@ -213,6 +227,101 @@ def test_admin_edit_link(app):
|
|||||||
assert app._link("group_4") == "https://infocyber.pro/pay/group4"
|
assert app._link("group_4") == "https://infocyber.pro/pay/group4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_vk_ru_and_com_links(app):
|
||||||
|
"""Резолвер принимает ссылки vk.ru и vk.com (id-форма работает без сети)."""
|
||||||
|
assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273
|
||||||
|
assert app._resolve_vk_user_id("https://vk.com/id777") == 777
|
||||||
|
assert app._resolve_vk_user_id("http://m.vk.ru/id42") == 42
|
||||||
|
assert app._resolve_vk_user_id("www.vk.com/id5") == 5
|
||||||
|
assert app._resolve_vk_user_id("id99") == 99
|
||||||
|
assert app._resolve_vk_user_id("12345") == 12345
|
||||||
|
|
||||||
|
|
||||||
|
def test_funnel_keyboards_no_empty_rows():
|
||||||
|
"""VK отклоняет клавиатуру с пустым рядом — таких быть не должно ни в одной."""
|
||||||
|
import json, keyboards
|
||||||
|
kbs = [
|
||||||
|
keyboards.get_baza_menu_only_keyboard(), keyboards.get_baza_forwho_keyboard(),
|
||||||
|
keyboards.get_baza_age_keyboard(), keyboards.get_baza_game_keyboard(),
|
||||||
|
keyboards.get_baza_level_keyboard(), keyboards.get_baza_format_keyboard(),
|
||||||
|
keyboards.get_baza_group_keyboard(), keyboards.get_baza_individual_keyboard(),
|
||||||
|
keyboards.get_baza_int_level_keyboard(),
|
||||||
|
]
|
||||||
|
for kb in kbs:
|
||||||
|
for row in json.loads(kb)["buttons"]:
|
||||||
|
assert len(row) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_vk_links(app):
|
||||||
|
"""Ссылки vk.ru и vk.com (и голый id) корректно приводятся к числовому id."""
|
||||||
|
assert app._resolve_vk_user_id("https://vk.ru/id350793273") == 350793273
|
||||||
|
assert app._resolve_vk_user_id("https://vk.com/id777") == 777
|
||||||
|
assert app._resolve_vk_user_id("vk.ru/id42") == 42
|
||||||
|
assert app._resolve_vk_user_id("id100") == 100
|
||||||
|
assert app._resolve_vk_user_id("999") == 999
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_feature_toggle(app):
|
||||||
|
"""Скрытие кнопки меню: раздел «интенсив» выключается и не запускается по слову."""
|
||||||
|
import json
|
||||||
|
a = _admin()
|
||||||
|
# выключаем интенсив
|
||||||
|
app.handle_message(a, "Кнопки меню 🧩")
|
||||||
|
app.handle_message(a, "Летний интенсив CS2 🔥 — вкл ✅") # нажатие -> выкл
|
||||||
|
assert bot.db.get_flag("intensive", True) is False
|
||||||
|
# кнопки нет в меню
|
||||||
|
labels = [b["action"].get("label") for row in json.loads(app._menu_kb(a))["buttons"] for b in row]
|
||||||
|
assert not any("Летний интенсив" in x for x in labels)
|
||||||
|
# кодовое слово «лето» больше не запускает интенсив
|
||||||
|
u = 70050
|
||||||
|
app.handle_message(u, "лето")
|
||||||
|
assert _state(u) == "FREE_CHAT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_favi_stop_start(app):
|
||||||
|
"""Экстренное отключение супер-админом по паролю и обратное включение."""
|
||||||
|
import config as cfg
|
||||||
|
# тестовый хэш пароля 'secret'
|
||||||
|
import hashlib, os
|
||||||
|
salt = os.urandom(16)
|
||||||
|
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
|
||||||
|
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
|
||||||
|
a = bot.config.SUPER_ADMIN_IDS[0]
|
||||||
|
app.handle_message(a, "/favi-stop")
|
||||||
|
app.handle_message(a, "secret")
|
||||||
|
assert bot.db.get_flag("bot_disabled", False) is True
|
||||||
|
# обычный юзер не получает ответа
|
||||||
|
app._sent.clear(); app.handle_message(70051, "привет")
|
||||||
|
assert len(app._sent) == 0
|
||||||
|
# включаем обратно
|
||||||
|
app.handle_message(a, "/favi-start"); app.handle_message(a, "secret")
|
||||||
|
assert bot.db.get_flag("bot_disabled", False) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_favi_wrong_password(app):
|
||||||
|
import config as cfg, hashlib, os
|
||||||
|
salt = os.urandom(16)
|
||||||
|
cfg.FAVI_STOP_HASH = "pbkdf2_sha256$240000$" + salt.hex() + "$" + \
|
||||||
|
hashlib.pbkdf2_hmac("sha256", b"secret", salt, 240000).hex()
|
||||||
|
a = bot.config.SUPER_ADMIN_IDS[0]
|
||||||
|
app.handle_message(a, "/favi-stop")
|
||||||
|
app.handle_message(a, "wrong")
|
||||||
|
assert bot.db.get_flag("bot_disabled", False) is False # не отключился
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_mute_notifications(app):
|
||||||
|
"""Админ может отключить себе уведомления от клиентов — _notify_operators его пропускает."""
|
||||||
|
op = _admin()
|
||||||
|
bot.config.OPERATOR_PEER_IDS = [op]
|
||||||
|
app._sent.clear(); app._notify_operators("тест-уведомление")
|
||||||
|
assert any("тест-уведомление" in s["msg"] for s in app._sent) # по умолчанию доходит
|
||||||
|
app.handle_message(op, "Админка ⚙️")
|
||||||
|
app.handle_message(op, "Уведомления: вкл 🔔") # переключаем в «выкл»
|
||||||
|
assert bot.db.get_profile(op).get("notify_muted") is True
|
||||||
|
app._sent.clear(); app._notify_operators("второе уведомление")
|
||||||
|
assert not any("второе уведомление" in s["msg"] for s in app._sent) # больше не доходит
|
||||||
|
|
||||||
|
|
||||||
def test_admin_faq_crud(app):
|
def test_admin_faq_crud(app):
|
||||||
a = _admin()
|
a = _admin()
|
||||||
app.handle_message(a, "Быстрые ответы 💬")
|
app.handle_message(a, "Быстрые ответы 💬")
|
||||||
@@ -243,3 +352,205 @@ def test_stats_runs(app):
|
|||||||
app._sent.clear()
|
app._sent.clear()
|
||||||
app.handle_message(a, "Статистика 📊")
|
app.handle_message(a, "Статистика 📊")
|
||||||
assert any("Статистика" in s["msg"] for s in app._sent)
|
assert any("Статистика" in s["msg"] for s in app._sent)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_reminder_wizard_creates_reminder(app, monkeypatch):
|
||||||
|
admin = 80001
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не должен мешать
|
||||||
|
|
||||||
|
app.handle_message(admin, "Админка ⚙️")
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
app.handle_message(admin, "➕ Добавить")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_DATE"
|
||||||
|
|
||||||
|
app.handle_message(admin, "не дата") # невалидный формат — остаёмся на шаге
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_DATE"
|
||||||
|
|
||||||
|
app.handle_message(admin, "01.01.2099 09:00")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_TEXT"
|
||||||
|
|
||||||
|
app.handle_message(admin, "Продлить домен")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS"
|
||||||
|
|
||||||
|
app.handle_message(admin, "0") # все админы
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_PERIOD"
|
||||||
|
|
||||||
|
app.handle_message(admin, "Каждый год")
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
|
||||||
|
rows = bot.db.get_active_reminders()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["recipients"] == "all"
|
||||||
|
assert rows[0]["recurrence"] == "yearly"
|
||||||
|
assert rows[0]["text"] == "Продлить домен"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_reminder_wizard_specific_recipients(app, monkeypatch):
|
||||||
|
admin, other = 80003, 80004
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin, other])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 0)
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
app.handle_message(admin, "➕ Добавить")
|
||||||
|
app.handle_message(admin, "01.01.2099 09:00")
|
||||||
|
app.handle_message(admin, "Оплатить сервер")
|
||||||
|
app.handle_message(admin, "9") # номер вне списка (только 2 админа)
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_RECIPIENTS"
|
||||||
|
app.handle_message(admin, "1") # первый по сортировке id
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_PERIOD"
|
||||||
|
app.handle_message(admin, "Разово")
|
||||||
|
|
||||||
|
rows = bot.db.get_active_reminders()
|
||||||
|
assert rows[0]["recipients"] == [min(admin, other)]
|
||||||
|
assert rows[0]["recurrence"] == "once"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_reminder_delete(app, monkeypatch):
|
||||||
|
admin = 80002
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 0)
|
||||||
|
rid = bot.db.add_reminder("Тест", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
|
||||||
|
|
||||||
|
app.handle_message(admin, f"напоминалка удалить {rid}")
|
||||||
|
assert not bot.db.get_active_reminders()
|
||||||
|
|
||||||
|
before = len(app._sent)
|
||||||
|
app.handle_message(admin, f"напоминалка удалить {rid}") # повторно — не найдено
|
||||||
|
# _handle_reminder_delete шлёт результат, а затем обновлённую панель «Напоминалки»,
|
||||||
|
# поэтому проверяем все сообщения этого шага, а не только последнее.
|
||||||
|
new_msgs = [s["msg"] for s in app._sent[before:]]
|
||||||
|
assert any("не найдено" in m for m in new_msgs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_reminder_delete_via_picker_button(app, monkeypatch):
|
||||||
|
"""Удаление через кнопку «🗑 Удалить напоминание» + выбор из списка (без ввода id вручную)."""
|
||||||
|
admin = 80005
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 0)
|
||||||
|
rid = bot.db.add_reminder("Продлить хостинг", "2099-01-01 09:00:00", "once", [admin], created_by=admin)
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
app.handle_message(admin, "🗑 Удалить напоминание")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_DEL_PICK"
|
||||||
|
|
||||||
|
app.handle_message(admin, f"{rid}. Продлить хостинг") # клик по кнопке пикера
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
assert not bot.db.get_active_reminders()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_tz_gate_before_list(app, monkeypatch):
|
||||||
|
"""Без заданного часового пояса раздел не открывается — сначала гейт."""
|
||||||
|
admin = 80006
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
|
||||||
|
|
||||||
|
app.handle_message(admin, "не число") # невалидный формат — остаёмся на шаге
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
|
||||||
|
|
||||||
|
app.handle_message(admin, "+3")
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
assert bot.db.get_admin_tz_offset(admin) == 3
|
||||||
|
|
||||||
|
# повторный вход — пояс уже задан, гейт больше не срабатывает
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_tz_setup_via_city_button(app, monkeypatch):
|
||||||
|
"""Часовой пояс можно выбрать кнопкой города, а не только вводом числа."""
|
||||||
|
admin = 80010
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
|
||||||
|
|
||||||
|
app.handle_message(admin, "Екатеринбург, UTC+5") # клик по кнопке клавиатуры
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
assert bot.db.get_admin_tz_offset(admin) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_creation_converts_author_local_time_to_server(app, monkeypatch):
|
||||||
|
admin = 80007
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 3) # пояс уже задан — гейт не мешает
|
||||||
|
# Фиксируем "серверный" пояс отдельно от пояса автора (0 != 3), чтобы тест
|
||||||
|
# не проходил случайно из-за совпадения с реальным поясом машины, где он
|
||||||
|
# запускается — иначе он ничего не проверял бы, если сервер сам в UTC+3.
|
||||||
|
monkeypatch.setattr(bot, "server_utc_offset_hours", lambda: 0)
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
app.handle_message(admin, "➕ Добавить")
|
||||||
|
app.handle_message(admin, "01.06.2099 09:00") # 09:00 по времени автора (UTC+3)
|
||||||
|
app.handle_message(admin, "Тест конвертации")
|
||||||
|
app.handle_message(admin, "0")
|
||||||
|
app.handle_message(admin, "Разово")
|
||||||
|
|
||||||
|
rows = bot.db.get_active_reminders()
|
||||||
|
stored = datetime.strptime(rows[0]["remind_at"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
server_offset = 0
|
||||||
|
expected = datetime(2099, 6, 1, 9, 0) + timedelta(hours=(server_offset - 3))
|
||||||
|
assert stored == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_pause_and_resume_via_picker(app, monkeypatch):
|
||||||
|
admin = 80008
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 0)
|
||||||
|
rid = bot.db.add_reminder("Продлить хостинг", "2020-01-01 09:00:00", "once", [admin], created_by=admin)
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
app.handle_message(admin, "⏸/▶ Пауза")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_PAUSE_PICK"
|
||||||
|
app.handle_message(admin, f"{rid}. Продлить хостинг")
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert row["paused"] == 1
|
||||||
|
|
||||||
|
# напоминание давно просрочено, но на паузе — планировщик не должен его отправить
|
||||||
|
app._sent.clear()
|
||||||
|
app._check_due_reminders()
|
||||||
|
assert not app._sent
|
||||||
|
assert bot.db.get_active_reminders() # всё ещё активно (не деактивировалось)
|
||||||
|
|
||||||
|
# возобновляем через тот же пикер
|
||||||
|
app.handle_message(admin, "⏸/▶ Пауза")
|
||||||
|
app.handle_message(admin, f"{rid}. Продлить хостинг")
|
||||||
|
row = next(r for r in bot.db.get_active_reminders() if r["id"] == rid)
|
||||||
|
assert row["paused"] == 0
|
||||||
|
|
||||||
|
# теперь просроченное разовое напоминание должно уйти при следующей проверке
|
||||||
|
app._check_due_reminders()
|
||||||
|
assert app._sent
|
||||||
|
assert not bot.db.get_active_reminders() # once — деактивировалось после отправки
|
||||||
|
|
||||||
|
|
||||||
|
def test_timezone_change_command(app, monkeypatch):
|
||||||
|
admin = 80009
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 3)
|
||||||
|
|
||||||
|
app.handle_message(admin, "часовой пояс -5")
|
||||||
|
assert bot.db.get_admin_tz_offset(admin) == -5
|
||||||
|
|
||||||
|
app.handle_message(admin, "часовой пояс много")
|
||||||
|
assert bot.db.get_admin_tz_offset(admin) == -5 # невалидный ввод не поменял значение
|
||||||
|
|
||||||
|
|
||||||
|
def test_timezone_change_via_button(app, monkeypatch):
|
||||||
|
"""Кнопка «🌐 Часовой пояс» открывает выбор заново, даже если пояс уже задан."""
|
||||||
|
admin = 80011
|
||||||
|
monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin])
|
||||||
|
bot.db.set_admin_tz_offset(admin, 3)
|
||||||
|
|
||||||
|
app.handle_message(admin, "⏰ Напоминалки")
|
||||||
|
assert _state(admin) == "FREE_CHAT" # пояс уже есть — гейт не срабатывает
|
||||||
|
|
||||||
|
app.handle_message(admin, "🌐 Часовой пояс")
|
||||||
|
assert _state(admin) == "ADMIN_REMINDER_TZ_SETUP"
|
||||||
|
|
||||||
|
app.handle_message(admin, "Владивосток, UTC+10")
|
||||||
|
assert _state(admin) == "FREE_CHAT"
|
||||||
|
assert bot.db.get_admin_tz_offset(admin) == 10
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from reminders import parse_reminder_datetime, compute_next_occurrence, \
|
||||||
|
server_utc_offset_hours, convert_time
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_reminder_datetime_valid():
|
||||||
|
dt = parse_reminder_datetime("15.03.2027 09:00")
|
||||||
|
assert dt == datetime(2027, 3, 15, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_reminder_datetime_invalid():
|
||||||
|
assert parse_reminder_datetime("не дата") is None
|
||||||
|
assert parse_reminder_datetime("2027-03-15 09:00") is None
|
||||||
|
assert parse_reminder_datetime("32.13.2027 09:00") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_weekly():
|
||||||
|
anchor = datetime(2026, 9, 2, 9, 0)
|
||||||
|
nxt = compute_next_occurrence(anchor, "weekly", after=anchor)
|
||||||
|
assert nxt == datetime(2026, 9, 9, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_monthly_end_of_month_clamp_no_drift():
|
||||||
|
"""31.01 -> 28.02 (2026 не високосный), а дальше 31.03 — не 28.03 (без дрейфа)."""
|
||||||
|
anchor = datetime(2026, 1, 31, 9, 0)
|
||||||
|
first = compute_next_occurrence(anchor, "monthly", after=anchor)
|
||||||
|
assert first == datetime(2026, 2, 28, 9, 0)
|
||||||
|
second = compute_next_occurrence(anchor, "monthly", after=first)
|
||||||
|
assert second == datetime(2026, 3, 31, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_yearly_leap_day():
|
||||||
|
anchor = datetime(2024, 2, 29, 9, 0)
|
||||||
|
nxt = compute_next_occurrence(anchor, "yearly", after=anchor)
|
||||||
|
assert nxt == datetime(2025, 2, 28, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_skips_missed_periods_during_downtime():
|
||||||
|
"""Бот стоял два месяца — пропущенные периоды пропускаются, берём ближайшее будущее."""
|
||||||
|
anchor = datetime(2026, 1, 1, 9, 0)
|
||||||
|
after = datetime(2026, 3, 15, 0, 0)
|
||||||
|
nxt = compute_next_occurrence(anchor, "monthly", after=after)
|
||||||
|
assert nxt == datetime(2026, 4, 1, 9, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_next_unknown_recurrence_raises():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_next_occurrence(datetime(2026, 1, 1, 9, 0), "daily", after=datetime(2026, 1, 1, 9, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_utc_offset_hours_in_sane_range():
|
||||||
|
"""Собственное смещение сервера от UTC — разумное число часов (реальные пояса Земли)."""
|
||||||
|
offset = server_utc_offset_hours()
|
||||||
|
assert -12 <= offset <= 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_time_to_utc():
|
||||||
|
dt = datetime(2027, 3, 15, 9, 0) # 09:00 по времени автора (UTC+3, Москва)
|
||||||
|
result = convert_time(dt, from_offset=3, to_offset=0) # переводим в UTC
|
||||||
|
assert result == datetime(2027, 3, 15, 6, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_time_crosses_midnight_backwards():
|
||||||
|
dt = datetime(2027, 3, 15, 1, 0) # 01:00 по времени автора (UTC+5, Екатеринбург)
|
||||||
|
result = convert_time(dt, from_offset=5, to_offset=-2)
|
||||||
|
assert result == datetime(2027, 3, 14, 18, 0) # переходит на предыдущие сутки
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_time_same_offset_is_noop():
|
||||||
|
dt = datetime(2027, 1, 1, 12, 0)
|
||||||
|
assert convert_time(dt, from_offset=3, to_offset=3) == dt
|
||||||
Reference in New Issue
Block a user