From bbaa093a8511f17a55a82ba6a458cc930c68f4ca Mon Sep 17 00:00:00 2001 From: Azimkin <112006710+Azimkin@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:25:03 +0200 Subject: [PATCH] feat: add Kurigram property notification bot --- .gitignore | 6 + README.md | 36 ++++++ config.example.toml | 19 +++ pyproject.toml | 33 ++++++ src/property_bot/__init__.py | 1 + src/property_bot/bot.py | 112 ++++++++++++++++++ src/property_bot/config.py | 91 +++++++++++++++ src/property_bot/consumer.py | 72 ++++++++++++ src/property_bot/listings.py | 83 ++++++++++++++ src/property_bot/main.py | 72 ++++++++++++ src/property_bot/repository.py | 88 ++++++++++++++ tests/test_bot_delivery.py | 54 +++++++++ tests/test_config.py | 41 +++++++ tests/test_consumer.py | 72 ++++++++++++ tests/test_listings.py | 34 ++++++ tests/test_repository.py | 21 ++++ uv.lock | 203 +++++++++++++++++++++++++++++++++ 17 files changed, 1038 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 pyproject.toml create mode 100644 src/property_bot/__init__.py create mode 100644 src/property_bot/bot.py create mode 100644 src/property_bot/config.py create mode 100644 src/property_bot/consumer.py create mode 100644 src/property_bot/listings.py create mode 100644 src/property_bot/main.py create mode 100644 src/property_bot/repository.py create mode 100644 tests/test_bot_delivery.py create mode 100644 tests/test_config.py create mode 100644 tests/test_consumer.py create mode 100644 tests/test_listings.py create mode 100644 tests/test_repository.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e9c3b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +config.toml +data/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b989b2 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Kurigram Property Bot + +Бот получает события `listing.new` из WebSocket API проекта `scrap-property` и +рассылает новые объявления всем подписанным Telegram-чатам. + +## Подготовка + +1. Заполните Telegram credentials в созданном `config.toml`. Для повторного + развёртывания используйте `config.example.toml`; рабочий конфиг и + SQLite-база игнорируются Git. +2. Установите зависимости: `uv sync --python 3.12`. +3. Запустите: `uv run property-bot --config config.toml`. + +На Linux бот запускается через `uvloop`. На Windows `uvloop` не поддерживается, +поэтому для локальной разработки автоматически используется стандартный цикл +`asyncio`. + +`scrap-property` должен быть доступен до запуска бота; его WebSocket URL +задаётся в `[property_api]`. + +## Команды + +- `/start` — включить уведомления; +- `/subscribe` — включить уведомления повторно; +- `/unsubscribe` — отключить уведомления; +- `/status` — показать состояние подписки; админам — также состояние API; +- `/test` — отправить вызывающему тестовую карточку. + +Подписка относится к чату, из которого вызвана команда. Любой чат может +подписаться. Администраторы задаются списком Telegram user ID в TOML. + +## Проверка + +```powershell +uv run pytest +``` diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..fc78f21 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,19 @@ +[telegram] +# Obtain these values at https://my.telegram.org/apps and from @BotFather. +api_id = 123456 +api_hash = "replace-with-api-hash" +bot_token = "123456:replace-with-bot-token" + +[admins] +# Telegram user IDs. Admins receive extended /status output. +user_ids = [123456789] + +[property_api] +# The WebSocket endpoint exposed by scrap-property. +websocket_url = "ws://127.0.0.1:3000/ws" +connect_timeout_seconds = 15 +reconnect_initial_delay_seconds = 1 +reconnect_max_delay_seconds = 60 + +[database] +path = "data/property-bot.sqlite3" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4860330 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "kurigram-property-bot" +version = "0.1.0" +description = "Telegram bot that forwards new property listings from scrap-property" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "aiosqlite>=0.20,<1", + "kurigram>=2.1.35", + "tgcrypto>=1.2,<2", + "websockets>=15,<16", + "uvloop>=0.22,<1; sys_platform != 'win32'", +] + +[dependency-groups] +dev = [ + "pytest>=8.3,<9", + "pytest-asyncio>=0.25,<1", +] + +[project.scripts] +property-bot = "property_bot.main:run" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[build-system] +requires = ["hatchling>=1.26"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/property_bot"] diff --git a/src/property_bot/__init__.py b/src/property_bot/__init__.py new file mode 100644 index 0000000..19abebe --- /dev/null +++ b/src/property_bot/__init__.py @@ -0,0 +1 @@ +"""Property notification bot package.""" diff --git a/src/property_bot/bot.py b/src/property_bot/bot.py new file mode 100644 index 0000000..4cf18b0 --- /dev/null +++ b/src/property_bot/bot.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import logging + +from pyrogram import Client, filters +from pyrogram.handlers import MessageHandler +from pyrogram.types import Message + +from .config import AppConfig +from .consumer import ListingEventConsumer +from .listings import Listing, format_listing +from .repository import SubscriptionRepository + +logger = logging.getLogger(__name__) + + +class TelegramPropertyBot: + def __init__( + self, + client: Client, + config: AppConfig, + repository: SubscriptionRepository, + consumer: ListingEventConsumer, + ) -> None: + self._client = client + self._config = config + self._repository = repository + self._consumer = consumer + + def register_handlers(self) -> None: + self._client.add_handler(MessageHandler(self._start, filters.command("start"))) + self._client.add_handler(MessageHandler(self._subscribe, filters.command("subscribe"))) + self._client.add_handler(MessageHandler(self._unsubscribe, filters.command("unsubscribe"))) + self._client.add_handler(MessageHandler(self._status, filters.command("status"))) + self._client.add_handler(MessageHandler(self._test, filters.command("test"))) + + @staticmethod + def _chat_and_user(message: Message) -> tuple[int, int | None]: + return message.chat.id, message.from_user.id if message.from_user else None + + async def _start(self, _: Client, message: Message) -> None: + chat_id, user_id = self._chat_and_user(message) + await self._repository.activate(chat_id, user_id) + await self._client.send_message(chat_id, "Уведомления о новых квартирах включены. /unsubscribe — отключить.") + + async def _subscribe(self, _: Client, message: Message) -> None: + chat_id, user_id = self._chat_and_user(message) + await self._repository.activate(chat_id, user_id) + await self._client.send_message(chat_id, "Уведомления включены.") + + async def _unsubscribe(self, _: Client, message: Message) -> None: + chat_id, _ = self._chat_and_user(message) + await self._repository.deactivate(chat_id) + await self._client.send_message(chat_id, "Уведомления отключены. Вернуться можно через /subscribe.") + + async def _status(self, _: Client, message: Message) -> None: + chat_id, user_id = self._chat_and_user(message) + subscription = await self._repository.get(chat_id) + state = "включены" if subscription and subscription.is_active else "выключены" + lines = [f"Ваши уведомления: {state}."] + if user_id in self._config.admin_user_ids: + status = self._consumer.status + lines.extend( + [ + "", + "Состояние API:", + f"URL: {self._config.property_api.websocket_url}", + f"Подключение: {'есть' if status.connected else 'нет'}", + f"Активных подписок: {await self._repository.active_count()}", + f"Последнее событие: {status.last_event_at.isoformat() if status.last_event_at else 'ещё не было'}", + f"Последняя ошибка: {status.last_error or 'нет'}", + ] + ) + await self._client.send_message(chat_id, "\n".join(lines)) + + async def _test(self, _: Client, message: Message) -> None: + chat_id, _ = self._chat_and_user(message) + await self._send_listing(chat_id, self._test_listing()) + + @staticmethod + def _test_listing() -> Listing: + return Listing( + external_id="test", + title="Тестовое объявление", + url="https://example.com/test-listing", + price=5000, + currency="PLN", + additional_fees=800, + area=65, + rooms=3, + city="Warszawa", + district="Mokotów", + street=None, + images=(), + ) + + async def notify_listing(self, listing: Listing) -> None: + for chat_id in await self._repository.active_chat_ids(): + await self._send_listing(chat_id, listing) + + async def _send_listing(self, chat_id: int, listing: Listing) -> None: + text = format_listing(listing) + if listing.images: + try: + await self._client.send_photo(chat_id, listing.images[0], caption=text) + return + except Exception as error: # Telegram errors vary by transport and media URL. + logger.warning("Could not send listing photo to %s; using text fallback: %s", chat_id, error) + try: + await self._client.send_message(chat_id, text, disable_web_page_preview=True) + except Exception as error: # A failed recipient must not block the rest of the broadcast. + logger.warning("Could not send listing to %s: %s", chat_id, error) diff --git a/src/property_bot/config.py b/src/property_bot/config.py new file mode 100644 index 0000000..f4d0ff3 --- /dev/null +++ b/src/property_bot/config.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import tomllib +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class TelegramConfig: + api_id: int + api_hash: str + bot_token: str + + +@dataclass(frozen=True, slots=True) +class PropertyApiConfig: + websocket_url: str + connect_timeout_seconds: float + reconnect_initial_delay_seconds: float + reconnect_max_delay_seconds: float + + +@dataclass(frozen=True, slots=True) +class AppConfig: + telegram: TelegramConfig + admin_user_ids: frozenset[int] + property_api: PropertyApiConfig + database_path: Path + + +def _section(data: dict[str, object], name: str) -> dict[str, object]: + value = data.get(name) + if not isinstance(value, dict): + raise ValueError(f"Missing [{name}] section") + return value + + +def _text(section: dict[str, object], name: str) -> str: + value = section.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + return value.strip() + + +def _positive_number(section: dict[str, object], name: str) -> float: + value = section.get(name) + if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0: + raise ValueError(f"{name} must be a positive number") + return float(value) + + +def load_config(path: str | Path) -> AppConfig: + config_path = Path(path) + with config_path.open("rb") as file: + data = tomllib.load(file) + + telegram_data = _section(data, "telegram") + api_id = telegram_data.get("api_id") + if not isinstance(api_id, int) or isinstance(api_id, bool) or api_id <= 0: + raise ValueError("telegram.api_id must be a positive integer") + + admins_data = _section(data, "admins") + raw_admin_ids = admins_data.get("user_ids") + if not isinstance(raw_admin_ids, list) or any( + not isinstance(item, int) or isinstance(item, bool) or item <= 0 for item in raw_admin_ids + ): + raise ValueError("admins.user_ids must be a list of positive integers") + + api_data = _section(data, "property_api") + websocket_url = _text(api_data, "websocket_url") + if urlparse(websocket_url).scheme not in {"ws", "wss"}: + raise ValueError("property_api.websocket_url must use ws:// or wss://") + + database_data = _section(data, "database") + database_path = Path(_text(database_data, "path")) + return AppConfig( + telegram=TelegramConfig( + api_id=api_id, + api_hash=_text(telegram_data, "api_hash"), + bot_token=_text(telegram_data, "bot_token"), + ), + admin_user_ids=frozenset(raw_admin_ids), + property_api=PropertyApiConfig( + websocket_url=websocket_url, + connect_timeout_seconds=_positive_number(api_data, "connect_timeout_seconds"), + reconnect_initial_delay_seconds=_positive_number(api_data, "reconnect_initial_delay_seconds"), + reconnect_max_delay_seconds=_positive_number(api_data, "reconnect_max_delay_seconds"), + ), + database_path=database_path, + ) diff --git a/src/property_bot/consumer.py b/src/property_bot/consumer.py new file mode 100644 index 0000000..014fc82 --- /dev/null +++ b/src/property_bot/consumer.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import UTC, datetime +import json +import logging +from typing import Awaitable, Callable + +from websockets.asyncio.client import connect +from websockets.exceptions import WebSocketException + +from .config import PropertyApiConfig +from .listings import Listing, listing_from_event + +logger = logging.getLogger(__name__) +ListingSink = Callable[[Listing], Awaitable[None]] + + +@dataclass(slots=True) +class ConnectionStatus: + connected: bool = False + last_connected_at: datetime | None = None + last_event_at: datetime | None = None + last_error: str | None = None + + +class ListingEventConsumer: + def __init__(self, config: PropertyApiConfig, on_listing: ListingSink) -> None: + self._config = config + self._on_listing = on_listing + self.status = ConnectionStatus() + + async def handle_message(self, raw_message: str | bytes) -> bool: + try: + payload = json.loads(raw_message) + except (TypeError, json.JSONDecodeError): + logger.warning("Ignoring invalid WebSocket JSON event") + return False + listing = listing_from_event(payload) + if listing is None: + return False + await self._on_listing(listing) + self.status.last_event_at = datetime.now(UTC) + return True + + async def run(self) -> None: + delay = self._config.reconnect_initial_delay_seconds + while True: + try: + async with connect( + self._config.websocket_url, + open_timeout=self._config.connect_timeout_seconds, + ping_interval=20, + ping_timeout=20, + ) as websocket: + self.status.connected = True + self.status.last_connected_at = datetime.now(UTC) + self.status.last_error = None + delay = self._config.reconnect_initial_delay_seconds + logger.info("Connected to property API: %s", self._config.websocket_url) + async for message in websocket: + await self.handle_message(message) + except asyncio.CancelledError: + raise + except (OSError, WebSocketException, TimeoutError) as error: + self.status.last_error = str(error) + logger.warning("Property API connection failed; retrying in %.1fs: %s", delay, error) + finally: + self.status.connected = False + await asyncio.sleep(delay) + delay = min(delay * 2, self._config.reconnect_max_delay_seconds) diff --git a/src/property_bot/listings.py b/src/property_bot/listings.py new file mode 100644 index 0000000..5a11860 --- /dev/null +++ b/src/property_bot/listings.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class Listing: + external_id: str + url: str + title: str + price: float | None + currency: str | None + additional_fees: float | None + area: float | None + rooms: float | None + city: str | None + district: str | None + street: str | None + images: tuple[str, ...] + + +def _optional_text(value: object) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _optional_number(value: object) -> float | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + return None + + +def listing_from_event(payload: object) -> Listing | None: + if not isinstance(payload, dict) or payload.get("type") != "listing.new": + return None + raw_listing = payload.get("listing") + if not isinstance(raw_listing, dict): + return None + external_id = _optional_text(raw_listing.get("externalId")) + url = _optional_text(raw_listing.get("url")) + title = _optional_text(raw_listing.get("title")) + if not external_id or not url or not title: + return None + location = raw_listing.get("location") + location_data: dict[str, Any] = location if isinstance(location, dict) else {} + raw_images = raw_listing.get("images") + images = tuple(item.strip() for item in raw_images if isinstance(item, str) and item.strip()) if isinstance(raw_images, list) else () + return Listing( + external_id=external_id, + url=url, + title=title, + price=_optional_number(raw_listing.get("price")), + currency=_optional_text(raw_listing.get("currency")), + additional_fees=_optional_number(raw_listing.get("additionalFees")), + area=_optional_number(raw_listing.get("area")), + rooms=_optional_number(raw_listing.get("rooms")), + city=_optional_text(location_data.get("city")), + district=_optional_text(location_data.get("district")), + street=_optional_text(location_data.get("street")), + images=images, + ) + + +def _number(value: float) -> str: + return str(int(value)) if value.is_integer() else f"{value:g}" + + +def format_listing(listing: Listing) -> str: + price = "Не указана" + if listing.price is not None: + price = _number(listing.price) + (f" {listing.currency}" if listing.currency else "") + details = [f"Цена: {price}"] + if listing.additional_fees is not None: + details.append(f"Доплаты: {_number(listing.additional_fees)}") + if listing.area is not None: + details.append(f"Площадь: {_number(listing.area)} м²") + if listing.rooms is not None: + details.append(f"Комнаты: {_number(listing.rooms)}") + address = ", ".join(item for item in (listing.city, listing.district, listing.street) if item) + if address: + details.append(f"Адрес: {address}") + text = "\n".join([f"Новая квартира: {listing.title}", *details, listing.url]) + return text[:1024] diff --git a/src/property_bot/main.py b/src/property_bot/main.py new file mode 100644 index 0000000..156eab8 --- /dev/null +++ b/src/property_bot/main.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +from pyrogram import Client + +from .bot import TelegramPropertyBot +from .config import load_config +from .consumer import ListingEventConsumer +from .listings import Listing +from .repository import SubscriptionRepository + + +async def main(config_path: Path) -> None: + config = load_config(config_path) + repository = SubscriptionRepository(config.database_path) + await repository.open() + client = Client( + "property-bot", + api_id=config.telegram.api_id, + api_hash=config.telegram.api_hash, + bot_token=config.telegram.bot_token, + no_updates=False, + ) + bot_holder: dict[str, TelegramPropertyBot] = {} + + async def notify(listing: Listing) -> None: + await bot_holder["bot"].notify_listing(listing) + + consumer = ListingEventConsumer(config.property_api, notify) + bot = TelegramPropertyBot(client, config, repository, consumer) + bot_holder["bot"] = bot + bot.register_handlers() + consumer_task: asyncio.Task[None] | None = None + try: + await client.start() + consumer_task = asyncio.create_task(consumer.run(), name="property-api-consumer") + logging.getLogger(__name__).info("Telegram bot started") + await asyncio.Future[None]() + finally: + if consumer_task is not None: + consumer_task.cancel() + try: + await consumer_task + except asyncio.CancelledError: + pass + await client.stop() + await repository.close() + + +def run() -> None: + parser = argparse.ArgumentParser(description="Forward scrap-property listings to Telegram") + parser.add_argument("--config", default="config.toml", type=Path, help="Path to TOML configuration") + arguments = parser.parse_args() + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + if sys.platform == "win32": + asyncio.run(main(arguments.config)) + return + import uvloop + + uvloop.run(main(arguments.config)) + + +if __name__ == "__main__": + run() diff --git a/src/property_bot/repository.py b/src/property_bot/repository.py new file mode 100644 index 0000000..d9c185b --- /dev/null +++ b/src/property_bot/repository.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import aiosqlite + + +@dataclass(frozen=True, slots=True) +class Subscription: + chat_id: int + user_id: int | None + is_active: bool + + +class SubscriptionRepository: + def __init__(self, path: Path) -> None: + self._path = path + self._connection: aiosqlite.Connection | None = None + + async def open(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._connection = await aiosqlite.connect(self._path) + await self._connection.execute( + """ + CREATE TABLE IF NOT EXISTS subscriptions ( + chat_id INTEGER PRIMARY KEY, + user_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + await self._connection.commit() + + async def close(self) -> None: + if self._connection is not None: + await self._connection.close() + self._connection = None + + @property + def connection(self) -> aiosqlite.Connection: + if self._connection is None: + raise RuntimeError("Subscription repository is not open") + return self._connection + + async def activate(self, chat_id: int, user_id: int | None) -> None: + now = datetime.now(UTC).isoformat() + await self.connection.execute( + """ + INSERT INTO subscriptions (chat_id, user_id, is_active, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET + user_id = excluded.user_id, + is_active = 1, + updated_at = excluded.updated_at + """, + (chat_id, user_id, now, now), + ) + await self.connection.commit() + + async def deactivate(self, chat_id: int) -> bool: + cursor = await self.connection.execute( + "UPDATE subscriptions SET is_active = 0, updated_at = ? WHERE chat_id = ? AND is_active = 1", + (datetime.now(UTC).isoformat(), chat_id), + ) + await self.connection.commit() + return cursor.rowcount > 0 + + async def get(self, chat_id: int) -> Subscription | None: + cursor = await self.connection.execute( + "SELECT chat_id, user_id, is_active FROM subscriptions WHERE chat_id = ?", (chat_id,) + ) + row = await cursor.fetchone() + if row is None: + return None + return Subscription(chat_id=row[0], user_id=row[1], is_active=bool(row[2])) + + async def active_chat_ids(self) -> list[int]: + cursor = await self.connection.execute("SELECT chat_id FROM subscriptions WHERE is_active = 1") + return [row[0] for row in await cursor.fetchall()] + + async def active_count(self) -> int: + cursor = await self.connection.execute("SELECT COUNT(*) FROM subscriptions WHERE is_active = 1") + row = await cursor.fetchone() + return int(row[0]) diff --git a/tests/test_bot_delivery.py b/tests/test_bot_delivery.py new file mode 100644 index 0000000..4fcf080 --- /dev/null +++ b/tests/test_bot_delivery.py @@ -0,0 +1,54 @@ +from pathlib import Path + +import pytest + +from property_bot.bot import TelegramPropertyBot +from property_bot.config import AppConfig, PropertyApiConfig, TelegramConfig +from property_bot.consumer import ListingEventConsumer +from property_bot.listings import Listing +from property_bot.repository import SubscriptionRepository + + +class FakeClient: + def __init__(self, fail_photo: bool = False) -> None: + self.fail_photo = fail_photo + self.photos: list[tuple[int, str, str]] = [] + self.messages: list[tuple[int, str]] = [] + + async def send_photo(self, chat_id: int, photo: str, caption: str) -> None: + if self.fail_photo: + raise RuntimeError("media unavailable") + self.photos.append((chat_id, photo, caption)) + + async def send_message(self, chat_id: int, text: str, **_: object) -> None: + self.messages.append((chat_id, text)) + + +def app_config(path: Path) -> AppConfig: + return AppConfig( + telegram=TelegramConfig(1, "hash", "token"), + admin_user_ids=frozenset(), + property_api=PropertyApiConfig("ws://localhost:3000/ws", 1, 1, 2), + database_path=path, + ) + + +def listing() -> Listing: + return Listing("id", "https://example.test", "Title", 1, "PLN", None, None, None, None, None, None, ("https://example.test/image.jpg",)) + + +@pytest.mark.asyncio +async def test_photo_failure_falls_back_to_text_and_keeps_other_recipients(tmp_path: Path) -> None: + repository = SubscriptionRepository(tmp_path / "db.sqlite3") + await repository.open() + try: + await repository.activate(1, 1) + await repository.activate(2, 2) + client = FakeClient(fail_photo=True) + consumer = ListingEventConsumer(app_config(tmp_path / "db.sqlite3").property_api, lambda _: None) # type: ignore[arg-type] + bot = TelegramPropertyBot(client, app_config(tmp_path / "db.sqlite3"), repository, consumer) # type: ignore[arg-type] + await bot.notify_listing(listing()) + assert [chat_id for chat_id, _ in client.messages] == [1, 2] + assert client.photos == [] + finally: + await repository.close() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..76118d4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,41 @@ +from pathlib import Path + +import pytest + +from property_bot.config import load_config + + +def write_config(path: Path, websocket_url: str = "ws://127.0.0.1:3000/ws") -> None: + path.write_text( + f'''[telegram] +api_id = 1 +api_hash = "hash" +bot_token = "token" +[admins] +user_ids = [10, 20] +[property_api] +websocket_url = "{websocket_url}" +connect_timeout_seconds = 15 +reconnect_initial_delay_seconds = 1 +reconnect_max_delay_seconds = 30 +[database] +path = "data/bot.sqlite3" +''', + encoding="utf-8", + ) + + +def test_loads_valid_toml_config(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + write_config(path) + config = load_config(path) + assert config.telegram.api_id == 1 + assert config.admin_user_ids == frozenset({10, 20}) + assert config.property_api.websocket_url == "ws://127.0.0.1:3000/ws" + + +def test_rejects_non_websocket_url(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + write_config(path, "http://127.0.0.1:3000/ws") + with pytest.raises(ValueError, match="ws://"): + load_config(path) diff --git a/tests/test_consumer.py b/tests/test_consumer.py new file mode 100644 index 0000000..762c992 --- /dev/null +++ b/tests/test_consumer.py @@ -0,0 +1,72 @@ +import json +import asyncio + +import pytest +from websockets.asyncio.server import serve + +from property_bot.config import PropertyApiConfig +from property_bot.consumer import ListingEventConsumer + + +@pytest.mark.asyncio +async def test_consumer_ignores_service_ready_and_forwards_listing() -> None: + received = [] + + async def sink(listing: object) -> None: + received.append(listing) + + consumer = ListingEventConsumer( + PropertyApiConfig("ws://localhost:3000/ws", 1, 1, 2), sink # type: ignore[arg-type] + ) + assert await consumer.handle_message(json.dumps({"type": "service.ready"})) is False + assert ( + await consumer.handle_message( + json.dumps( + { + "type": "listing.new", + "listing": { + "externalId": "abc", + "url": "https://example.test/a", + "title": "A", + "images": [], + }, + } + ) + ) + ) is True + assert len(received) == 1 + assert consumer.status.last_event_at is not None + + +@pytest.mark.asyncio +async def test_consumer_reads_listing_from_websocket_server(unused_tcp_port: int) -> None: + received = asyncio.Event() + + async def sink(_: object) -> None: + received.set() + + async def handler(websocket: object) -> None: + await websocket.send( # type: ignore[attr-defined] + json.dumps( + { + "type": "listing.new", + "listing": { + "externalId": "from-socket", + "url": "https://example.test/a", + "title": "From socket", + }, + } + ) + ) + + async with serve(handler, "127.0.0.1", unused_tcp_port): + consumer = ListingEventConsumer( + PropertyApiConfig(f"ws://127.0.0.1:{unused_tcp_port}", 1, 0.01, 0.02), sink # type: ignore[arg-type] + ) + task = asyncio.create_task(consumer.run()) + try: + await asyncio.wait_for(received.wait(), timeout=2) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/tests/test_listings.py b/tests/test_listings.py new file mode 100644 index 0000000..08e0568 --- /dev/null +++ b/tests/test_listings.py @@ -0,0 +1,34 @@ +from property_bot.listings import format_listing, listing_from_event + + +def event() -> dict[str, object]: + return { + "type": "listing.new", + "listing": { + "externalId": "abc", + "url": "https://example.test/listing", + "title": "Просторная квартира", + "price": 4500, + "currency": "PLN", + "additionalFees": 700, + "area": 62.5, + "rooms": 3, + "location": {"city": "Warszawa", "district": "Wola", "street": "Prosta"}, + "images": ["https://example.test/image.jpg"], + }, + } + + +def test_parses_and_formats_listing() -> None: + listing = listing_from_event(event()) + assert listing is not None + assert listing.images == ("https://example.test/image.jpg",) + text = format_listing(listing) + assert "4500 PLN" in text + assert "Warszawa, Wola, Prosta" in text + assert "https://example.test/listing" in text + + +def test_ignores_ready_and_incomplete_events() -> None: + assert listing_from_event({"type": "service.ready"}) is None + assert listing_from_event({"type": "listing.new", "listing": {}}) is None diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..d4521e0 --- /dev/null +++ b/tests/test_repository.py @@ -0,0 +1,21 @@ +from pathlib import Path + +import pytest + +from property_bot.repository import SubscriptionRepository + + +@pytest.mark.asyncio +async def test_subscription_lifecycle(tmp_path: Path) -> None: + repository = SubscriptionRepository(tmp_path / "subscriptions.sqlite3") + await repository.open() + try: + await repository.activate(100, 200) + assert await repository.active_chat_ids() == [100] + assert (await repository.get(100)).is_active is True # type: ignore[union-attr] + assert await repository.deactivate(100) is True + assert await repository.active_chat_ids() == [] + await repository.activate(100, 201) + assert (await repository.get(100)).user_id == 201 # type: ignore[union-attr] + finally: + await repository.close() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e5a016f --- /dev/null +++ b/uv.lock @@ -0,0 +1,203 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <3.14" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "kurigram" +version = "2.2.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyaes" }, + { name = "python-socks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/48/65efaaaaf24dc62d6f6ef046843952ff17b5358f9c7119303ceee6d53657/kurigram-2.2.26.tar.gz", hash = "sha256:83b64fd7ab37122d920b1e7c0e221b599d7c52dd5d0d24c79956f01273020c49", size = 748176, upload-time = "2026-09-12T17:45:08.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/e0/a00020c21725550c9309e6e03787acf7236de2f284c8d9bc57644ae2160a/kurigram-2.2.26-py3-none-any.whl", hash = "sha256:f53cee6119a579b74620f45b5f83d543cd3bd510a8a6a1b4b3852e21603241e9", size = 6044442, upload-time = "2026-09-12T17:45:06.456Z" }, +] + +[[package]] +name = "kurigram-property-bot" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "kurigram" }, + { name = "tgcrypto" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, + { name = "websockets" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20,<1" }, + { name = "kurigram", specifier = ">=2.1.35" }, + { name = "tgcrypto", specifier = ">=1.2,<2" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22,<1" }, + { name = "websockets", specifier = ">=15,<16" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3,<9" }, + { name = "pytest-asyncio", specifier = ">=0.25,<1" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" } + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156, upload-time = "2025-03-25T06:22:28.883Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694, upload-time = "2025-03-25T06:22:27.807Z" }, +] + +[[package]] +name = "python-socks" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ad/484ffb79532517b11a90af38647c38652224650b31a7ae1cedd5a418d8ab/python_socks-3.1.1.tar.gz", hash = "sha256:8d3e817cdbe858dc0bb8c8fdc8e79b6ce37acce110d33374c6f57a675cc9029e", size = 232781, upload-time = "2026-09-08T13:03:31.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/23/2c2cef1b4313c55d1713201acd4ee2043fb2cf22e546ca9d36bf4317faea/python_socks-3.1.1-py3-none-any.whl", hash = "sha256:327e0d6378702c73a7790bf732e9f01392f17b48c7348a50b5bd1f710c2df1be", size = 49566, upload-time = "2026-09-08T13:03:29.604Z" }, +] + +[[package]] +name = "tgcrypto" +version = "1.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" } + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +]