diff --git a/docs/superpowers/plans/2026-09-02-admin-reminders.md b/docs/superpowers/plans/2026-09-02-admin-reminders.md new file mode 100644 index 0000000..7184d51 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-admin-reminders.md @@ -0,0 +1,1013 @@ +# Настраиваемые напоминалки для админов — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Добавить в VK-бота раздел «⏰ Напоминалки»: админ создаёт напоминание (дата/время, +текст, получатели, периодичность разово/неделя/месяц/год), бот сам присылает его в срок и, +если периодическое, пересчитывает следующее срабатывание. + +**Architecture:** Новая таблица SQLite `reminders` + CRUD в `database.py`. Чистая логика +расчёта дат (парсинг ввода, следующее срабатывание с учётом коротких месяцев/високосного +29 февраля) — в новом файле `reminders.py`, без зависимостей от `bot.py`, что делает её +тривиально юнит-тестируемой. Пошаговый мастер ввода (`ADMIN_REMINDER_*` состояния) и +общий список для всех админов (add/delete, без edit) — в `bot.py`, по образцу уже +существующего мастера «Быстрые ответы» (`ADMIN_FAQ_*`). Отправка в срок — отдельный +фоновый поток (`threading.Thread`, как уже используется для рассылки/прогрева медиа), +проверяющий БД раз в 60 секунд; при простое бота пропущенное напоминание досылается +один раз при следующей проверке, дальше планируется в будущее. + +**Tech Stack:** Python, SQLite (`sqlite3` через существующий `DatabaseManager`), vk_api +(`VkKeyboard`), `pytest` для тестов. + +**Важно:** НЕ пушить в git до полного прогона тестов и ручной проверки в консольном +эмуляторе бота (см. Task 7). Локальные коммиты после каждой задачи — можно и нужно. + +--- + +## Task 1: `reminders.py` — парсинг даты и расчёт следующего срабатывания + +**Files:** +- Create: `reminders.py` +- Test: `tests/test_reminders.py` + +- [ ] **Step 1: Написать падающие тесты** + +Создать `tests/test_reminders.py`: + +```python +from datetime import datetime +from reminders import parse_reminder_datetime, compute_next_occurrence + + +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)) +``` + +- [ ] **Step 2: Убедиться, что тесты падают (модуля ещё нет)** + +Run: `python -m pytest tests/test_reminders.py -v` +Expected: FAIL с `ModuleNotFoundError: No module named 'reminders'` + +- [ ] **Step 3: Реализовать `reminders.py`** + +Создать `reminders.py`: + +```python +"""Расчёт дат для напоминалок админов: парсинг ввода и следующее срабатывание. + +Не зависит от 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 +``` + +- [ ] **Step 4: Прогнать тесты, убедиться, что все проходят** + +Run: `python -m pytest tests/test_reminders.py -v` +Expected: PASS (7 passed) + +- [ ] **Step 5: Коммит** + +```bash +git add reminders.py tests/test_reminders.py +git commit -m "Напоминалки: чистая логика расчёта дат (reminders.py)" +``` + +--- + +## Task 2: `database.py` — таблица `reminders` и CRUD + +**Files:** +- Modify: `database.py` +- Test: `tests/test_database.py` + +- [ ] **Step 1: Написать падающие тесты** + +Добавить в конец `tests/test_database.py`: + +```python +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 +``` + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `python -m pytest tests/test_database.py -v -k reminders` +Expected: FAIL с `AttributeError: 'DatabaseManager' object has no attribute 'add_reminder'` + +- [ ] **Step 3: Добавить таблицу в `_init_db`** + +В `database.py` найти блок создания таблицы `bot_flags` (последняя таблица перед +`conn.commit()` в `_init_db`): + +```python + # Table for feature flags (показ/скрытие кнопок меню и разделов) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS bot_flags ( + key TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + conn.commit() +``` + +Заменить на (добавлена таблица `reminders` перед `conn.commit()`): + +```python + # Table for feature flags (показ/скрытие кнопок меню и разделов) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS bot_flags ( + key TEXT PRIMARY KEY, + enabled INTEGER NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Table for admin reminders (настраиваемые напоминания: дата, текст, периодичность) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + remind_at TEXT NOT NULL, -- якорная дата первого срабатывания, не меняется + next_at TEXT NOT NULL, -- следующее срабатывание, двигается вперёд + recurrence TEXT NOT NULL, -- 'once' | 'weekly' | 'monthly' | 'yearly' + recipients TEXT NOT NULL, -- JSON: список vk_id, либо строка "all" + created_by INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + active INTEGER NOT NULL DEFAULT 1 + ) + """) + + conn.commit() +``` + +- [ ] **Step 4: Добавить CRUD-методы** + +В `database.py` найти метод `get_admins` (в блоке `# --- Администраторы ---`): + +```python + def get_admins(self) -> list: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT user_id FROM admins ORDER BY user_id") + return [r[0] for r in cursor.fetchall()] +``` + +Сразу после него (перед блоком `# --- Быстрые ответы (custom FAQ) ---`) добавить новый +блок: + +```python + # --- Напоминалки для админов (дата/время, периодичность) --- + 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 " + "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 " + "FROM reminders WHERE active = 1 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 + +``` + +(`json` уже импортирован в начале `database.py` — `import json` в строке 3, отдельно +импортировать не нужно.) + +- [ ] **Step 5: Прогнать тесты** + +Run: `python -m pytest tests/test_database.py -v` +Expected: PASS (все тесты, включая старые) + +- [ ] **Step 6: Коммит** + +```bash +git add database.py tests/test_database.py +git commit -m "Напоминалки: таблица reminders и CRUD в database.py" +``` + +--- + +## Task 3: `keyboards.py` — кнопки + +**Files:** +- Modify: `keyboards.py` + +- [ ] **Step 1: Добавить кнопку «⏰ Напоминалки» в админ-панель** + +В `keyboards.py` найти `get_admin_keyboard`: + +```python +def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str: + """Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы».""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY) + 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_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔", + color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + if is_super: + keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() +``` + +Заменить на (добавлена строка с кнопкой напоминалок перед разделом супер-админа): + +```python +def get_admin_keyboard(is_super: bool = False, muted: bool = False) -> str: + """Клавиатура админ-панели с разделами. Супер-админу — раздел «Администраторы».""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Статистика 📊", color=VkKeyboardColor.SECONDARY) + 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_button("Уведомления: выкл 🔕" if muted else "Уведомления: вкл 🔔", + color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("⏰ Напоминалки", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + if is_super: + keyboard.add_button("Администраторы 👑", color=VkKeyboardColor.SECONDARY) + keyboard.add_line() + keyboard.add_button("В главное меню ↩️", color=VkKeyboardColor.SECONDARY) + return keyboard.get_keyboard() +``` + +- [ ] **Step 2: Добавить клавиатуру выбора периодичности** + +В `keyboards.py` сразу после функции `get_cancel_admin_keyboard`: + +```python +def get_cancel_admin_keyboard() -> str: + """Кнопка отмены для админских мастеров ввода.""" + keyboard = VkKeyboard(one_time=False) + keyboard.add_button("Отмена ❌", color=VkKeyboardColor.NEGATIVE) + return keyboard.get_keyboard() +``` + +Добавить новую функцию: + +```python +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() +``` + +- [ ] **Step 3: Проверить, что ничего не сломалось** + +Run: `python -m pytest tests/ -v` +Expected: PASS (все существующие тесты по-прежнему проходят — `keyboards.py` не имеет +собственных тестов, но используется в `test_flow.py`) + +- [ ] **Step 4: Коммит** + +```bash +git add keyboards.py +git commit -m "Напоминалки: кнопка в админ-панели и клавиатура выбора периода" +``` + +--- + +## Task 4: `bot.py` — мастер создания и раздел списка/удаления + +**Files:** +- Modify: `bot.py` + +- [ ] **Step 1: Импорт и константы** + +В `bot.py` найти блок импортов: + +```python +from database import DatabaseManager +import config +import mailer +``` + +Заменить на: + +```python +from database import DatabaseManager +from reminders import parse_reminder_datetime, compute_next_occurrence +from datetime import datetime +import config +import mailer +``` + +Найти константу: + +```python +# Сколько секунд кэшировать список рантайм-админов из БД +ADMINS_CACHE_TTL = 30 +``` + +Заменить на: + +```python +# Сколько секунд кэшировать список рантайм-админов из БД +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()} +``` + +Затем найти внутри `run()` локальный импорт (он больше не нужен — `datetime` теперь +импортирован на уровне модуля): + +```python + try: + from datetime import datetime + ts = datetime.now().strftime("%d.%m.%Y %H:%M") +``` + +Заменить на: + +```python + try: + ts = datetime.now().strftime("%d.%m.%Y %H:%M") +``` + +- [ ] **Step 2: Прогнать тесты (регрессия импортов)** + +Run: `python -m pytest tests/ -v` +Expected: PASS (импорт `reminders` из Task 1 уже существует, ничего не сломано) + +- [ ] **Step 3: Добавить `_all_admin_ids` и раздел списка/удаления напоминаний** + +В `bot.py` найти конец метода `_handle_admin_remove_admin` (перед `_cleanup_antiflood`): + +```python + if db.remove_admin(rem_id): + self._admins_cache = None # инвалидируем кэш админов + self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") + else: + self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " + "(админов из .env и супер-админов отсюда снять нельзя).") + self._send_admins_panel(user_id) + + def _cleanup_antiflood(self): +``` + +Заменить на (добавлены новые методы между ними): + +```python + if db.remove_admin(rem_id): + self._admins_cache = None # инвалидируем кэш админов + self.send_message(user_id, f"✅ Пользователь vk.com/id{rem_id} больше не администратор.") + else: + self.send_message(user_id, f"⚠️ vk.com/id{rem_id} нет среди добавленных из бота " + "(админов из .env и супер-админов отсюда снять нельзя).") + 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 _send_reminders_admin(self, user_id: int): + """Раздел «Напоминалки»: список активных + инструкции.""" + rows = db.get_active_reminders() + listing = "" + for r in rows: + dt = datetime.strptime(r["next_at"], "%Y-%m-%d %H:%M:%S") + who = "всем админам" if r["recipients"] == "all" else \ + ", ".join(f"id{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"] + listing += (f"🔹 [{r['id']}] {dt.strftime('%d.%m.%Y %H:%M')} ({period}) → {who}\n" + f" «{short}»\n") + if not listing: + listing = "Активных напоминаний нет.\n" + kb = VkKeyboard(inline=True) + kb.add_button("➕ Добавить", color=VkKeyboardColor.PRIMARY) + kb.add_line() + kb.add_button("В админку ↩️", color=VkKeyboardColor.SECONDARY) + self.send_message( + user_id, + "⏰ Напоминалки\n\n" + f"{listing}\n" + "Чтобы удалить: напоминалка удалить \n" + "Чтобы добавить: нажмите «➕ Добавить»", + keyboard=kb.get_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): +``` + +- [ ] **Step 4: Подключить раздел в главном роутере `handle_message`** + +В `bot.py` найти в `handle_message` блок обработки быстрых ответов/ссылок: + +```python + elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]: + self._send_links_admin(user_id) + return +``` + +Заменить на (добавлены три новых elif сразу после): + +```python + elif text_lower in ["ссылки 🔗", "ссылки", "ссылка"]: + self._send_links_admin(user_id) + return + elif text_lower in ["⏰ напоминалки", "напоминалки"]: + self._send_reminders_admin(user_id) + return + elif text_lower == "➕ добавить": + 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("напоминалка удалить"): + self._handle_reminder_delete(user_id, text_clean) + return +``` + +- [ ] **Step 5: Добавить обработку состояний мастера в `_handle_admin_wizard`** + +В `bot.py` найти конец обработки администраторов (перед сбросом неизвестного состояния): + +```python + if state == "ADMIN_ADMIN_DEL_PICK": + if not self._is_super_admin(user_id): + db.update_user_state(user_id, "FREE_CHAT") + self._send_admin_panel(user_id) + return + self._handle_admin_remove_admin(user_id, "админ удал " + pick_id(text)) + db.update_user_state(user_id, "FREE_CHAT") + return + + # Неизвестное ADMIN_-состояние — сброс + self._admin_ctx.pop(user_id, None) + db.update_user_state(user_id, "FREE_CHAT") + self._send_admin_panel(user_id) +``` + +Заменить на (новый блок `# ---------- Напоминалки ----------` вставлен перед сбросом): + +```python + if state == "ADMIN_ADMIN_DEL_PICK": + if not self._is_super_admin(user_id): + db.update_user_state(user_id, "FREE_CHAT") + self._send_admin_panel(user_id) + return + self._handle_admin_remove_admin(user_id, "админ удал " + pick_id(text)) + db.update_user_state(user_id, "FREE_CHAT") + 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 + if dt <= datetime.now(): + self.send_message(user_id, "⚠️ Дата и время должны быть в будущем.", + keyboard=get_cancel_admin_keyboard()) + return + self._admin_ctx[user_id] = {"remind_at": 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}. vk.com/id{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_-состояние — сброс + self._admin_ctx.pop(user_id, None) + db.update_user_state(user_id, "FREE_CHAT") + self._send_admin_panel(user_id) +``` + +- [ ] **Step 6: Прогнать полный набор тестов** + +Run: `python -m pytest tests/ -v` +Expected: PASS (мастер пока не покрыт тестами — добавим в Task 6; ничего не должно +сломаться из существующего) + +- [ ] **Step 7: Коммит** + +```bash +git add bot.py +git commit -m "Напоминалки: мастер создания, список и удаление в bot.py" +``` + +--- + +## Task 5: `bot.py` — фоновый планировщик отправки + +**Files:** +- Modify: `bot.py` + +- [ ] **Step 1: Добавить `_check_due_reminders` и `_reminders_loop`** + +В `bot.py` найти конец метода `_run_broadcast` (перед `def run(self):`): + +```python + self.send_message(admin_id, f"✅ Рассылка завершена. Доставлено получателям: {sent} из {len(recipients)}.") + + def run(self): +``` + +Заменить на: + +```python + 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): +``` + +- [ ] **Step 2: Запустить поток в `run()`** + +В `bot.py` найти начало `run()`: + +```python + def run(self): + """Запуск бесконечного цикла прослушивания событий VK LongPoll.""" + if not self.longpoll: +``` + +Заменить на: + +```python + def run(self): + """Запуск бесконечного цикла прослушивания событий VK LongPoll.""" + threading.Thread(target=self._reminders_loop, daemon=True).start() + if not self.longpoll: +``` + +- [ ] **Step 3: Прогнать полный набор тестов** + +Run: `python -m pytest tests/ -v` +Expected: PASS + +- [ ] **Step 4: Коммит** + +```bash +git add bot.py +git commit -m "Напоминалки: фоновый планировщик отправки в срок" +``` + +--- + +## Task 6: Сквозные тесты мастера в `test_flow.py` + +**Files:** +- Modify: `tests/test_flow.py` + +- [ ] **Step 1: Написать тесты полного сценария** + +Добавить в конец `tests/test_flow.py`: + +```python +def test_admin_reminder_wizard_creates_reminder(app, monkeypatch): + admin = 80001 + monkeypatch.setattr(bot.config, "ADMIN_IDS", [admin]) + + 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]) + + 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]) + 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() + + app.handle_message(admin, f"напоминалка удалить {rid}") # повторно — не найдено + last_msg = app._sent[-1]["msg"] if app._sent else "" + assert "не найдено" in last_msg +``` + +- [ ] **Step 2: Прогнать новые тесты** + +Run: `python -m pytest tests/test_flow.py -v -k reminder` +Expected: PASS (3 passed) + +- [ ] **Step 3: Прогнать весь набор тестов проекта** + +Run: `python -m pytest tests/ -v` +Expected: PASS (все тесты проекта, включая старые) + +- [ ] **Step 4: Коммит** + +```bash +git add tests/test_flow.py +git commit -m "Напоминалки: сквозные тесты мастера создания и удаления" +``` + +--- + +## Task 7: Ручная проверка в консольном эмуляторе (перед push) + +**Files:** нет изменений кода — только проверка. + +- [ ] **Step 1: Запустить бота в режиме эмуляции** + +Run: `python bot.py` + +(Без валидного `VK_TOKEN` в `.env` бот запускается в консольном режиме — это ожидаемо +и удобно для ручной проверки без реального VK.) + +- [ ] **Step 2: Пройти мастер вручную** + +В консоли эмулятора (пользователь по умолчанию не админ — на реальном проекте временно +добавьте свой тестовый ID в `ADMIN_IDS` в `.env` и перезапустите, либо отредактируйте +`config.ADMIN_IDS` перед запуском для проверки): + +``` +Админка ⚙️ +⏰ Напоминалки +➕ Добавить +15.03.2027 09:00 +Тестовое напоминание +0 +Каждый месяц +``` + +Expected: после каждого шага — соответствующий вопрос мастера; в конце — «✅ Напоминание +создано.» и список с одной записью. + +- [ ] **Step 3: Проверить срабатывание фонового потока** + +Создать через код (не дожидаясь реальной даты) напоминание на ближайшую минуту и +подождать: временно в интерактивной python-сессии (`python`, в каталоге проекта): + +```python +import bot +from datetime import datetime, timedelta +soon = (datetime.now() + timedelta(seconds=5)).strftime("%Y-%m-%d %H:%M:%S") +bot.db.add_reminder("Проверка планировщика", soon, "once", "all", created_by=1) +app = bot.VKBotApplication() +import time +time.sleep(1) +app._check_due_reminders() # без реального VK_TOKEN send_message пишет в лог, не падает +print(bot.db.get_active_reminders()) # должно быть пусто — 'once' деактивировался +``` + +Expected: `[]` — напоминание отправлено (проверьте `bot_run.log` на строку с текстом +напоминания либо консольный вывод) и деактивировано. + +- [ ] **Step 4: Убедиться, что база не испортила существующие данные** + +Run: `python -m pytest tests/ -v` +Expected: PASS — полный прогон перед завершением работы. + +- [ ] **Step 5: Сообщить пользователю результат ручной проверки** + +Только после успешного прохождения Step 1–4 — сообщить, что фича готова к ревью. +**Не выполнять `git push`** — по договорённости с пользователем, push делается им +самим (или по отдельному явному запросу) после того, как он лично протестирует бота.