From c4b72b22e6da49db62b852ed76987fe10d097c07 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 6 Jul 2026 13:30:48 +0300 Subject: [PATCH] add reminder schedule --- .env.example | 3 ++ .gitignore | 1 + AGENTS.md | 76 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 ++- config.py | 19 +++++++++++ current-task.md | 72 +++++++++++++++++++++++++++++++++++++++++ main.py | 8 ++++- modules/reminders.py | 72 ++++++++++++++++++++++++++++++++++++++++- 8 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100644 current-task.md diff --git a/.env.example b/.env.example index 4f8fe6c..07cceb8 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,6 @@ MESSAGE_TIME_FORMAT=%H:%M TELEGRAM_PROXY=socks5://127.0.0.1:1080 REMINDER_MIN_HOURS=2 REMINDER_MAX_HOURS=3 +REMINDER_START_HOUR=7 +REMINDER_END_HOUR=23 +REMINDER_TIMEZONE=Europe/Moscow diff --git a/.gitignore b/.gitignore index e6ba304..5ff6931 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ wheels/ # Local environment .env +!AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..adc32f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +## Project Overview + +This repository contains a small Telegram bot for writing quick memoir/journal entries into Obsidian daily notes and syncing the vault through Git. + +Core flow: + +- `main.py` starts an aiogram polling bot. +- `config.py` loads required settings from environment variables and `.env` via `python-dotenv`. +- `modules/auth.py` restricts message handling to `ALLOWED_USER_IDS`. +- `modules/journal.py` appends accepted text messages to the configured daily note, then runs Git commands in the Obsidian vault. +- `modules/reminders.py` periodically sends random reminder questions to allowed users. + +## Runtime And Dependencies + +- Python version is `3.14`; keep `.python-version`, `pyproject.toml`, and `Dockerfile` aligned if changing it. +- Dependency management uses `uv` and `uv.lock`. +- Runtime dependencies are declared in `pyproject.toml`; update the lockfile after dependency changes. +- The bot requires a `.env` file based on `.env.example` for local execution. + +## Common Commands + +- Install/sync dependencies: `uv sync` +- Run locally: `uv run python main.py` +- Basic syntax/import check: `uv run python -m compileall main.py config.py modules` +- Build container image: `docker build -t memoir-bot .` + +There is currently no dedicated test suite, linter, formatter, or type checker configured in the repository. Do not invent tool commands in documentation or final responses unless you add the corresponding config/dependency. + +## Environment Variables + +Required: + +- `BOT_TOKEN`: Telegram bot token. +- `ALLOWED_USER_IDS`: comma-separated Telegram user IDs allowed to write notes and receive reminders. +- `OBSIDIAN_VAULT_PATH`: path to the Obsidian vault; this directory must be a Git repository for sync to work. + +Optional with defaults: + +- `DAILY_NOTES_DIR`: defaults to `03 Journal`. +- `NOTE_PATH_FORMAT`: defaults to `%Y/%m/%d.%m.%y.md`. +- `MESSAGE_TIME_FORMAT`: defaults to `%H:%M`. +- `TELEGRAM_PROXY`: optional aiohttp proxy URL. +- `REMINDER_MIN_HOURS`: defaults to `2`. +- `REMINDER_MAX_HOURS`: defaults to `3`. +- `REMINDER_START_HOUR`: defaults to `7`. +- `REMINDER_END_HOUR`: defaults to `23`. +- `REMINDER_TIMEZONE`: defaults to `Europe/Moscow`. + +Never commit real `.env` files, bot tokens, user IDs, proxy credentials, vault paths that are private, or SSH material. + +## Coding Guidelines + +- Keep the project simple; prefer small, direct changes over abstractions. +- Preserve the existing async aiogram style. +- Keep blocking filesystem and subprocess work out of the event loop; `Journal.append()` currently delegates sync work via `asyncio.to_thread()`. +- Use `pathlib.Path` for filesystem paths. +- Keep user-facing bot messages and README content in Russian unless there is a clear reason to change language. +- Be careful with `modules/journal.py`: `_git()` runs in `OBSIDIAN_VAULT_PATH`, not in this repository. +- Do not add broad exception swallowing around journal writes unless failures are still logged or surfaced; silent note loss is worse than a visible error. +- Keep reminder intervals in seconds internally and hours in environment variables unless intentionally changing the public config surface. +- Reminder delivery is limited to `REMINDER_START_HOUR <= current hour < REMINDER_END_HOUR` in `REMINDER_TIMEZONE`; manual note writes should delay the next reminder by at least one hour. + +## Git And Data Safety + +- This repo's Git history is separate from the configured Obsidian vault Git repository. +- Changes to journal sync behavior can affect user notes. Treat `git pull --rebase`, `git add`, `git commit`, and `git push` behavior as user-data-critical. +- Avoid destructive Git commands in both this repo and the vault. +- If modifying sync logic, consider edge cases: no staged changes, merge/rebase conflicts, missing vault repository, push failures, concurrent messages, and paths with spaces. + +## Verification Notes + +- After code changes, run at least `uv run python -m compileall main.py config.py modules` when Python 3.14 and dependencies are available. +- For behavior touching Telegram or a real Obsidian vault, prefer unit-level isolation/mocking if adding tests; do not require real tokens or real remote Git repositories for automated checks. +- If verification cannot run because Python 3.14 or `uv` is unavailable, state that explicitly in the final response. diff --git a/README.md b/README.md index d15232c..adf47ea 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,14 @@ MESSAGE_TIME_FORMAT=%H:%M TELEGRAM_PROXY=socks5://user:password@127.0.0.1:1080 REMINDER_MIN_HOURS=2 REMINDER_MAX_HOURS=3 +REMINDER_START_HOUR=7 +REMINDER_END_HOUR=23 +REMINDER_TIMEZONE=Europe/Moscow ``` `TELEGRAM_PROXY` необязателен. Без него бот подключается к Telegram напрямую. -Напоминания отправляются разрешенным пользователям через случайный интервал от `REMINDER_MIN_HOURS` до `REMINDER_MAX_HOURS`. +Напоминания отправляются разрешенным пользователям через случайный интервал от `REMINDER_MIN_HOURS` до `REMINDER_MAX_HOURS`, только в окне от `REMINDER_START_HOUR` до `REMINDER_END_HOUR` по `REMINDER_TIMEZONE`. По умолчанию это 07:00-23:00 по Москве. Если пользователь сам написал заметку, следующее напоминание откладывается минимум на 1 час. ## Запуск diff --git a/config.py b/config.py index bf2581c..8a3be5f 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from os import getenv from pathlib import Path +from zoneinfo import ZoneInfo from dotenv import load_dotenv @@ -18,6 +19,9 @@ class Config: telegram_proxy: str | None reminder_min_seconds: int reminder_max_seconds: int + reminder_start_hour: int + reminder_end_hour: int + reminder_timezone: ZoneInfo def _required(name: str) -> str: @@ -36,7 +40,19 @@ def _hours_to_seconds(value: str) -> int: # return int(float(value)) +def _hour(name: str, default: str) -> int: + hour = int(getenv(name, default)) + if not 0 <= hour <= 23: + raise RuntimeError(f"{name} должен быть числом от 0 до 23") + return hour + + def load_config() -> Config: + reminder_start_hour = _hour("REMINDER_START_HOUR", "7") + reminder_end_hour = _hour("REMINDER_END_HOUR", "23") + if reminder_start_hour >= reminder_end_hour: + raise RuntimeError("REMINDER_START_HOUR должен быть меньше REMINDER_END_HOUR") + return Config( bot_token=_required("BOT_TOKEN"), allowed_user_ids=_user_ids(_required("ALLOWED_USER_IDS")), @@ -47,4 +63,7 @@ def load_config() -> Config: telegram_proxy=getenv("TELEGRAM_PROXY"), reminder_min_seconds=_hours_to_seconds(getenv("REMINDER_MIN_HOURS", "2")), reminder_max_seconds=_hours_to_seconds(getenv("REMINDER_MAX_HOURS", "3")), + reminder_start_hour=reminder_start_hour, + reminder_end_hour=reminder_end_hour, + reminder_timezone=ZoneInfo(getenv("REMINDER_TIMEZONE", "Europe/Moscow")), ) diff --git a/current-task.md b/current-task.md new file mode 100644 index 0000000..65ef629 --- /dev/null +++ b/current-task.md @@ -0,0 +1,72 @@ +# План изменений: расписание напоминаний + +## Цели + +1. Добавить расписание для напоминаний: бот должен писать только в явно разрешенные часы. +2. По умолчанию разрешенное окно: с 07:00 до 23:00 по МСК. +3. Если пользователь сам написал заметку, следующий reminder должен быть отложен минимум на 1 час. + +## План + +### 1. Расширить конфиг + +- Добавить переменные в `.env.example` и `README.md`: + - `REMINDER_START_HOUR` + - `REMINDER_END_HOUR` + - `REMINDER_TIMEZONE` +- Значения по умолчанию: + - `REMINDER_START_HOUR=7` + - `REMINDER_END_HOUR=23` + - `REMINDER_TIMEZONE=Europe/Moscow` +- В `config.py` парсить часы как `int`, timezone через `zoneinfo.ZoneInfo`. + +### 2. Изменить модель напоминаний + +- Сейчас `send_reminders()` просто спит случайное число секунд между min/max. +- Нужно рассчитывать следующий момент отправки с учетом: + - разрешенного окна времени; + - случайного интервала `REMINDER_MIN_HOURS` / `REMINDER_MAX_HOURS`; + - timezone; + - ночного периода, когда отправка запрещена. + +### 3. Добавить сброс после ручной заметки + +- В `main.py` после успешного `journal.append(...)` сообщать планировщику, что пользователь сам написал заметку. +- Простой вариант: передать в `send_reminders()` общий объект состояния, например `ReminderScheduler` или `ReminderState`. +- В состоянии хранить `last_manual_entry_at`. +- При ручной записи следующий reminder должен быть не раньше `last_manual_entry_at + 1 hour`. + +### 4. Сделать отдельную логику планирования + +- Минимально оставить `send_reminders()` в `modules/reminders.py`, но добавить функции: + - `is_allowed_time(now, start_hour, end_hour)` + - `next_window_start(now, start_hour, timezone)` + - `calculate_next_reminder(...)` +- Не усложнять классами без необходимости, но небольшой объект состояния между handler и background task может быть оправдан. + +### 5. Учесть edge cases + +- Если сейчас ночь, следующий reminder переносится на 07:00 МСК. +- Если случайный интервал попадает за 23:00, reminder переносится на следующее 07:00. +- Если пользователь написал заметку в 22:30, минимальная задержка до 23:30, но окно уже закрыто, значит следующий reminder не раньше 07:00 следующего дня. +- Если пользователь написал ночью, reminder не раньше max(`+1 hour`, ближайшее 07:00). +- Если `REMINDER_START_HOUR >= REMINDER_END_HOUR`, лучше сразу падать с понятной ошибкой, чтобы не поддерживать ночные окна. + +### 6. Обновить документацию + +- README на русском: описать новое окно отправки. +- `.env.example`: добавить новые переменные. +- При необходимости обновить `AGENTS.md`, чтобы зафиксировать новые env vars и правило про timezone. + +### 7. Проверка + +- Запустить `uv run python -m compileall main.py config.py modules`. +- Если добавятся чистые функции расчета времени, можно добавить тесты позже, но сейчас отдельной тестовой инфраструктуры нет. + +## Лог реализации + +- Шаг 1: добавлены настройки `REMINDER_START_HOUR`, `REMINDER_END_HOUR`, `REMINDER_TIMEZONE` в `config.py`; часы валидируются как диапазон 0-23, окно должно быть start < end. +- Шаг 2: в `modules/reminders.py` добавлен минимальный `ReminderState`, расчет следующего reminder с учетом окна времени и пробуждение sleep через `asyncio.Event` при ручной записи. +- Шаг 3: `main.py` создает `ReminderState`, передает его в `send_reminders()` и вызывает `mark_manual_entry()` после успешного сохранения заметки. +- Шаг 4: обновлены `.env.example`, `README.md` и `AGENTS.md` с новыми настройками окна напоминаний, timezone и правилом задержки после ручной заметки. +- Проверка: `uv run python -m compileall main.py config.py modules` прошла успешно; `uv` создал локальную `.venv`, она игнорируется `.gitignore`. diff --git a/main.py b/main.py index 660fd10..0e414d4 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from aiogram.types import Message from config import load_config from modules.auth import AllowedUserFilter from modules.journal import Journal -from modules.reminders import send_reminders +from modules.reminders import ReminderState, send_reminders async def run_bot() -> None: @@ -31,10 +31,12 @@ async def run_bot() -> None: note_path_format=config.note_path_format, message_time_format=config.message_time_format, ) + reminder_state = ReminderState() @dispatcher.message(AllowedUserFilter(config.allowed_user_ids), F.text) async def save_message(message: Message) -> None: await journal.append(message.text or "") + reminder_state.mark_manual_entry(config.reminder_timezone) await message.answer("Записал") @dispatcher.message() @@ -48,6 +50,10 @@ async def run_bot() -> None: user_ids=config.allowed_user_ids, min_seconds=config.reminder_min_seconds, max_seconds=config.reminder_max_seconds, + start_hour=config.reminder_start_hour, + end_hour=config.reminder_end_hour, + timezone=config.reminder_timezone, + state=reminder_state, ) ) try: diff --git a/modules/reminders.py b/modules/reminders.py index 8938526..cf33598 100644 --- a/modules/reminders.py +++ b/modules/reminders.py @@ -1,6 +1,8 @@ import asyncio import logging import random +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from aiogram import Bot @@ -15,14 +17,82 @@ QUESTIONS = ( ) +class ReminderState: + def __init__(self) -> None: + self.last_manual_entry_at: datetime | None = None + self.changed = asyncio.Event() + + def mark_manual_entry(self, timezone: ZoneInfo) -> None: + self.last_manual_entry_at = datetime.now(timezone) + self.changed.set() + + +def _is_allowed_time(now: datetime, start_hour: int, end_hour: int) -> bool: + return start_hour <= now.hour < end_hour + + +def _next_window_start(now: datetime, start_hour: int) -> datetime: + if now.hour < start_hour: + return now.replace(hour=start_hour, minute=0, second=0, microsecond=0) + tomorrow = now + timedelta(days=1) + return tomorrow.replace(hour=start_hour, minute=0, second=0, microsecond=0) + + +def _move_to_allowed_time(now: datetime, start_hour: int, end_hour: int) -> datetime: + if _is_allowed_time(now, start_hour, end_hour): + return now + return _next_window_start(now, start_hour) + + +def _next_reminder_at( + now: datetime, + state: ReminderState, + min_seconds: int, + max_seconds: int, + start_hour: int, + end_hour: int, +) -> datetime: + if not _is_allowed_time(now, start_hour, end_hour): + candidate = _move_to_allowed_time(now, start_hour, end_hour) + else: + candidate = now + timedelta(seconds=random.randint(min_seconds, max_seconds)) + + if state.last_manual_entry_at: + candidate = max(candidate, state.last_manual_entry_at + timedelta(hours=1)) + + return _move_to_allowed_time(candidate, start_hour, end_hour) + + async def send_reminders( bot: Bot, user_ids: set[int], min_seconds: int, max_seconds: int, + start_hour: int, + end_hour: int, + timezone: ZoneInfo, + state: ReminderState, ) -> None: while True: - await asyncio.sleep(random.randint(min_seconds, max_seconds)) + now = datetime.now(timezone) + next_reminder_at = _next_reminder_at( + now=now, + state=state, + min_seconds=min_seconds, + max_seconds=max_seconds, + start_hour=start_hour, + end_hour=end_hour, + ) + sleep_seconds = max(0, (next_reminder_at - now).total_seconds()) + + try: + await asyncio.wait_for(state.changed.wait(), timeout=sleep_seconds) + except TimeoutError: + pass + else: + state.changed.clear() + continue + question = random.choice(QUESTIONS) for user_id in user_ids: