feat: add Kurigram property notification bot

This commit is contained in:
Azimkin
2026-09-22 17:25:03 +02:00
commit bbaa093a85
17 changed files with 1038 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
config.toml
data/

36
README.md Normal file
View File

@@ -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
```

19
config.example.toml Normal file
View File

@@ -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"

33
pyproject.toml Normal file
View File

@@ -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"]

View File

@@ -0,0 +1 @@
"""Property notification bot package."""

112
src/property_bot/bot.py Normal file
View File

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

View File

@@ -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,
)

View File

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

View File

@@ -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]

72
src/property_bot/main.py Normal file
View File

@@ -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()

View File

@@ -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])

View File

@@ -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()

41
tests/test_config.py Normal file
View File

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

72
tests/test_consumer.py Normal file
View File

@@ -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

34
tests/test_listings.py Normal file
View File

@@ -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

21
tests/test_repository.py Normal file
View File

@@ -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()

203
uv.lock generated Normal file
View File

@@ -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" },
]