feat: add Kurigram property notification bot
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
config.toml
|
||||||
|
data/
|
||||||
36
README.md
Normal file
36
README.md
Normal 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
19
config.example.toml
Normal 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
33
pyproject.toml
Normal 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"]
|
||||||
1
src/property_bot/__init__.py
Normal file
1
src/property_bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Property notification bot package."""
|
||||||
112
src/property_bot/bot.py
Normal file
112
src/property_bot/bot.py
Normal 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)
|
||||||
91
src/property_bot/config.py
Normal file
91
src/property_bot/config.py
Normal 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,
|
||||||
|
)
|
||||||
72
src/property_bot/consumer.py
Normal file
72
src/property_bot/consumer.py
Normal 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)
|
||||||
83
src/property_bot/listings.py
Normal file
83
src/property_bot/listings.py
Normal 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
72
src/property_bot/main.py
Normal 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()
|
||||||
88
src/property_bot/repository.py
Normal file
88
src/property_bot/repository.py
Normal 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])
|
||||||
54
tests/test_bot_delivery.py
Normal file
54
tests/test_bot_delivery.py
Normal 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
41
tests/test_config.py
Normal 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
72
tests/test_consumer.py
Normal 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
34
tests/test_listings.py
Normal 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
21
tests/test_repository.py
Normal 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
203
uv.lock
generated
Normal 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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user