-
Notifications
You must be signed in to change notification settings - Fork 0
Add notification scheduler and Telegram reminder integrations #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
libhet
wants to merge
1
commit into
main
Choose a base branch
from
codex/setup-periodic-job-scheduler-for-notifications
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| """Test package initialisation to ensure project root is importable.""" | ||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | ||
| if str(PROJECT_ROOT) not in sys.path: | ||
| sys.path.insert(0, str(PROJECT_ROOT)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| from datetime import datetime, time, timedelta | ||
| import time as time_module | ||
|
|
||
| import pytest | ||
|
|
||
| from tgbot.models import NotificationSettings, Reminder | ||
| from tgbot.notifications import InMemoryNotificationSettingsProvider, NotificationService | ||
| from tgbot.repository import InMemoryReminderRepository | ||
| from tgbot.scheduler import ReminderScheduler | ||
| from tgbot.telegram import TelegramAPIClient | ||
|
|
||
|
|
||
| class FakeHTTPClient: | ||
| def __init__(self) -> None: | ||
| self.requests = [] | ||
|
|
||
| def post_json(self, url, payload, timeout=None): # pragma: no cover - trivial wrapper | ||
| self.requests.append({"url": url, "payload": payload}) | ||
| return {"ok": True, "result": {"url": url}} | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def notification_setup(): | ||
| repository = InMemoryReminderRepository() | ||
| settings = NotificationSettings( | ||
| user_id=1, | ||
| frequency=timedelta(hours=6), | ||
| quiet_hours_start=time(22, 0), | ||
| quiet_hours_end=time(7, 0), | ||
| ) | ||
| settings_provider = InMemoryNotificationSettingsProvider([settings]) | ||
| http = FakeHTTPClient() | ||
| telegram = TelegramAPIClient("token", http_client=http) | ||
| service = NotificationService(repository, telegram, settings_provider) | ||
| reminder = Reminder( | ||
| reminder_id="rem-1", | ||
| user_id=1, | ||
| chat_id=100, | ||
| message="Пора повторить упражнения", | ||
| scheduled_at=datetime(2023, 1, 1, 21, 0), | ||
| ) | ||
| service.add_reminder(reminder) | ||
| return repository, settings_provider, http, service, reminder | ||
|
|
||
|
|
||
| def test_quiet_hours_prevents_sending(notification_setup): | ||
| repository, settings_provider, http, service, reminder = notification_setup | ||
| now = datetime(2023, 1, 1, 23, 0) | ||
|
|
||
| service.send_due_reminders(now) | ||
|
|
||
| assert http.requests == [] | ||
| stored = repository.get(reminder.reminder_id) | ||
| assert stored.scheduled_at == datetime(2023, 1, 2, 7, 0) | ||
|
|
||
|
|
||
| def test_send_due_notification_with_inline_keyboard(notification_setup): | ||
| repository, settings_provider, http, service, reminder = notification_setup | ||
| now = datetime(2023, 1, 2, 10, 0) | ||
| # ensure reminder is due | ||
| repository.get(reminder.reminder_id).scheduled_at = now - timedelta(minutes=1) | ||
|
|
||
| service.send_due_reminders(now) | ||
|
|
||
| assert len(http.requests) == 1 | ||
| request_payload = http.requests[0]["payload"] | ||
| assert request_payload["chat_id"] == reminder.chat_id | ||
| assert "inline_keyboard" in request_payload["reply_markup"] | ||
| buttons = request_payload["reply_markup"]["inline_keyboard"] | ||
| assert any(button[0]["callback_data"].startswith("snooze:") for button in buttons) | ||
| assert any(button[0]["callback_data"].startswith("reschedule:") for button in buttons) | ||
|
|
||
| stored = repository.get(reminder.reminder_id) | ||
| assert stored.scheduled_at == now + settings_provider.get_settings(reminder.user_id).frequency | ||
|
|
||
|
|
||
| def test_handle_snooze_callback(notification_setup): | ||
| repository, settings_provider, http, service, reminder = notification_setup | ||
| now = datetime(2023, 1, 2, 10, 0) | ||
|
|
||
| service.handle_callback("cbq", f"snooze:{reminder.reminder_id}:900", now=now) | ||
|
|
||
| stored = repository.get(reminder.reminder_id) | ||
| assert stored.scheduled_at == now + timedelta(seconds=900) | ||
| assert http.requests[-1]["url"].endswith("answerCallbackQuery") | ||
|
|
||
|
|
||
| def test_scheduler_runs(notification_setup): | ||
| repository, settings_provider, http, service, reminder = notification_setup | ||
| now = datetime(2023, 1, 2, 9, 0) | ||
| repository.get(reminder.reminder_id).scheduled_at = now - timedelta(minutes=1) | ||
| calls = [] | ||
|
|
||
| original_send = service.send_due_reminders | ||
|
|
||
| def wrapped(moment): | ||
| calls.append(moment) | ||
| return original_send(moment) | ||
|
|
||
| service.send_due_reminders = wrapped # type: ignore[assignment] | ||
|
|
||
| scheduler = ReminderScheduler(service, poll_interval=timedelta(milliseconds=50), now_func=lambda: now) | ||
| scheduler.start() | ||
| time_module.sleep(0.12) | ||
| scheduler.stop() | ||
|
|
||
| assert calls, "Scheduler did not execute send_due_reminders" | ||
| assert any(req["url"].endswith("sendMessage") for req in http.requests) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| """Notification scheduling and Telegram integration helpers for the spaced repetition bot.""" | ||
|
|
||
| from .models import NotificationSettings, Reminder | ||
| from .notifications import NotificationService | ||
| from .repository import InMemoryReminderRepository, ReminderRepository | ||
| from .scheduler import ReminderScheduler | ||
| from .telegram import TelegramAPIClient | ||
|
|
||
| __all__ = [ | ||
| "NotificationService", | ||
| "NotificationSettings", | ||
| "Reminder", | ||
| "ReminderScheduler", | ||
| "ReminderRepository", | ||
| "InMemoryReminderRepository", | ||
| "TelegramAPIClient", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| """Data models for notification scheduling.""" | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from datetime import datetime, time, timedelta | ||
| from typing import Optional | ||
|
|
||
|
|
||
| @dataclass | ||
| class NotificationSettings: | ||
| """Preferences that influence when reminders are delivered.""" | ||
|
|
||
| user_id: int | ||
| frequency: timedelta = field(default=timedelta(hours=24)) | ||
| quiet_hours_start: Optional[time] = None | ||
| quiet_hours_end: Optional[time] = None | ||
|
|
||
| def is_quiet_time(self, moment: datetime) -> bool: | ||
| """Return ``True`` when the provided ``moment`` falls inside quiet hours.""" | ||
|
|
||
| if self.quiet_hours_start is None or self.quiet_hours_end is None: | ||
| return False | ||
|
|
||
| quiet_start = self.quiet_hours_start | ||
| quiet_end = self.quiet_hours_end | ||
| current_time = moment.time() | ||
|
|
||
| if quiet_start == quiet_end: | ||
| # Same value means quiet hours disabled (covers 0 minutes). | ||
| return False | ||
|
|
||
| if quiet_start < quiet_end: | ||
| return quiet_start <= current_time < quiet_end | ||
|
|
||
| # Quiet hours wrap around midnight (e.g. 22:00 - 07:00) | ||
| return current_time >= quiet_start or current_time < quiet_end | ||
|
|
||
|
|
||
| @dataclass | ||
| class Reminder: | ||
| """Represents a scheduled notification.""" | ||
|
|
||
| reminder_id: str | ||
| user_id: int | ||
| chat_id: int | ||
| message: str | ||
| scheduled_at: datetime | ||
| reply_markup: Optional[dict] = None | ||
|
|
||
| def snooze(self, delay: timedelta) -> None: | ||
| """Postpone the reminder by ``delay`` duration.""" | ||
|
|
||
| self.scheduled_at = self.scheduled_at + delay | ||
|
|
||
| def postpone(self, new_time: datetime) -> None: | ||
| """Move the reminder to an absolute ``new_time``.""" | ||
|
|
||
| self.scheduled_at = new_time |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| """Notification orchestration logic.""" | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime, timedelta | ||
| from typing import Dict, Iterable, List, Optional, Protocol | ||
|
|
||
| from .models import NotificationSettings, Reminder | ||
| from .repository import ReminderRepository | ||
| from .telegram import TelegramAPIClient | ||
|
|
||
|
|
||
| class NotificationSettingsProvider(Protocol): | ||
| """Returns per-user notification preferences.""" | ||
|
|
||
| def get_settings(self, user_id: int) -> NotificationSettings: | ||
| ... | ||
|
|
||
|
|
||
| class InMemoryNotificationSettingsProvider: | ||
| """Simple settings store used in tests.""" | ||
|
|
||
| def __init__(self, settings: Optional[Iterable[NotificationSettings]] = None) -> None: | ||
| self._store: Dict[int, NotificationSettings] = {} | ||
| if settings: | ||
| for pref in settings: | ||
| self._store[pref.user_id] = pref | ||
|
|
||
| def get_settings(self, user_id: int) -> NotificationSettings: | ||
| if user_id not in self._store: | ||
| self._store[user_id] = NotificationSettings(user_id=user_id) | ||
| return self._store[user_id] | ||
|
|
||
| def upsert(self, settings: NotificationSettings) -> None: | ||
| self._store[settings.user_id] = settings | ||
|
|
||
|
|
||
| class NotificationService: | ||
| """High-level service that schedules and sends notifications.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| repository: ReminderRepository, | ||
| telegram_client: TelegramAPIClient, | ||
| settings_provider: NotificationSettingsProvider, | ||
| snooze_options: Optional[Iterable[timedelta]] = None, | ||
| ) -> None: | ||
| self._repository = repository | ||
| self._telegram = telegram_client | ||
| self._settings_provider = settings_provider | ||
| self._snooze_options: List[timedelta] = list(snooze_options) if snooze_options else [ | ||
| timedelta(minutes=15), | ||
| timedelta(hours=1), | ||
| ] | ||
|
|
||
| def _build_keyboard(self, reminder: Reminder) -> Dict[str, List[List[Dict[str, str]]]]: | ||
| rows: List[List[Dict[str, str]]] = [] | ||
| for option in self._snooze_options: | ||
| seconds = int(option.total_seconds()) | ||
| text = self._format_snooze_label(option) | ||
| rows.append([ | ||
| { | ||
| "text": text, | ||
| "callback_data": f"snooze:{reminder.reminder_id}:{seconds}", | ||
| } | ||
| ]) | ||
| rows.append( | ||
| [ | ||
| { | ||
| "text": "Перенести на завтра", | ||
| "callback_data": f"reschedule:{reminder.reminder_id}:tomorrow", | ||
| } | ||
| ] | ||
| ) | ||
| return {"inline_keyboard": rows} | ||
|
|
||
| @staticmethod | ||
| def _format_snooze_label(delta: timedelta) -> str: | ||
| minutes = int(delta.total_seconds() // 60) | ||
| if minutes < 60: | ||
| return f"Отложить на {minutes} мин" | ||
| hours = minutes // 60 | ||
| return f"Отложить на {hours} ч" | ||
|
|
||
| def send_due_reminders(self, now: Optional[datetime] = None) -> None: | ||
| moment = now or datetime.utcnow() | ||
| due = list(self._repository.list_due(moment)) | ||
| for reminder in due: | ||
| settings = self._settings_provider.get_settings(reminder.user_id) | ||
| if settings.is_quiet_time(moment): | ||
| reminder.postpone(self._next_allowed_time(moment, settings)) | ||
| self._repository.update(reminder) | ||
| continue | ||
| keyboard = self._build_keyboard(reminder) | ||
| reminder.reply_markup = keyboard | ||
| self._telegram.send_message(reminder.chat_id, reminder.message, reply_markup=keyboard) | ||
| next_time = moment + settings.frequency | ||
| reminder.postpone(next_time) | ||
| self._repository.update(reminder) | ||
|
|
||
| def _next_allowed_time(self, now: datetime, settings: NotificationSettings) -> datetime: | ||
| quiet_start = settings.quiet_hours_start | ||
| quiet_end = settings.quiet_hours_end | ||
| if quiet_start is None or quiet_end is None: | ||
| return now | ||
|
|
||
| if quiet_start < quiet_end: | ||
| candidate = datetime.combine(now.date(), quiet_end) | ||
| if candidate <= now: | ||
| candidate = candidate + timedelta(days=1) | ||
| return candidate | ||
|
|
||
| # Quiet hours wrap midnight | ||
| if now.time() < quiet_end: | ||
| return datetime.combine(now.date(), quiet_end) | ||
| candidate = datetime.combine(now.date() + timedelta(days=1), quiet_end) | ||
| return candidate | ||
|
|
||
| def handle_callback(self, callback_query_id: str, data: str, now: Optional[datetime] = None) -> None: | ||
| moment = now or datetime.utcnow() | ||
| action, reminder_id, *rest = data.split(":") | ||
| reminder = self._repository.get(reminder_id) | ||
| settings = self._settings_provider.get_settings(reminder.user_id) | ||
|
|
||
| if action == "snooze": | ||
| seconds = int(rest[0]) if rest else int(settings.frequency.total_seconds()) | ||
| reminder.postpone(moment + timedelta(seconds=seconds)) | ||
| self._telegram.answer_callback(callback_query_id, text="Напоминание отложено") | ||
| elif action == "reschedule": | ||
| when = rest[0] if rest else "tomorrow" | ||
| if when == "tomorrow": | ||
| target = (moment + timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0) | ||
| else: | ||
| target = datetime.fromisoformat(when) | ||
| reminder.postpone(target) | ||
| self._telegram.answer_callback(callback_query_id, text="Перенос выполнен") | ||
| else: | ||
| self._telegram.answer_callback(callback_query_id, text="Неизвестное действие") | ||
| return | ||
|
|
||
| self._repository.update(reminder) | ||
|
|
||
| def add_reminder(self, reminder: Reminder) -> None: | ||
| """Persist a new reminder and attach default inline keyboard.""" | ||
|
|
||
| reminder.reply_markup = self._build_keyboard(reminder) | ||
| self._repository.add(reminder) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The callback parser splits the payload with
data.split(":"), which consumes every colon. When a reschedule button ever encodes an absolute timestamp likereschedule:rem-1:2023-01-02T10:30,rest[0]becomes"2023-01-02T10"and the minutes/seconds after the first colon are dropped beforedatetime.fromisoformatis invoked later. The reminder will therefore be rescheduled for 10:00 instead of the requested 10:30. Consider splitting with a maximum of two separators (e.g.split(":", 2)) or otherwise preserving the remainder of the string so full ISO datetimes survive intact.Useful? React with 👍 / 👎.