1014 lines
44 KiB
Markdown
1014 lines
44 KiB
Markdown
# Настраиваемые напоминалки для админов — план реализации
|
||
|
||
> **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 делается им
|
||
самим (или по отдельному явному запросу) после того, как он лично протестирует бота.
|