diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e3f1847 --- /dev/null +++ b/tests/__init__.py @@ -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)) diff --git a/tests/test_notifications_integration.py b/tests/test_notifications_integration.py new file mode 100644 index 0000000..e1c2c44 --- /dev/null +++ b/tests/test_notifications_integration.py @@ -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) diff --git a/tgbot/__init__.py b/tgbot/__init__.py new file mode 100644 index 0000000..898605f --- /dev/null +++ b/tgbot/__init__.py @@ -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", +] diff --git a/tgbot/models.py b/tgbot/models.py new file mode 100644 index 0000000..725abab --- /dev/null +++ b/tgbot/models.py @@ -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 diff --git a/tgbot/notifications.py b/tgbot/notifications.py new file mode 100644 index 0000000..1b2231f --- /dev/null +++ b/tgbot/notifications.py @@ -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) diff --git a/tgbot/repository.py b/tgbot/repository.py new file mode 100644 index 0000000..658e2f6 --- /dev/null +++ b/tgbot/repository.py @@ -0,0 +1,62 @@ +"""Reminder storage interfaces.""" +from __future__ import annotations + +from datetime import datetime +from threading import RLock +from typing import Dict, Iterable, List + +from .models import Reminder + + +class ReminderRepository: + """Abstract base for reminder persistence.""" + + def add(self, reminder: Reminder) -> None: + raise NotImplementedError + + def remove(self, reminder_id: str) -> None: + raise NotImplementedError + + def get(self, reminder_id: str) -> Reminder: + raise NotImplementedError + + def list_due(self, moment: datetime) -> Iterable[Reminder]: + raise NotImplementedError + + def update(self, reminder: Reminder) -> None: + raise NotImplementedError + + +class InMemoryReminderRepository(ReminderRepository): + """Thread-safe in-memory repository used in tests and local runs.""" + + def __init__(self) -> None: + self._reminders: Dict[str, Reminder] = {} + self._lock = RLock() + + def add(self, reminder: Reminder) -> None: + with self._lock: + self._reminders[reminder.reminder_id] = reminder + + def remove(self, reminder_id: str) -> None: + with self._lock: + self._reminders.pop(reminder_id, None) + + def get(self, reminder_id: str) -> Reminder: + with self._lock: + return self._reminders[reminder_id] + + def list_due(self, moment: datetime) -> Iterable[Reminder]: + with self._lock: + return [rem for rem in self._reminders.values() if rem.scheduled_at <= moment] + + def update(self, reminder: Reminder) -> None: + with self._lock: + if reminder.reminder_id not in self._reminders: + raise KeyError(f"Reminder {reminder.reminder_id} is not stored") + self._reminders[reminder.reminder_id] = reminder + + # Helper for tests + def all(self) -> List[Reminder]: + with self._lock: + return list(self._reminders.values()) diff --git a/tgbot/scheduler.py b/tgbot/scheduler.py new file mode 100644 index 0000000..91b694e --- /dev/null +++ b/tgbot/scheduler.py @@ -0,0 +1,49 @@ +"""Polling scheduler built around a lightweight worker thread.""" +from __future__ import annotations + +import threading +from datetime import datetime, timedelta +from typing import Callable, Optional + +from .notifications import NotificationService + + +class ReminderScheduler: + """Runs :class:`NotificationService` periodically.""" + + def __init__( + self, + service: NotificationService, + poll_interval: timedelta = timedelta(minutes=1), + now_func: Callable[[], datetime] = datetime.utcnow, + ) -> None: + self._service = service + self._poll_interval = poll_interval + self._now_func = now_func + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def _run_loop(self) -> None: + while not self._stop.is_set(): + self.run_pending() + self._stop.wait(self._poll_interval.total_seconds()) + + def stop(self) -> None: + if not self._thread: + return + self._stop.set() + self._thread.join() + self._thread = None + + def run_pending(self) -> None: + """Execute a single polling cycle.""" + + now = self._now_func() + self._service.send_due_reminders(now) diff --git a/tgbot/telegram.py b/tgbot/telegram.py new file mode 100644 index 0000000..1e5232b --- /dev/null +++ b/tgbot/telegram.py @@ -0,0 +1,53 @@ +"""Minimal Telegram Bot API client with pluggable HTTP backend.""" +from __future__ import annotations + +import json +from typing import Any, Dict, Optional +from urllib import request + + +class TelegramHTTPClient: + """Simple HTTP client wrapper that posts JSON payloads.""" + + def __init__(self) -> None: + self._opener = request.build_opener() + + def post_json(self, url: str, payload: Dict[str, Any], timeout: Optional[float] = None) -> Dict[str, Any]: + data = json.dumps(payload).encode("utf-8") + req = request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with self._opener.open(req, timeout=timeout) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) + + +class TelegramAPIClient: + """Client used by the notification service to talk to Telegram.""" + + def __init__(self, token: str, http_client: Optional[TelegramHTTPClient] = None, timeout: Optional[float] = 10.0) -> None: + self._token = token + self._http_client = http_client or TelegramHTTPClient() + self._timeout = timeout + + def _url(self, method: str) -> str: + return f"https://api.telegram.org/bot{self._token}/{method}" + + def send_message(self, chat_id: int, text: str, reply_markup: Optional[Dict[str, Any]] = None, disable_notification: bool = False) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "disable_notification": disable_notification, + } + if reply_markup: + payload["reply_markup"] = reply_markup + return self._http_client.post_json(self._url("sendMessage"), payload, timeout=self._timeout) + + def answer_callback(self, callback_query_id: str, text: Optional[str] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = {"callback_query_id": callback_query_id} + if text: + payload["text"] = text + return self._http_client.post_json(self._url("answerCallbackQuery"), payload, timeout=self._timeout) + + def edit_message(self, chat_id: int, message_id: int, text: str) -> Dict[str, Any]: + payload = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": "HTML"} + return self._http_client.post_json(self._url("editMessageText"), payload, timeout=self._timeout)