Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions tests/__init__.py
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))
108 changes: 108 additions & 0 deletions tests/test_notifications_integration.py
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)
17 changes: 17 additions & 0 deletions tgbot/__init__.py
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",
]
58 changes: 58 additions & 0 deletions tgbot/models.py
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
146 changes: 146 additions & 0 deletions tgbot/notifications.py
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)
Comment on lines +118 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve ISO timestamps when parsing reschedule callbacks

The callback parser splits the payload with data.split(":"), which consumes every colon. When a reschedule button ever encodes an absolute timestamp like reschedule:rem-1:2023-01-02T10:30, rest[0] becomes "2023-01-02T10" and the minutes/seconds after the first colon are dropped before datetime.fromisoformat is 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 👍 / 👎.

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)
Loading