Files
bot_vk_ikp_prodagi/docs/superpowers/plans/2026-09-02-admin-reminders.md
T

1014 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Настраиваемые напоминалки для админов — план реализации
> **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"
"Чтобы удалить: напоминалка удалить <id>\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 делается им
самим (или по отдельному явному запросу) после того, как он лично протестирует бота.