Compare commits

..

21 Commits

Author SHA1 Message Date
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 3241 additions and 1 deletions
+327 -1
View File
@@ -24,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
@@ -47,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
@@ -460,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()
@@ -590,6 +700,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 +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")
@@ -1805,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("Симуляция работы бота в консоли. Напишите 'выход' для завершения.")
@@ -1829,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}).")
+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