23 Commits

Author SHA1 Message Date
Kenik 28499d9b55 Merge: защита от тихого зависания бота (watchdog + сетевой таймаут)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:48:37 +03:00
Kenik 6ee4e6cf0f Защита от тихого зависания бота (без таймаута сети + watchdog)
Причина 'бот в моменте перестаёт работать' без ошибок в логах:
vk_api ставит таймаут только на сам LongPoll-запрос (wait+10 с), а
обычные вызовы API (messages.send и т.п.) идут без таймаута вообще.
Если VK/сеть один раз не ответит на такой вызов — соединение виснет
навсегда, а так как обработка сообщений однопоточная (внутри цикла
LongPoll), весь бот намертво стопорится без единой строки в логе.
Docker restart: unless-stopped не спасает — процесс не падает, а просто
висит.

- socket.setdefaulttimeout(60) — подстраховка для всех сетевых вызовов
  без явного timeout (vk_api, requests в mailer.py и загрузке фото).
- Ручной вызов longpoll.check() вместо for event in listen() — даёт
  watchdog 'пульс' на каждом опросе сервера (~25-35 с), а не только при
  реальных сообщениях.
- Watchdog-поток: если цикл не подавал признаков жизни дольше 5 минут,
  принудительно завершает процесс (os._exit), чтобы Docker реально
  перезапустил контейнер.
2026-09-06 13:44:45 +03:00
Kenik 6ecbd7aa94 Напоминалки: кнопка смены часового пояса в разделе
Раньше сменить уже заданный пояс можно было только скрытой командой
'часовой пояс <±N>', нигде не упомянутой в интерфейсе. Добавлена кнопка
«🌐 Часовой пояс» — открывает тот же выбор (кнопки городов + ручной ввод)
заново, показывая текущее значение.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:22:57 +03:00
Kenik e41db35f13 Напоминалки: выбор часового пояса кнопками городов + ручной ввод
При гейте часового пояса теперь показывается клавиатура с частыми
российскими городами-поясами (Калининград..Камчатка). Клик по кнопке
шлёт 'Город, UTC+N' — _parse_tz_offset распознаёт и это, и обычный
ручной ввод вида '+3'/'-5', если своего города в списке нет.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 15:18:29 +03:00
Kenik 0085195941 Напоминалки: тест конвертации не должен зависеть от пояса сервера
По ревью Task 5: test_reminder_creation_converts_author_local_time_to_server
сравнивал результат с server_utc_offset_hours() самой машины, на которой
запускается тест. Так как сервер в этом окружении сам в UTC+3 — совпадающем
с поясом автора в тесте, — разница смещений была 0, и тест проходил бы даже
если бы конвертация вообще не выполнялась. Фикс: подменяем
server_utc_offset_hours на фиксированное значение (0), заведомо отличное от
пояса автора (3), чтобы тест реально ловил регрессию независимо от машины.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:54:55 +03:00
Kenik c814eca596 Напоминалки: тесты гейта часового пояса, конвертации и паузы
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:51:35 +03:00
Kenik b766d445b4 Напоминалки: гейт часового пояса и для текстовой команды удаления
По ревью Task 4: команда 'напоминалка удалить <id>' была единственной точкой
входа в раздел, не защищённой гейтом _require_reminder_tz — админ без заданного
пояса мог через неё попасть в _send_reminders_admin и увидеть список с
подписью (UTC+0) по фолбэку, так и не указав реальный часовой пояс.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:49:17 +03:00
Kenik 3ff411b554 Напоминалки: гейт часового пояса, конвертация времени, состояние паузы
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:44:35 +03:00
Kenik 5ce69fb66c Напоминалки: кнопка паузы в клавиатуре раздела
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:42:10 +03:00
Kenik 79ec619360 Напоминалки: часовой пояс админов и пауза в database.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:39:54 +03:00
Kenik 40cfcbd45f Напоминалки: конвертация между часовыми поясами (reminders.py)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:37:26 +03:00
Kenik 5efc0aca8d Напоминалки: план реализации паузы и часового пояса (docs)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:35:13 +03:00
Kenik 4429b99a55 Напоминалки: спека паузы и часового пояса
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:29:01 +03:00
Kenik 19bade27b6 Напоминалки: нижняя клавиатура раздела вместо инлайн (не остаются старые кнопки)
Инлайн-клавиатура в _send_reminders_admin не заменяла нижнюю (reply) клавиатуру
предыдущего шага мастера — после создания напоминания кнопки «Разово / Каждую
неделю / …» оставались висеть внизу. Теперь раздел использует обычную нижнюю
клавиатуру get_reminders_admin_keyboard(), как и раздел «Быстрые ответы».

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:11:15 +03:00
Kenik fc79da8f83 Напоминалки: кнопка удаления списком и ФИО получателей вместо голых id
- В разделе «Напоминалки» добавлена кнопка «🗑 Удалить напоминание» с выбором
  из списка (как в разделе «Быстрые ответы»), не только текстовая команда.
- При выборе получателей и в списке напоминаний теперь показывается ФИО
  админа из VK рядом со ссылкой (если удалось получить), чтобы не кликать
  по каждой ссылке для опознания.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:49 +03:00
Kenik 6d4dd16caf Напоминалки: план реализации (docs) 2026-09-02 21:56:18 +03:00
Kenik 77f314be3f Напоминалки: сквозные тесты мастера создания и удаления 2026-09-02 21:53:31 +03:00
Kenik 5d88b8899e Напоминалки: фоновый планировщик отправки в срок
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:49:28 +03:00
Kenik 86f8115de1 Напоминалки: мастер создания, список и удаление в bot.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:44:23 +03:00
Kenik 0e162ac9a1 Напоминалки: кнопка в админ-панели и клавиатура выбора периода
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:40:30 +03:00
Kenik bf958e1c12 Напоминалки: таблица reminders и CRUD в database.py
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:37:26 +03:00
Kenik f5ce327bbf Напоминалки: чистая логика расчёта дат (reminders.py)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:33:56 +03:00
Kenik 4bf7f98540 Спека: настраиваемые напоминалки для админов 2026-09-02 21:23:47 +03:00
11 changed files with 3282 additions and 2 deletions
+368 -2
View File
@@ -10,10 +10,20 @@ import time
import hmac import hmac
import hashlib import hashlib
import logging import logging
import socket
import threading import threading
from collections import defaultdict from collections import defaultdict
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
# Глобальный таймаут для всех сетевых сокетов без явного timeout.
# Без этого библиотека vk_api ставит таймаут только на сам LongPoll-запрос
# (wait+10 сек), а обычные вызовы API (messages.send и т.п.) идут без
# таймаута вообще — если VK/сеть один раз не ответит, соединение висит
# бесконечно и весь однопоточный цикл обработки сообщений намертво стопорится
# без единой строки в логе. Docker restart:unless-stopped при этом не
# помогает — процесс не падает, а просто зависает.
socket.setdefaulttimeout(60)
# Нам понадобятся библиотеки vk_api. # Нам понадобятся библиотеки vk_api.
try: try:
import vk_api import vk_api
@@ -24,6 +34,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
@@ -47,6 +60,20 @@ BAN_WINDOW_SEC = 60
BAN_SEC = 600 BAN_SEC = 600
# Сколько секунд кэшировать список рантайм-админов из БД # Сколько секунд кэшировать список рантайм-админов из БД
ADMINS_CACHE_TTL = 30 ADMINS_CACHE_TTL = 30
# Как часто фоновый поток проверяет, не пора ли отправить напоминание
REMINDER_CHECK_INTERVAL_SEC = 60
# Сколько секунд основной цикл LongPoll может не подавать признаков жизни,
# прежде чем watchdog сочтёт его зависшим и принудительно завершит процесс
# (штатный опрос происходит раз в ~25-35 с, так что 5 минут — большой запас)
WATCHDOG_TIMEOUT_SEC = 300
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
@@ -460,6 +487,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()
@@ -590,6 +714,69 @@ 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 ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]: elif text_lower in ["уведомления: вкл 🔔", "уведомления: выкл 🔕", "уведомления", "уведомления 🔔", "уведомления 🔕"]:
self._toggle_admin_notifications(user_id) self._toggle_admin_notifications(user_id)
return return
@@ -1623,6 +1810,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")
@@ -1805,8 +2114,59 @@ 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 _watchdog_loop(self):
"""Фоновый страж: если основной цикл LongPoll не подавал признаков
жизни дольше WATCHDOG_TIMEOUT_SEC (завис на каком-то вызове без
таймаута, дедлок и т.п.), принудительно завершает процесс — чтобы
Docker (restart: unless-stopped) реально перезапустил контейнер.
Без этого зависший, но не упавший процесс никто не перезапустит."""
while True:
time.sleep(30)
stale_for = time.time() - self._last_alive_ts
if stale_for > WATCHDOG_TIMEOUT_SEC:
logger.critical(
f"Watchdog: основной цикл не подавал признаков жизни {stale_for:.0f} с "
f"(порог {WATCHDOG_TIMEOUT_SEC} с). Похоже на зависание — принудительный выход "
f"для перезапуска контейнером."
)
os._exit(1)
def run(self): def run(self):
"""Запуск бесконечного цикла прослушивания событий VK LongPoll.""" """Запуск бесконечного цикла прослушивания событий VK LongPoll."""
self._last_alive_ts = time.time()
threading.Thread(target=self._reminders_loop, daemon=True).start()
threading.Thread(target=self._watchdog_loop, daemon=True).start()
if not self.longpoll: if not self.longpoll:
logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).") logger.warning("Бот запущен в режиме ЭМУЛЯЦИИ (без валидных токенов VK API).")
print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.") print("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -1829,7 +2189,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}).")
@@ -1837,9 +2196,16 @@ class VKBotApplication:
logger.error(f"Не удалось отправить старт-пинг: {e}") logger.error(f"Не удалось отправить старт-пинг: {e}")
# Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота. # Устойчивый цикл: обрыв longpoll-соединения (обычное дело) не должен ронять бота.
# Вызываем check() вручную (а не for event in self.longpoll.listen()), чтобы
# обновлять "пульс" для watchdog на каждом опросе сервера (раз в ~25-35 с),
# а не только когда реально приходит сообщение — иначе в тихие часы без
# сообщений watchdog решит, что бот завис, и перезапустит его зря.
while True: while True:
try: try:
for event in self.longpoll.listen(): self._last_alive_ts = time.time()
events = self.longpoll.check()
self._last_alive_ts = time.time()
for event in events:
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"]
+118
View File
@@ -192,6 +192,36 @@ class DatabaseManager:
) )
""") """)
# 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()
# ---------- Редактируемые ссылки (кнопки записи/оплаты) ---------- # ---------- Редактируемые ссылки (кнопки записи/оплаты) ----------
@@ -606,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:
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` не шлёт напоминание на паузе,
шлёт после возобновления).
+55
View File
@@ -176,6 +176,8 @@ def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str:
keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔", keyboard.add_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔",
color=VkKeyboardColor.SECONDARY) color=VkKeyboardColor.SECONDARY)
keyboard.add_line() 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()
@@ -195,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)
@@ -217,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)
+79
View File
@@ -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))
+50
View File
@@ -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
+203
View File
@@ -1,4 +1,5 @@
import bot import bot
from datetime import datetime, timedelta
def _state(uid): def _state(uid):
@@ -351,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
+72
View File
@@ -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