add reminder schedule
This commit is contained in:
@@ -7,3 +7,6 @@ MESSAGE_TIME_FORMAT=%H:%M
|
|||||||
TELEGRAM_PROXY=socks5://127.0.0.1:1080
|
TELEGRAM_PROXY=socks5://127.0.0.1:1080
|
||||||
REMINDER_MIN_HOURS=2
|
REMINDER_MIN_HOURS=2
|
||||||
REMINDER_MAX_HOURS=3
|
REMINDER_MAX_HOURS=3
|
||||||
|
REMINDER_START_HOUR=7
|
||||||
|
REMINDER_END_HOUR=23
|
||||||
|
REMINDER_TIMEZONE=Europe/Moscow
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ wheels/
|
|||||||
|
|
||||||
# Local environment
|
# Local environment
|
||||||
.env
|
.env
|
||||||
|
!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.
|
||||||
@@ -16,11 +16,14 @@ MESSAGE_TIME_FORMAT=%H:%M
|
|||||||
TELEGRAM_PROXY=socks5://user:password@127.0.0.1:1080
|
TELEGRAM_PROXY=socks5://user:password@127.0.0.1:1080
|
||||||
REMINDER_MIN_HOURS=2
|
REMINDER_MIN_HOURS=2
|
||||||
REMINDER_MAX_HOURS=3
|
REMINDER_MAX_HOURS=3
|
||||||
|
REMINDER_START_HOUR=7
|
||||||
|
REMINDER_END_HOUR=23
|
||||||
|
REMINDER_TIMEZONE=Europe/Moscow
|
||||||
```
|
```
|
||||||
|
|
||||||
`TELEGRAM_PROXY` необязателен. Без него бот подключается к Telegram напрямую.
|
`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 час.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ class Config:
|
|||||||
telegram_proxy: str | None
|
telegram_proxy: str | None
|
||||||
reminder_min_seconds: int
|
reminder_min_seconds: int
|
||||||
reminder_max_seconds: int
|
reminder_max_seconds: int
|
||||||
|
reminder_start_hour: int
|
||||||
|
reminder_end_hour: int
|
||||||
|
reminder_timezone: ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
def _required(name: str) -> str:
|
def _required(name: str) -> str:
|
||||||
@@ -36,7 +40,19 @@ def _hours_to_seconds(value: str) -> int:
|
|||||||
# return int(float(value))
|
# 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:
|
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(
|
return Config(
|
||||||
bot_token=_required("BOT_TOKEN"),
|
bot_token=_required("BOT_TOKEN"),
|
||||||
allowed_user_ids=_user_ids(_required("ALLOWED_USER_IDS")),
|
allowed_user_ids=_user_ids(_required("ALLOWED_USER_IDS")),
|
||||||
@@ -47,4 +63,7 @@ def load_config() -> Config:
|
|||||||
telegram_proxy=getenv("TELEGRAM_PROXY"),
|
telegram_proxy=getenv("TELEGRAM_PROXY"),
|
||||||
reminder_min_seconds=_hours_to_seconds(getenv("REMINDER_MIN_HOURS", "2")),
|
reminder_min_seconds=_hours_to_seconds(getenv("REMINDER_MIN_HOURS", "2")),
|
||||||
reminder_max_seconds=_hours_to_seconds(getenv("REMINDER_MAX_HOURS", "3")),
|
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")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -11,7 +11,7 @@ from aiogram.types import Message
|
|||||||
from config import load_config
|
from config import load_config
|
||||||
from modules.auth import AllowedUserFilter
|
from modules.auth import AllowedUserFilter
|
||||||
from modules.journal import Journal
|
from modules.journal import Journal
|
||||||
from modules.reminders import send_reminders
|
from modules.reminders import ReminderState, send_reminders
|
||||||
|
|
||||||
|
|
||||||
async def run_bot() -> None:
|
async def run_bot() -> None:
|
||||||
@@ -31,10 +31,12 @@ async def run_bot() -> None:
|
|||||||
note_path_format=config.note_path_format,
|
note_path_format=config.note_path_format,
|
||||||
message_time_format=config.message_time_format,
|
message_time_format=config.message_time_format,
|
||||||
)
|
)
|
||||||
|
reminder_state = ReminderState()
|
||||||
|
|
||||||
@dispatcher.message(AllowedUserFilter(config.allowed_user_ids), F.text)
|
@dispatcher.message(AllowedUserFilter(config.allowed_user_ids), F.text)
|
||||||
async def save_message(message: Message) -> None:
|
async def save_message(message: Message) -> None:
|
||||||
await journal.append(message.text or "")
|
await journal.append(message.text or "")
|
||||||
|
reminder_state.mark_manual_entry(config.reminder_timezone)
|
||||||
await message.answer("Записал")
|
await message.answer("Записал")
|
||||||
|
|
||||||
@dispatcher.message()
|
@dispatcher.message()
|
||||||
@@ -48,6 +50,10 @@ async def run_bot() -> None:
|
|||||||
user_ids=config.allowed_user_ids,
|
user_ids=config.allowed_user_ids,
|
||||||
min_seconds=config.reminder_min_seconds,
|
min_seconds=config.reminder_min_seconds,
|
||||||
max_seconds=config.reminder_max_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:
|
try:
|
||||||
|
|||||||
+71
-1
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from aiogram import Bot
|
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(
|
async def send_reminders(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
user_ids: set[int],
|
user_ids: set[int],
|
||||||
min_seconds: int,
|
min_seconds: int,
|
||||||
max_seconds: int,
|
max_seconds: int,
|
||||||
|
start_hour: int,
|
||||||
|
end_hour: int,
|
||||||
|
timezone: ZoneInfo,
|
||||||
|
state: ReminderState,
|
||||||
) -> None:
|
) -> None:
|
||||||
while True:
|
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)
|
question = random.choice(QUESTIONS)
|
||||||
|
|
||||||
for user_id in user_ids:
|
for user_id in user_ids:
|
||||||
|
|||||||
Reference in New Issue
Block a user