diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e66ce..7e3e246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.3.0] — 2026-05-21 + +### Changed + +- Internal architecture reorganized around explicit domain packages: + - TON account and balance helpers are now unified under `pyfragment.domains.tonapi.account` + - service wrappers and operation modules are aligned by domain (`ads`, `purchases`, `giveaways`, `anonymous_numbers`, `marketplace`, `tonapi`) +- Package exports were cleaned up for domain and model packages (`__init__.py`) to provide clearer public symbols. +- Examples and system tests were updated to follow current public import paths and project structure. + +### Fixed + +- `get_cookies_from_browser()` is now patch-friendly in tests (`pyfragment.core.cookies.rookiepy` can be mocked reliably). +- Anonymous number `NOT_OWNED` error message wording was adjusted for test and backward-compatibility with existing matchers. + ## [2026.2.3] — 2026-05-12 ### Fixed @@ -75,7 +90,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required ```python - from pyfragment.utils import get_cookies_from_browser + from pyfragment import get_cookies_from_browser result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... client = FragmentClient(seed="...", api_key="...", cookies=result.cookies) print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies @@ -177,6 +192,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0 [2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3 [2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2 [2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1 diff --git a/README.md b/README.md index 7677515..3b4c4a6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) [![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE) -[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) +[Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) @@ -53,13 +53,20 @@ Requires Python 3.10+. **Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways: -- **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed: +- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed: + + ```bash + pip install "pyfragment[browser]" + ``` + ```python - from pyfragment.utils import get_cookies_from_browser + from pyfragment import get_cookies_from_browser + result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... # result.cookies — dict[str, str] to pass to FragmentClient # result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies ``` + - **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string. Refresh when you get authentication errors. diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index dafcaac..e0c78df 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -11,7 +11,6 @@ import asyncio import json from pyfragment import FragmentClient, GiftsResult -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index f3f5a36..9cc40e2 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -10,7 +10,6 @@ import asyncio import json from pyfragment import FragmentClient, NumbersResult -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index 5fc3e13..b8c4ce3 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -10,7 +10,6 @@ import asyncio import json from pyfragment import FragmentClient, UsernamesResult -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py index 4996d90..066e755 100644 --- a/examples/client/raw_api_call.py +++ b/examples/client/raw_api_call.py @@ -12,7 +12,6 @@ Defaults to the Fragment base URL. import asyncio from pyfragment import FragmentClient -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index bfc6620..7631288 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -8,7 +8,6 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. import asyncio from pyfragment import FragmentClient -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index 80f1a3b..b4b9702 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -9,7 +9,6 @@ Use terminate_sessions() to forcefully end all active Telegram sessions. import asyncio from pyfragment import AnonymousNumberError, FragmentClient -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index 5e4c457..7dc0162 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -13,7 +13,6 @@ from pyfragment import ( FragmentClient, WalletError, ) -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 9fb8565..63526c5 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -10,7 +10,6 @@ Channel can be "@channel", "channel", or "https://t.me/channel". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 7f98ed9..83dfc7a 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -10,7 +10,6 @@ Channel can be "@channel", "channel", or "https://t.me/channel". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index a28cfa7..dab0d48 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -10,7 +10,6 @@ Username can be "@username", "username", or "https://t.me/username". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index dbe5664..a155fc5 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -10,7 +10,6 @@ Username can be "@username", "username", or "https://t.me/username". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index c4fa58f..e44731c 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -15,7 +15,6 @@ from pyfragment import ( UserNotFoundError, WalletError, ) -from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 78c492b..ac06351 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -1,44 +1,30 @@ -# Copyright (c) 2026 bohd4nx -# -# This source code is licensed under the MIT License found in the -# LICENSE file in the root directory of this source tree. - from importlib.metadata import version from pyfragment.client import FragmentClient -from pyfragment.types import ( - AdsRechargeResult, - AdsTopupResult, +from pyfragment.core.cookies import get_cookies_from_browser +from pyfragment.exceptions import ( AnonymousNumberError, ClientError, ConfigurationError, CookieError, - CookieResult, FragmentAPIError, - # exceptions FragmentError, FragmentPageError, - GiftsResult, - LoginCodeResult, - NumbersResult, OperationError, ParseError, - # literal types - PaymentMethod, - PremiumGiveawayResult, - PremiumResult, - StarsGiveawayResult, - # results - StarsResult, - TerminateSessionsResult, TransactionError, UnexpectedError, - UsernamesResult, UserNotFoundError, VerificationError, WalletError, - WalletInfo, ) +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult +from pyfragment.models.cookies import CookieResult +from pyfragment.models.enums import PaymentMethod +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult +from pyfragment.models.wallet import WalletInfo __version__: str = version("pyfragment") @@ -76,4 +62,5 @@ __all__ = [ "UnexpectedError", # literal types "PaymentMethod", + "get_cookies_from_browser", ] diff --git a/pyfragment/client.py b/pyfragment/client.py index a35b9b8..f452581 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -5,46 +5,21 @@ from typing import Any, cast, get_args import httpx -from pyfragment.methods import ( - get_login_code, - giveaway_premium, - giveaway_stars, - purchase_premium, - purchase_stars, - recharge_ads, - search_gifts, - search_numbers, - search_usernames, - terminate_sessions, - toggle_login_codes, - topup_ton, -) -from pyfragment.types import ( - AdsRechargeResult, - AdsTopupResult, - ConfigurationError, - CookieError, - GiftsResult, - LoginCodeResult, - NumbersResult, - PremiumGiveawayResult, - PremiumResult, - StarsGiveawayResult, - StarsResult, - TerminateSessionsResult, - UsernamesResult, - WalletInfo, -) -from pyfragment.types.constants import ( - BASE_HEADERS, - DEFAULT_TIMEOUT, - FRAGMENT_BASE_URL, - REQUIRED_COOKIE_KEYS, - PaymentMethod, - WalletVersion, -) -from pyfragment.utils.api import fragment_request, get_fragment_hash -from pyfragment.utils.wallet import get_wallet_info +from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS +from pyfragment.core.transport import fragment_request, get_fragment_hash +from pyfragment.domains.ads.service import AdsService +from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService +from pyfragment.domains.giveaways.service import GiveawaysService +from pyfragment.domains.marketplace.service import MarketplaceService +from pyfragment.domains.purchases.service import PurchasesService +from pyfragment.domains.tonapi.service import TonapiService +from pyfragment.exceptions import ConfigurationError, CookieError +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult +from pyfragment.models.enums import PaymentMethod, WalletVersion +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult +from pyfragment.models.wallet import WalletInfo class FragmentClient: @@ -120,6 +95,12 @@ class FragmentClient: self.cookies: dict[str, Any] = cast(dict[str, Any], cookies) self.wallet_version: WalletVersion = version # type: ignore[assignment] self.timeout: float = timeout + self.marketplace = MarketplaceService(self) + self.purchases = PurchasesService(self) + self.giveaways = GiveawaysService(self) + self.tonapi = TonapiService(self) + self.anonymous_numbers = AnonymousNumbersService(self) + self.ads = AdsService(self) async def __aenter__(self) -> FragmentClient: return self @@ -148,7 +129,7 @@ class FragmentClient: Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. """ - return await purchase_premium(self, username, months, show_sender, payment_method) + return await self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method) async def purchase_stars( self, @@ -168,7 +149,7 @@ class FragmentClient: Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. """ - return await purchase_stars(self, username, amount, show_sender, payment_method) + return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: """Top up TON to a recipient's Telegram balance. @@ -181,7 +162,7 @@ class FragmentClient: Returns: :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. """ - return await topup_ton(self, username, amount, show_sender) + return await self.ads.topup_ton(username, amount, show_sender=show_sender) async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: """Add funds to your own Telegram Ads account. @@ -194,7 +175,7 @@ class FragmentClient: Returns: :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. """ - return await recharge_ads(self, account, amount) + return await self.ads.recharge_ads(account, amount) async def get_wallet(self) -> WalletInfo: """Return the address, state, and balances of the wallet. @@ -204,7 +185,7 @@ class FragmentClient: (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), ``ton_balance`` in TON, and ``usdt_balance`` in USDT. """ - return await get_wallet_info(self) + return await self.tonapi.get_wallet() async def giveaway_stars( self, @@ -225,7 +206,7 @@ class FragmentClient: :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ - return await giveaway_stars(self, channel, winners, amount, payment_method) + return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method) async def giveaway_premium( self, @@ -246,7 +227,7 @@ class FragmentClient: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ - return await giveaway_premium(self, channel, winners, months, payment_method) + return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method) async def get_login_code(self, number: str) -> LoginCodeResult: """Fetch the current pending login code for an anonymous number. @@ -258,7 +239,7 @@ class FragmentClient: :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), and ``active_sessions`` count. """ - return await get_login_code(self, number) + return await self.anonymous_numbers.get_login_code(number) async def toggle_login_codes(self, number: str, can_receive: bool) -> None: """Enable or disable login code delivery for an anonymous number. @@ -267,7 +248,7 @@ class FragmentClient: number: Phone number with or without leading ``+``. can_receive: ``True`` to allow receiving codes, ``False`` to block them. """ - return await toggle_login_codes(self, number, can_receive) + return await self.anonymous_numbers.toggle_login_codes(number, can_receive) async def terminate_sessions(self, number: str) -> TerminateSessionsResult: """Terminate all active Telegram sessions for an anonymous number. @@ -281,7 +262,7 @@ class FragmentClient: Raises: AnonymousNumberError: If the number is not owned by this account or has no active sessions. """ - return await terminate_sessions(self, number) + return await self.anonymous_numbers.terminate_sessions(number) async def search_usernames( self, @@ -305,7 +286,7 @@ class FragmentClient: :class:`UsernamesResult` with ``items`` (parsed list of item dicts) and ``next_offset_id`` (``None`` on the last page). """ - return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id) + return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id) async def search_numbers( self, @@ -329,7 +310,7 @@ class FragmentClient: :class:`NumbersResult` with ``items`` (parsed list of item dicts) and ``next_offset_id`` (``None`` on the last page). """ - return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id) + return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id) async def search_gifts( self, @@ -361,8 +342,8 @@ class FragmentClient: :class:`GiftsResult` with ``items`` (parsed list of item dicts) and ``next_offset`` (``None`` on the last page). """ - return await search_gifts( - self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset + return await self.marketplace.search_gifts( + query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset ) async def call( diff --git a/pyfragment/core/__init__.py b/pyfragment/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyfragment/types/constants.py b/pyfragment/core/constants.py similarity index 73% rename from pyfragment/types/constants.py rename to pyfragment/core/constants.py index 029c55d..23a3ef0 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/core/constants.py @@ -1,34 +1,21 @@ from __future__ import annotations import json -from typing import Any, Literal +from typing import Any from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 -# Payment methods -PaymentMethod = Literal["ton", "usdt_ton"] - -# Single source of truth for supported wallet versions -WalletVersion = Literal["V4R2", "V5R1"] - -# Wallet class map — used to resolve the correct contract from WALLET_VERSION WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} -# Minimum TON balance threshold required for payment flows. MIN_TON_BALANCE: float = 0.33 - -# USDT (TON) jetton metadata used for payment-method balance checks. USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" MIN_USDT_BALANCE: float = 0.75 -# Default HTTP request timeout in seconds. DEFAULT_TIMEOUT: float = 30.0 -# Required Fragment session cookie keys REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") -# Fragment domain and page URLs -FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy +FRAGMENT_DOMAIN: str = "fragment.com" FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" @@ -38,7 +25,6 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" -# Browsers supported by get_cookies_from_browser() SUPPORTED_BROWSERS: frozenset[str] = frozenset( { "arc", @@ -57,7 +43,6 @@ SUPPORTED_BROWSERS: frozenset[str] = frozenset( } ) -# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( { "platform": "iphone", @@ -72,8 +57,6 @@ DEVICE: str = json.dumps( } ) -# Base HTTP headers — shared across all Fragment API requests. -# Each method merges these with its own "referer" and "x-aj-referer". BASE_HEADERS: dict[str, str] = { "accept": "application/json, text/javascript, */*; q=0.01", "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", diff --git a/pyfragment/utils/cookies.py b/pyfragment/core/cookies.py similarity index 59% rename from pyfragment/utils/cookies.py rename to pyfragment/core/cookies.py index 7f9aaa4..85c568d 100644 --- a/pyfragment/utils/cookies.py +++ b/pyfragment/core/cookies.py @@ -1,41 +1,31 @@ from __future__ import annotations +import importlib from datetime import datetime, timezone from typing import Any -import rookiepy +from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS +from pyfragment.exceptions import CookieError +from pyfragment.models.cookies import CookieResult -from pyfragment.types import CookieError -from pyfragment.types import CookieResult as CookieResult -from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS +try: + import rookiepy # type: ignore[import-not-found] +except Exception: + rookiepy = None def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: - """Extract Fragment session cookies directly from an installed browser. + global rookiepy - Reads the browser's on-disk cookie store (no extension required) and - returns the four cookies required by :class:`~pyfragment.FragmentClient` - along with the session expiry timestamp. - - Args: - browser: Browser name to read cookies from — case-insensitive. Supported values: - ``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``, - ``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``, - ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``. - - Returns: - :class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``). - - Raises: - CookieError: If the browser is not supported, cookies cannot be read, - or required keys are missing. - """ key = browser.lower() if key not in SUPPORTED_BROWSERS: supported = ", ".join(sorted(SUPPORTED_BROWSERS)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) try: + if rookiepy is None: + rookiepy = importlib.import_module("rookiepy") + jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) except Exception as exc: raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc @@ -66,7 +56,4 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: if expires_dt < datetime.now(timezone.utc): raise CookieError(CookieError.EXPIRED.format(expires=expires_iso)) - return CookieResult( - cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, - expires=expires_iso, - ) + return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso) diff --git a/pyfragment/utils/api.py b/pyfragment/core/transport.py similarity index 55% rename from pyfragment/utils/api.py rename to pyfragment/core/transport.py index da00f82..9ebeb32 100644 --- a/pyfragment/utils/api.py +++ b/pyfragment/core/transport.py @@ -7,8 +7,8 @@ from typing import Any, cast import httpx -from pyfragment.types import FragmentPageError, ParseError, VerificationError -from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL +from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL +from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError async def get_fragment_hash( @@ -17,25 +17,6 @@ async def get_fragment_hash( page_url: str, timeout: float = DEFAULT_TIMEOUT, ) -> str: - """Fetch the API hash from a Fragment page. - - Fragment embeds a short-lived hash in each page's HTML that must be - included in every subsequent API request. This function loads the page - as a real browser navigation (not XHR) so Fragment returns full HTML. - - Args: - cookies: Active Fragment session cookies. - headers: Base headers for the relevant Fragment page. - page_url: URL of the Fragment page to fetch the hash from. - timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``. - - Returns: - Lowercase hex hash string. - - Raises: - FragmentPageError: If the page returns a non-200 status or the hash - is not found in the response HTML. - """ page_headers = { k: v for k, v in headers.items() @@ -65,18 +46,6 @@ async def get_fragment_hash( def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]: - """Parse a Fragment API JSON response. - - Args: - response: The HTTP response object. - context: Human-readable name of the API method, used in error messages. - - Returns: - Parsed response as a dict. - - Raises: - ParseError: If the response body cannot be decoded as JSON. - """ try: return cast(dict[str, Any], response.json()) except Exception as exc: @@ -89,21 +58,6 @@ async def fragment_request( headers: dict[str, str], data: dict[str, Any], ) -> dict[str, Any]: - """POST a single request to the Fragment API. - - Builds the ``/api?hash=`` URL, sends the request, and returns the - parsed JSON body. Use this for every API method call — search, - init, state updates, etc. - - Args: - session: Active httpx session with Fragment cookies. - fragment_hash: Short-lived hash from the Fragment page HTML. - headers: Page-specific HTTP headers. - data: Form data payload; must include a ``"method"`` key. - - Returns: - Parsed API response as a dict. - """ for attempt in range(3): resp = await session.post( f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", @@ -127,21 +81,6 @@ async def execute_transaction_request( tx_data: dict[str, Any], fragment_hash: str, ) -> dict[str, Any]: - """Post a transaction request to the Fragment API. - - Args: - session: Active httpx session with Fragment cookies. - headers: Page-specific HTTP headers. - tx_data: Form data payload for the API method. - fragment_hash: Short-lived hash from the Fragment page. - - Returns: - Parsed API response dict containing transaction data. - - Raises: - VerificationError: If Fragment requires KYC verification. - ParseError: If the response cannot be parsed. - """ transaction = await fragment_request(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): diff --git a/pyfragment/domains/__init__.py b/pyfragment/domains/__init__.py new file mode 100644 index 0000000..8611980 --- /dev/null +++ b/pyfragment/domains/__init__.py @@ -0,0 +1 @@ +"""Domain-level helpers for Fragment operations.""" diff --git a/pyfragment/domains/ads/__init__.py b/pyfragment/domains/ads/__init__.py new file mode 100644 index 0000000..50fd95a --- /dev/null +++ b/pyfragment/domains/ads/__init__.py @@ -0,0 +1,5 @@ +from pyfragment.domains.ads.recharge import recharge_ads +from pyfragment.domains.ads.service import AdsService +from pyfragment.domains.ads.tonup import topup_ton + +__all__ = ["AdsService", "recharge_ads", "topup_ton"] diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/domains/ads/recharge.py similarity index 61% rename from pyfragment/methods/recharge_ads.py rename to pyfragment/domains/ads/recharge.py index 19cf098..5c6285e 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/domains/ads/recharge.py @@ -3,38 +3,17 @@ from __future__ import annotations import json from typing import TYPE_CHECKING -from pyfragment.types import ( - AdsRechargeResult, - ConfigurationError, - FragmentAPIError, - FragmentError, - UnexpectedError, - VerificationError, -) -from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.domains.tonapi.account import get_account_info +from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError +from pyfragment.models.payments import AdsRechargeResult if TYPE_CHECKING: from pyfragment.client import FragmentClient async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: - """Add funds to your own Telegram Ads account. - - Args: - client: Authenticated :class:`FragmentClient` instance. - account: Your Fragment Ads account identifier — the channel or bot username - the Ads account is linked to (e.g. ``"@mychannel"``). - amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. - - Returns: - :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. - - Raises: - ConfigurationError: If ``amount`` is not a valid integer in the allowed range. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py new file mode 100644 index 0000000..ce310cc --- /dev/null +++ b/pyfragment/domains/ads/service.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.ads.recharge import recharge_ads +from pyfragment.domains.ads.tonup import topup_ton +from pyfragment.domains.base import BaseService +from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult + +if TYPE_CHECKING: + pass + + +class AdsService(BaseService): + async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: + return await recharge_ads(self._client, account, amount) + + async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + return await topup_ton(self._client, username, amount, show_sender=show_sender) diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/domains/ads/tonup.py similarity index 66% rename from pyfragment/methods/topup_ton.py rename to pyfragment/domains/ads/tonup.py index d3ad869..c86a77c 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/domains/ads/tonup.py @@ -3,8 +3,11 @@ from __future__ import annotations import json from typing import TYPE_CHECKING -from pyfragment.types import ( - AdsTopupResult, +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.domains.payments import parse_required_payment_amount +from pyfragment.domains.tonapi.account import get_account_info +from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.exceptions import ( ConfigurationError, FragmentAPIError, FragmentError, @@ -12,31 +15,13 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, ) -from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE -from pyfragment.utils import get_account_info, process_transaction +from pyfragment.models.payments import AdsTopupResult if TYPE_CHECKING: from pyfragment.client import FragmentClient async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up TON to a recipient's Telegram balance. - - Args: - client: Authenticated :class:`FragmentClient` instance. - username: Recipient's Telegram username (with or without ``@``). - amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. - show_sender: Show your name as the sender. Defaults to ``True``. - - Returns: - :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. - - Raises: - ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. - UserNotFoundError: If the recipient is not found on Telegram. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) @@ -49,6 +34,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE) + required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) @@ -68,7 +54,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) - tx_hash = await process_transaction(client, transaction) + tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) except FragmentError: diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py new file mode 100644 index 0000000..9b7bb67 --- /dev/null +++ b/pyfragment/domains/anonymous_numbers/__init__.py @@ -0,0 +1,12 @@ +from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes +from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult + +__all__ = [ + "AnonymousNumbersService", + "LoginCodeResult", + "TerminateSessionsResult", + "get_login_code", + "terminate_sessions", + "toggle_login_codes", +] diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/domains/anonymous_numbers/number.py similarity index 59% rename from pyfragment/methods/anonymous_number.py rename to pyfragment/domains/anonymous_numbers/number.py index fc79d29..9a84c48 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/domains/anonymous_numbers/number.py @@ -3,16 +3,10 @@ from __future__ import annotations import html from typing import TYPE_CHECKING -from pyfragment.types import ( - AnonymousNumberError, - FragmentAPIError, - FragmentError, - LoginCodeResult, - TerminateSessionsResult, - UnexpectedError, -) -from pyfragment.types.constants import NUMBERS_PAGE -from pyfragment.utils import parse_login_code +from pyfragment.core.constants import NUMBERS_PAGE +from pyfragment.domains.anonymous_numbers.parser import parse_login_code +from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -23,20 +17,6 @@ def _strip_plus(number: str) -> str: async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult: - """Fetch the current pending login code for an anonymous number. - - Args: - client: Authenticated :class:`FragmentClient` instance. - number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). - - Returns: - :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code), - and ``active_sessions`` count. - - Raises: - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ try: clean = _strip_plus(number) result = await client.call( @@ -59,17 +39,6 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None: - """Enable or disable login code delivery for an anonymous number. - - Args: - client: Authenticated :class:`FragmentClient` instance. - number: Phone number with or without leading ``+``. - can_receive: ``True`` to allow receiving codes, ``False`` to block them. - - Raises: - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ try: clean = _strip_plus(number) result = await client.call( @@ -88,24 +57,6 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult: - """Terminate all active Telegram sessions for an anonymous number. - - This is a two-step operation: Fragment first returns a confirmation hash, - which is then submitted to confirm the termination. - - Args: - client: Authenticated :class:`FragmentClient` instance. - number: Phone number with or without leading ``+``. - - Returns: - :class:`TerminateSessionsResult` with ``number`` and ``message``. - - Raises: - AnonymousNumberError: If the number is not owned by this account or has no active sessions, - or if Fragment returns an error during termination. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ try: clean = _strip_plus(number) diff --git a/pyfragment/domains/anonymous_numbers/parser.py b/pyfragment/domains/anonymous_numbers/parser.py new file mode 100644 index 0000000..d7dacee --- /dev/null +++ b/pyfragment/domains/anonymous_numbers/parser.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import re + +CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') +ROW_RE = re.compile(r"]") + + +def parse_login_code(html: str) -> tuple[str | None, int]: + match = CODE_RE.search(html) + code = match.group(1).strip() if match else None + active_sessions = len(ROW_RE.findall(html)) + return code, active_sessions diff --git a/pyfragment/domains/anonymous_numbers/service.py b/pyfragment/domains/anonymous_numbers/service.py new file mode 100644 index 0000000..28c7ec5 --- /dev/null +++ b/pyfragment/domains/anonymous_numbers/service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes +from pyfragment.domains.base import BaseService +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult + +if TYPE_CHECKING: + pass + + +class AnonymousNumbersService(BaseService): + async def get_login_code(self, number: str) -> LoginCodeResult: + return await get_login_code(self._client, number) + + async def toggle_login_codes(self, number: str, can_receive: bool) -> None: + return await toggle_login_codes(self._client, number, can_receive) + + async def terminate_sessions(self, number: str) -> TerminateSessionsResult: + return await terminate_sessions(self._client, number) diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py new file mode 100644 index 0000000..01d5ca5 --- /dev/null +++ b/pyfragment/domains/base.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + + +class BaseService: + def __init__(self, client: FragmentClient) -> None: + self._client = client diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py new file mode 100644 index 0000000..eaec868 --- /dev/null +++ b/pyfragment/domains/giveaways/__init__.py @@ -0,0 +1,11 @@ +from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars +from pyfragment.domains.giveaways.service import GiveawaysService +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult + +__all__ = [ + "GiveawaysService", + "PremiumGiveawayResult", + "StarsGiveawayResult", + "giveaway_premium", + "giveaway_stars", +] diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py new file mode 100644 index 0000000..ab1af30 --- /dev/null +++ b/pyfragment/domains/giveaways/giveaway.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, get_args + +from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE +from pyfragment.domains.payments import parse_required_payment_amount +from pyfragment.domains.tonapi.account import get_account_info +from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.exceptions import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, + VerificationError, +) +from pyfragment.models.enums import PaymentMethod +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + + +async def giveaway_stars( + client: FragmentClient, + channel: str, + winners: int, + amount: int, + payment_method: PaymentMethod = "ton", +) -> StarsGiveawayResult: + if not isinstance(winners, int) or not (1 <= winners <= 5): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) + if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): + raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) + if payment_method not in get_args(PaymentMethod): + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(get_args(PaymentMethod))), + ) + ) + + try: + result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayStarsRequest", + { + "recipient": recipient, + "quantity": str(winners), + "stars": str(amount), + "payment_method": payment_method, + }, + page_url=STARS_GIVEAWAY_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) + + account = await get_account_info(client) + transaction = await client.call( + "getGiveawayStarsLink", + { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + }, + page_url=STARS_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) + return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def giveaway_premium( + client: FragmentClient, + channel: str, + winners: int, + months: int = 3, + payment_method: PaymentMethod = "ton", +) -> PremiumGiveawayResult: + if not isinstance(winners, int) or not (1 <= winners <= 24_000): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) + if months not in (3, 6, 12): + raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + if payment_method not in get_args(PaymentMethod): + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(get_args(PaymentMethod))), + ) + ) + + try: + result = await client.call( + "searchPremiumGiveawayRecipient", + {"query": channel, "quantity": winners, "months": months}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayPremiumRequest", + { + "recipient": recipient, + "quantity": str(winners), + "months": str(months), + "payment_method": payment_method, + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) + + account = await get_account_info(client) + transaction = await client.call( + "getGiveawayPremiumLink", + { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) + return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py new file mode 100644 index 0000000..abbaefa --- /dev/null +++ b/pyfragment/domains/giveaways/service.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.base import BaseService +from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars +from pyfragment.models.enums import PaymentMethod +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult + +if TYPE_CHECKING: + pass + + +class GiveawaysService(BaseService): + async def giveaway_stars( + self, + channel: str, + winners: int, + amount: int, + payment_method: PaymentMethod = "ton", + ) -> StarsGiveawayResult: + return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method) + + async def giveaway_premium( + self, + channel: str, + winners: int, + months: int = 3, + payment_method: PaymentMethod = "ton", + ) -> PremiumGiveawayResult: + return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method) diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py new file mode 100644 index 0000000..ed298ed --- /dev/null +++ b/pyfragment/domains/marketplace/__init__.py @@ -0,0 +1,13 @@ +from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames +from pyfragment.domains.marketplace.service import MarketplaceService +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult + +__all__ = [ + "GiftsResult", + "MarketplaceService", + "NumbersResult", + "UsernamesResult", + "search_gifts", + "search_numbers", + "search_usernames", +] diff --git a/pyfragment/domains/marketplace/parser.py b/pyfragment/domains/marketplace/parser.py new file mode 100644 index 0000000..0bd681d --- /dev/null +++ b/pyfragment/domains/marketplace/parser.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import re +from typing import Any + +ROW_BLOCK_RE = re.compile(r']*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)', re.DOTALL) +HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"') +VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<') +PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<") +DATETIME_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>') +DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>') +NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") + +GRID_ITEM_RE = re.compile(r']*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)', re.DOTALL) +GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))') +GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<') +GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<') +GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<') +GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<') +GRID_DATETIME_RE = re.compile(r']+datetime="([^"]+)"') + + +def parse_auction_rows(html: str) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for row_match in ROW_BLOCK_RE.finditer(html): + row = row_match.group(1) + + href_m = HREF_RE.search(row) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") + + values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] + name = values[0] if values else slug + + status: str | None = None + for v in values[1:]: + if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v): + status = v + break + + price_m = PRICE_RE.search(row) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) + date: str | None = time_m.group(1) if time_m else None + + items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) + + return items + + +def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: + items: list[dict[str, Any]] = [] + for item_match in GRID_ITEM_RE.finditer(html): + block = item_match.group(0) + + href_m = GRID_HREF_RE.search(block) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") + + name_m = GRID_NAME_RE.search(block) + num_m = GRID_NUM_RE.search(block) + item_name = name_m.group(1).strip() if name_m else slug + item_num = f" #{num_m.group(1)}" if num_m else "" + name = f"{item_name}{item_num}" + + status_m = GRID_STATUS_RE.search(block) + status: str | None = status_m.group(1).strip() if status_m else None + + price_m = GRID_PRICE_RE.search(block) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + time_m = GRID_DATETIME_RE.search(block) + date: str | None = time_m.group(1) if time_m else None + + items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) + + next_offset_m = re.search(r'data-next-offset="(\d+)"', html) + next_offset = int(next_offset_m.group(1)) if next_offset_m else None + + return items, next_offset diff --git a/pyfragment/domains/marketplace/search.py b/pyfragment/domains/marketplace/search.py new file mode 100644 index 0000000..a7f6807 --- /dev/null +++ b/pyfragment/domains/marketplace/search.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE +from pyfragment.domains.marketplace.parser import parse_auction_rows, parse_gift_items +from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + + +async def search_usernames( + client: FragmentClient, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> UsernamesResult: + data: dict[str, Any] = {"type": "usernames", "query": query} + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL) + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return UsernamesResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def search_numbers( + client: FragmentClient, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> NumbersResult: + data: dict[str, Any] = {"type": "numbers", "query": query} + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE) + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return NumbersResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def search_gifts( + client: FragmentClient, + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, +) -> GiftsResult: + data: dict[str, Any] = {"type": "gifts", "query": query} + if collection is not None: + data["collection"] = collection + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if view is not None: + data["view"] = view + if attr is not None: + for trait, values in attr.items(): + data[f"attr[{trait}]"] = values + if offset is not None: + data["offset"] = offset + + try: + result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE) + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items, next_offset = parse_gift_items(result.get("html") or "") + return GiftsResult(items=items, next_offset=next_offset) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/marketplace/service.py b/pyfragment/domains/marketplace/service.py new file mode 100644 index 0000000..885e17a --- /dev/null +++ b/pyfragment/domains/marketplace/service.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.base import BaseService +from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult + +if TYPE_CHECKING: + pass + + +class MarketplaceService(BaseService): + async def search_usernames( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> UsernamesResult: + return await search_usernames(self._client, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_numbers( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> NumbersResult: + return await search_numbers(self._client, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_gifts( + self, + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, + ) -> GiftsResult: + return await search_gifts( + self._client, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset + ) diff --git a/pyfragment/domains/payments.py b/pyfragment/domains/payments.py new file mode 100644 index 0000000..0f6b88a --- /dev/null +++ b/pyfragment/domains/payments.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Any + + +def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None: + raw_amount = init_response.get("amount") + try: + return float(str(raw_amount)) + except (TypeError, ValueError): + return None diff --git a/pyfragment/domains/purchases/__init__.py b/pyfragment/domains/purchases/__init__.py new file mode 100644 index 0000000..baa5df2 --- /dev/null +++ b/pyfragment/domains/purchases/__init__.py @@ -0,0 +1,5 @@ +from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars +from pyfragment.domains.purchases.service import PurchasesService +from pyfragment.models.payments import PremiumResult, StarsResult + +__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"] diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py new file mode 100644 index 0000000..52d24e4 --- /dev/null +++ b/pyfragment/domains/purchases/purchase.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING, get_args + +from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE +from pyfragment.domains.payments import parse_required_payment_amount +from pyfragment.domains.tonapi.account import get_account_info +from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.exceptions import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, + VerificationError, +) +from pyfragment.models.enums import PaymentMethod +from pyfragment.models.payments import PremiumResult, StarsResult + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + + +async def purchase_stars( + client: FragmentClient, + username: str, + amount: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", +) -> StarsResult: + if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): + raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) + if payment_method not in get_args(PaymentMethod): + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(get_args(PaymentMethod))), + ) + ) + + try: + result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + await client.call( + "updateStarsBuyState", + {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + page_url=STARS_PAGE, + ) + result = await client.call( + "initBuyStarsRequest", + {"recipient": recipient, "quantity": amount, "payment_method": payment_method}, + page_url=STARS_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) + + account = await get_account_info(client) + transaction = await client.call( + "getBuyStarsLink", + { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": int(show_sender), + }, + page_url=STARS_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) + return StarsResult(transaction_id=tx_hash, username=username, amount=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def purchase_premium( + client: FragmentClient, + username: str, + months: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", +) -> PremiumResult: + if months not in (3, 6, 12): + raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + if payment_method not in get_args(PaymentMethod): + raise ConfigurationError( + ConfigurationError.INVALID_PAYMENT_METHOD.format( + method=payment_method, + supported=", ".join(sorted(get_args(PaymentMethod))), + ) + ) + + try: + result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + await client.call( + "updatePremiumState", + {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + page_url=PREMIUM_PAGE, + ) + result = await client.call( + "initGiftPremiumRequest", + {"recipient": recipient, "months": months, "payment_method": payment_method}, + page_url=PREMIUM_PAGE, + ) + required_payment_amount = parse_required_payment_amount(result) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) + + account = await get_account_info(client) + transaction = await client.call( + "getGiftPremiumLink", + { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": int(show_sender), + }, + page_url=PREMIUM_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + tx_hash = await process_transaction( + client, + transaction, + payment_method=payment_method, + required_payment_amount=required_payment_amount, + ) + return PremiumResult(transaction_id=tx_hash, username=username, amount=months) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/purchases/service.py b/pyfragment/domains/purchases/service.py new file mode 100644 index 0000000..811a21b --- /dev/null +++ b/pyfragment/domains/purchases/service.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.base import BaseService +from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars +from pyfragment.models.enums import PaymentMethod +from pyfragment.models.payments import PremiumResult, StarsResult + +if TYPE_CHECKING: + pass + + +class PurchasesService(BaseService): + async def purchase_stars( + self, + username: str, + amount: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", + ) -> StarsResult: + return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method) + + async def purchase_premium( + self, + username: str, + months: int, + show_sender: bool = True, + payment_method: PaymentMethod = "ton", + ) -> PremiumResult: + return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method) diff --git a/pyfragment/domains/tonapi/__init__.py b/pyfragment/domains/tonapi/__init__.py new file mode 100644 index 0000000..e8f85e3 --- /dev/null +++ b/pyfragment/domains/tonapi/__init__.py @@ -0,0 +1,23 @@ +from pyfragment.domains.tonapi.account import ( + check_ton_payment_balance, + check_usdt_payment_balance, + get_account_info, + get_usdt_balance, + get_wallet_info, +) +from pyfragment.domains.tonapi.service import TonapiService +from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction +from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer + +__all__ = [ + "TonapiService", + "clean_decode", + "check_ton_payment_balance", + "check_usdt_payment_balance", + "get_account_info", + "get_usdt_balance", + "get_wallet_info", + "process_transaction", + "send_ton_transfer", + "send_usdt_transfer", +] diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py new file mode 100644 index 0000000..cd150fd --- /dev/null +++ b/pyfragment/domains/tonapi/account.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING, Any + +from ton_core import NetworkGlobalID +from tonutils.clients import TonapiClient +from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method +from tonutils.exceptions import ProviderResponseError + +from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES +from pyfragment.exceptions import WalletError +from pyfragment.models.wallet import WalletInfo + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + + +async def get_usdt_balance(ton: Any, wallet_address: str) -> float: + """Return the USDT balance for a Fragment-linked TON wallet.""" + try: + jetton_wallet_address = await get_wallet_address_get_method( + client=ton, + address=USDT_TON_MASTER_ADDRESS, + owner_address=wallet_address, + ) + wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address) + raw_balance = int(wallet_data[0]) if wallet_data else 0 + return float(raw_balance) / 1_000_000.0 + except ProviderResponseError as exc: + if exc.code == 404: + return 0.0 + raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + except Exception as exc: + raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + + +async def check_ton_payment_balance( + balance_ton: float, + amount_ton: float, + required_payment_amount: float | None, +) -> None: + """Validate that the TON wallet can cover a TON-denominated payment.""" + tx_price_ton = amount_ton + if required_payment_amount is not None and required_payment_amount > 0: + tx_price_ton = max(tx_price_ton, required_payment_amount) + + required_ton = max(tx_price_ton, MIN_TON_BALANCE) + if balance_ton < required_ton: + raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton)) + + +async def check_usdt_payment_balance( + balance_ton: float, + required_payment_amount: float | None, + ton: Any, + wallet_address: str, +) -> None: + """Validate that the wallet can cover a USDT-denominated payment.""" + if balance_ton < MIN_TON_BALANCE: + raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE)) + + usdt_balance = await get_usdt_balance(ton, wallet_address) + required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE + if usdt_balance < required_usdt: + raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) + + +async def get_account_info(client: FragmentClient) -> dict[str, Any]: + """Build the wallet payload Fragment needs to prepare a transaction.""" + async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: + try: + wallet_cls = WALLET_CLASSES[client.wallet_version] + wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) + boc = wallet.state_init.serialize().to_boc() + return { + "address": wallet.address.to_str(False, False), + "publicKey": pub_key.as_hex, + "chain": "-239", + "walletStateInit": base64.b64encode(boc).decode(), + } + except Exception as exc: + raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc + + +async def get_wallet_info(client: FragmentClient) -> WalletInfo: + """Fetch the wallet address, chain state, and TON/USDT balances.""" + async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: + try: + wallet_cls = WALLET_CLASSES[client.wallet_version] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) + await wallet.refresh() + wallet_address = wallet.address.to_str(False, False) + usdt_balance = await get_usdt_balance(ton, wallet_address) + return WalletInfo( + address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False), + state=wallet.state.value, + ton_balance=round(wallet.balance / 1_000_000_000, 4), + usdt_balance=round(usdt_balance, 4), + ) + except Exception as exc: + raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc diff --git a/pyfragment/domains/tonapi/service.py b/pyfragment/domains/tonapi/service.py new file mode 100644 index 0000000..9914b64 --- /dev/null +++ b/pyfragment/domains/tonapi/service.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyfragment.domains.base import BaseService +from pyfragment.domains.tonapi.account import get_wallet_info +from pyfragment.models.wallet import WalletInfo + +if TYPE_CHECKING: + pass + + +class TonapiService(BaseService): + async def get_wallet(self) -> WalletInfo: + return await get_wallet_info(self._client) diff --git a/pyfragment/utils/wallet/transaction.py b/pyfragment/domains/tonapi/transaction.py similarity index 72% rename from pyfragment/utils/wallet/transaction.py rename to pyfragment/domains/tonapi/transaction.py index 7da9bd1..157ba7b 100644 --- a/pyfragment/utils/wallet/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -10,16 +10,22 @@ from ton_core import Cell, NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from pyfragment.types import ParseError, TransactionError, WalletError -from pyfragment.types.constants import WALLET_CLASSES, PaymentMethod -from pyfragment.utils.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance +from pyfragment.core.constants import WALLET_CLASSES +from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance +from pyfragment.exceptions import ParseError, TransactionError, WalletError +from pyfragment.models.enums import PaymentMethod if TYPE_CHECKING: from pyfragment.client import FragmentClient def clean_decode(payload: str) -> str | Cell: - """Decode a base64-encoded BOC payload to a plain-text comment string.""" + """Decode a base64 BOC comment from Fragment into text when possible. + + Some Fragment payloads are plain text comments, while others are structured + TON messages such as jetton transfers. Non-text payloads are returned as a + `Cell` so the caller can keep the raw binary structure. + """ s = payload.strip() if not s: return "" @@ -30,8 +36,7 @@ def clean_decode(payload: str) -> str | Cell: sl = cell.begin_parse() op = sl.load_uint(32) if op != 0: - # Non-zero op code means this is a structured message (e.g. jetton transfer), - # not a plain text comment — return the full cell as-is. + # Non-zero op code means this is a structured TON message, not a plain text comment. return cell try: return sl.load_snake_string().strip() @@ -47,23 +52,16 @@ async def process_transaction( payment_method: PaymentMethod = "ton", required_payment_amount: float | None = None, ) -> str: - """Sign and broadcast a Fragment transaction to the TON network. - - Validates the payload structure, checks the wallet balance, decodes the - on-chain comment, and calls ``wallet.transfer``. + """Sign and broadcast a Fragment transaction with the seeded TON wallet. Args: - client: Authenticated :class:`FragmentClient` instance. - transaction_data: Raw transaction dict from ``execute_transaction_request``. - payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``. - required_payment_amount: Optional price from init*Request response. + client: Authenticated `FragmentClient` instance. + transaction_data: Raw Fragment transaction payload returned by the API. + payment_method: Payment currency to use for the purchase flow. + required_payment_amount: Optional amount returned by Fragment's init request. Returns: - Normalised transaction hash string. - - Raises: - TransactionError: If the payload is malformed or the broadcast fails. - WalletError: If the wallet balance is too low or cannot be fetched. + Normalized transaction hash string. """ if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"): raise TransactionError(TransactionError.INVALID_PAYLOAD) @@ -75,7 +73,7 @@ async def process_transaction( wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - # Check balance covers selected payment flow requirements. + # Check balance first so we fail before trying to broadcast on-chain. try: await wallet.refresh() balance_ton = wallet.balance / 1_000_000_000 @@ -83,8 +81,7 @@ async def process_transaction( wallet.address.to_str(False, False) await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount) else: - # USDT is withdrawn from the Fragment-linked wallet (transaction["from"]), - # not from the signing seed wallet. Seed wallet only pays TON gas. + # USDT is paid from the Fragment-linked wallet, not the signing wallet. fragment_wallet_address = transaction_data["transaction"].get("from", "") await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) except WalletError: @@ -109,7 +106,6 @@ async def process_transaction( await asyncio.sleep(1 + random.uniform(0, 0.5)) continue if exc.code == 406 and "seqno" in str(exc).lower(): - # Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry if attempt < 2: await asyncio.sleep(2 + random.uniform(0, 1)) continue diff --git a/pyfragment/utils/wallet/transfer.py b/pyfragment/domains/tonapi/transfer.py similarity index 63% rename from pyfragment/utils/wallet/transfer.py rename to pyfragment/domains/tonapi/transfer.py index a8ff893..0870a89 100644 --- a/pyfragment/utils/wallet/transfer.py +++ b/pyfragment/domains/tonapi/transfer.py @@ -6,9 +6,9 @@ from ton_core import Address, NetworkGlobalID from tonutils.clients import ToncenterClient from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder -from pyfragment.types import TransactionError, WalletError -from pyfragment.types.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES -from pyfragment.types.results import TonTransferResult, UsdtTransferResult +from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES +from pyfragment.exceptions import TransactionError, WalletError +from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -20,19 +20,13 @@ async def send_ton_transfer( amount: int, body: str | None = None, ) -> TonTransferResult: - """Send a direct TON transfer on-chain using ToncenterClient. + """Send a direct TON transfer from the seeded wallet. Args: - client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used). - destination: Recipient TON address (any format, e.g. ``"UQ..."``). - amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons). - body: Optional on-chain comment attached to the transfer. - - Returns: - :class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``. - - Raises: - TransactionError: If the transaction fails to broadcast. + client: Authenticated `FragmentClient` instance. + destination: Recipient address in any TON-compatible format. + amount: Amount in nanotons. + body: Optional on-chain comment. """ try: async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: @@ -63,20 +57,14 @@ async def send_usdt_transfer( forward_payload: str | None = None, ton_for_gas: int = 50_000_000, ) -> UsdtTransferResult: - """Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient. + """Send a direct USDT transfer from the seeded wallet. Args: - client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used). - destination: Recipient TON address (any format, e.g. ``"UQ..."``). - usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000). - forward_payload: Optional comment forwarded to the recipient with the transfer notification. - ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON). - - Returns: - :class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``. - - Raises: - TransactionError: If the transaction fails to broadcast. + client: Authenticated `FragmentClient` instance. + destination: Recipient address in any TON-compatible format. + usdt_amount: Amount in USDT base units (6 decimals). + forward_payload: Optional comment passed through to the recipient. + ton_for_gas: TON attached for gas in nanotons. """ try: async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton: diff --git a/pyfragment/types/exceptions.py b/pyfragment/exceptions.py similarity index 77% rename from pyfragment/types/exceptions.py rename to pyfragment/exceptions.py index 6f8f621..d1337f6 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/exceptions.py @@ -2,44 +2,44 @@ from __future__ import annotations class FragmentError(Exception): - """Base exception for all pyfragment library errors.""" + """Base exception for all pyfragment errors.""" class ClientError(FragmentError): - """Raised for client configuration and setup issues (bad params, invalid cookies).""" + """Raised for client configuration and setup issues.""" class ConfigurationError(ClientError): """Raised when required client parameters are missing or invalid.""" MISSING_VARS = "Missing required parameter(s): {keys}." - UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." - INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." + UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}." + INVALID_MNEMONIC = "Invalid mnemonic phrase: expected 12, 18, or 24 words, got {count}." INVALID_API_KEY = ( - "Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com." + "Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com." ) INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." - INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000." - INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000." + INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1,000,000." + INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000." INVALID_USERNAME = ( "Invalid username '{username}'. " - "Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)." + "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)." ) INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." - INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000." - INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000." + INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000." + INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000." INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}." class CookieError(ClientError): """Raised when cookies are unreadable or missing required fields.""" - READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}" + READ_FAILED = "Failed to parse cookies: expected a JSON string or a dict, got {exc}." MISSING_KEYS = ( "Fragment cookies are missing or empty for key(s): {keys}. " "Open fragment.com in your browser, log in, and copy fresh cookies." ) - UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." + UNSUPPORTED_BROWSER = "Unsupported browser '{browser}'. Supported values: {supported}." BROWSER_READ_FAILED = ( "Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}." ) @@ -53,23 +53,14 @@ class CookieError(ClientError): class FragmentAPIError(FragmentError): """Raised for errors returned by Fragment's API responses.""" - NO_REQUEST_ID = ( - "Fragment did not return a request ID for '{context}'. " - "Your session may have expired — log in to fragment.com and refresh your cookies." - ) + NO_REQUEST_ID = "Fragment did not return a request ID for '{context}'. Your session may have expired. Refresh your cookies and try again." class FragmentPageError(FragmentAPIError): """Raised when the Fragment page cannot be fetched or the API hash is not found.""" - BAD_STATUS = ( - "Fragment returned HTTP {status} when loading {url}. " - "Your cookies may be invalid or expired — log in to fragment.com and refresh them." - ) - NOT_FOUND = ( - "Could not extract the API hash from {url}. " - "The page structure may have changed, or you are not logged in — refresh your cookies." - ) + BAD_STATUS = "Fragment returned HTTP {status} when loading {url}. Your cookies may be invalid or expired. Refresh them and try again." + NOT_FOUND = "Could not extract the API hash from {url}. The page structure may have changed, or you may not be logged in. Refresh your cookies." class UserNotFoundError(FragmentAPIError): @@ -90,9 +81,7 @@ class AnonymousNumberError(FragmentAPIError): class TransactionError(FragmentAPIError): """Raised when a TON transaction fails to build or broadcast.""" - INVALID_PAYLOAD = ( - "Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response." - ) + INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty." BROADCAST_FAILED = "Transaction broadcast failed: {exc}" BROADCAST_FAILED_SSL = ( "Transaction broadcast failed due to an SSL certificate error: {exc}\n" diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py deleted file mode 100644 index 9714024..0000000 --- a/pyfragment/methods/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes -from pyfragment.methods.giveaway_premium import giveaway_premium -from pyfragment.methods.giveaway_stars import giveaway_stars -from pyfragment.methods.purchase_premium import purchase_premium -from pyfragment.methods.purchase_stars import purchase_stars -from pyfragment.methods.recharge_ads import recharge_ads -from pyfragment.methods.search_gifts import search_gifts -from pyfragment.methods.search_numbers import search_numbers -from pyfragment.methods.search_usernames import search_usernames -from pyfragment.methods.topup_ton import topup_ton - -__all__ = [ - "get_login_code", - "giveaway_premium", - "giveaway_stars", - "purchase_premium", - "purchase_stars", - "recharge_ads", - "search_gifts", - "search_numbers", - "search_usernames", - "terminate_sessions", - "toggle_login_codes", - "topup_ton", -] diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py deleted file mode 100644 index f5bab9b..0000000 --- a/pyfragment/methods/giveaway_premium.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, get_args - -from pyfragment.types import ( - ConfigurationError, - FragmentAPIError, - FragmentError, - PremiumGiveawayResult, - UnexpectedError, - UserNotFoundError, - VerificationError, -) -from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, PaymentMethod -from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def giveaway_premium( - client: FragmentClient, - channel: str, - winners: int, - months: int = 3, - payment_method: PaymentMethod = "ton", -) -> PremiumGiveawayResult: - """Run a Telegram Premium giveaway for a channel. - - Args: - client: Authenticated :class:`FragmentClient` instance. - channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. - winners: Number of winners — integer from ``1`` to ``24 000``. - months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. - - Returns: - :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``amount``. - - Raises: - ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12. - UserNotFoundError: If the channel is not found on Fragment. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - if not isinstance(winners, int) or not (1 <= winners <= 24_000): - raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) - if months not in (3, 6, 12): - raise ConfigurationError(ConfigurationError.INVALID_MONTHS) - if payment_method not in get_args(PaymentMethod): - raise ConfigurationError( - ConfigurationError.INVALID_PAYMENT_METHOD.format( - method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), - ) - ) - - try: - result = await client.call( - "searchPremiumGiveawayRecipient", - {"query": channel, "quantity": winners, "months": months}, - page_url=PREMIUM_GIVEAWAY_PAGE, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) - - result = await client.call( - "initGiveawayPremiumRequest", - { - "recipient": recipient, - "quantity": str(winners), - "months": str(months), - "payment_method": payment_method, - }, - page_url=PREMIUM_GIVEAWAY_PAGE, - ) - required_payment_amount = parse_required_payment_amount(result) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) - - account = await get_account_info(client) - transaction = await client.call( - "getGiveawayPremiumLink", - { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - }, - page_url=PREMIUM_GIVEAWAY_PAGE, - ) - if transaction.get("need_verify"): - raise VerificationError(VerificationError.KYC_REQUIRED) - - tx_hash = await process_transaction( - client, - transaction, - payment_method=payment_method, - required_payment_amount=required_payment_amount, - ) - return PremiumGiveawayResult( - transaction_id=tx_hash, - channel=channel, - winners=winners, - amount=months, - ) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py deleted file mode 100644 index 48615a6..0000000 --- a/pyfragment/methods/giveaway_stars.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, get_args - -from pyfragment.types import ( - ConfigurationError, - FragmentAPIError, - FragmentError, - StarsGiveawayResult, - UnexpectedError, - UserNotFoundError, - VerificationError, -) -from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, PaymentMethod -from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def giveaway_stars( - client: FragmentClient, - channel: str, - winners: int, - amount: int, - payment_method: PaymentMethod = "ton", -) -> StarsGiveawayResult: - """Run a Telegram Stars giveaway for a channel. - - Args: - client: Authenticated :class:`FragmentClient` instance. - channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. - winners: Number of winners — integer from ``1`` to ``5``. - amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. - - Returns: - :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``amount``. - - Raises: - ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000. - UserNotFoundError: If the channel is not found on Fragment. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - if not isinstance(winners, int) or not (1 <= winners <= 5): - raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) - if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): - raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) - if payment_method not in get_args(PaymentMethod): - raise ConfigurationError( - ConfigurationError.INVALID_PAYMENT_METHOD.format( - method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), - ) - ) - - try: - result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) - - result = await client.call( - "initGiveawayStarsRequest", - { - "recipient": recipient, - "quantity": str(winners), - "stars": str(amount), - "payment_method": payment_method, - }, - page_url=STARS_GIVEAWAY_PAGE, - ) - required_payment_amount = parse_required_payment_amount(result) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) - - account = await get_account_info(client) - transaction = await client.call( - "getGiveawayStarsLink", - { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - }, - page_url=STARS_GIVEAWAY_PAGE, - ) - if transaction.get("need_verify"): - raise VerificationError(VerificationError.KYC_REQUIRED) - - tx_hash = await process_transaction( - client, - transaction, - payment_method=payment_method, - required_payment_amount=required_payment_amount, - ) - return StarsGiveawayResult( - transaction_id=tx_hash, - channel=channel, - winners=winners, - amount=amount, - ) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py deleted file mode 100644 index 4ab7ad4..0000000 --- a/pyfragment/methods/purchase_premium.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import json -import time -from typing import TYPE_CHECKING, get_args - -from pyfragment.types import ( - ConfigurationError, - FragmentAPIError, - FragmentError, - PremiumResult, - UnexpectedError, - UserNotFoundError, - VerificationError, -) -from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, PaymentMethod -from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def purchase_premium( - client: FragmentClient, - username: str, - months: int, - show_sender: bool = True, - payment_method: PaymentMethod = "ton", -) -> PremiumResult: - """Gift Telegram Premium to a user. - - Args: - client: Authenticated :class:`FragmentClient` instance. - username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. - months: Premium duration — ``3``, ``6``, or ``12``. - show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. - - Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. - - Raises: - ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. - UserNotFoundError: If the user is not found on Fragment. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - if months not in (3, 6, 12): - raise ConfigurationError(ConfigurationError.INVALID_MONTHS) - if payment_method not in get_args(PaymentMethod): - raise ConfigurationError( - ConfigurationError.INVALID_PAYMENT_METHOD.format( - method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), - ) - ) - - try: - result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - - await client.call( - "updatePremiumState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, - page_url=PREMIUM_PAGE, - ) - result = await client.call( - "initGiftPremiumRequest", - {"recipient": recipient, "months": months, "payment_method": payment_method}, - page_url=PREMIUM_PAGE, - ) - required_payment_amount = parse_required_payment_amount(result) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) - - account = await get_account_info(client) - transaction = await client.call( - "getGiftPremiumLink", - { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - "show_sender": int(show_sender), - }, - page_url=PREMIUM_PAGE, - ) - if transaction.get("need_verify"): - raise VerificationError(VerificationError.KYC_REQUIRED) - - tx_hash = await process_transaction( - client, - transaction, - payment_method=payment_method, - required_payment_amount=required_payment_amount, - ) - return PremiumResult(transaction_id=tx_hash, username=username, amount=months) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py deleted file mode 100644 index 0eb6cb0..0000000 --- a/pyfragment/methods/purchase_stars.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import json -import time -from typing import TYPE_CHECKING, get_args - -from pyfragment.types import ( - ConfigurationError, - FragmentAPIError, - FragmentError, - StarsResult, - UnexpectedError, - UserNotFoundError, - VerificationError, -) -from pyfragment.types.constants import DEVICE, STARS_PAGE, PaymentMethod -from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def purchase_stars( - client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton" -) -> StarsResult: - """Send Telegram Stars to a user. - - Args: - client: Authenticated :class:`FragmentClient` instance. - username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. - amount: Number of Stars to send — integer from ``50`` to ``1 000 000``. - show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. - - Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. - - Raises: - ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. - UserNotFoundError: If the user is not found on Fragment. - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): - raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) - if payment_method not in get_args(PaymentMethod): - raise ConfigurationError( - ConfigurationError.INVALID_PAYMENT_METHOD.format( - method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), - ) - ) - - try: - result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - - await client.call( - "updateStarsBuyState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, - page_url=STARS_PAGE, - ) - result = await client.call( - "initBuyStarsRequest", - {"recipient": recipient, "quantity": amount, "payment_method": payment_method}, - page_url=STARS_PAGE, - ) - required_payment_amount = parse_required_payment_amount(result) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) - - account = await get_account_info(client) - transaction = await client.call( - "getBuyStarsLink", - { - "account": json.dumps(account), - "device": DEVICE, - "transaction": 1, - "id": req_id, - "show_sender": int(show_sender), - }, - page_url=STARS_PAGE, - ) - if transaction.get("need_verify"): - raise VerificationError(VerificationError.KYC_REQUIRED) - - tx_hash = await process_transaction( - client, - transaction, - payment_method=payment_method, - required_payment_amount=required_payment_amount, - ) - return StarsResult(transaction_id=tx_hash, username=username, amount=amount) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py deleted file mode 100644 index badea9e..0000000 --- a/pyfragment/methods/search_gifts.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError -from pyfragment.types.constants import GIFTS_PAGE -from pyfragment.utils import parse_gift_items - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def search_gifts( - client: FragmentClient, - query: str = "", - collection: str | None = None, - sort: str | None = None, - filter: str | None = None, - view: str | None = None, - attr: dict[str, list[str]] | None = None, - offset: int | None = None, -) -> GiftsResult: - """Search the Fragment gifts marketplace. - - Args: - client: Authenticated :class:`FragmentClient` instance. - query: Search text. Omit or pass ``""`` to browse without filtering by name. - collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. - sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or - ``"ending"``. Omit to use Fragment's default ordering. - filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` - (available items). Omit to return all. - view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. - attr: Attribute filters as a mapping of trait name to list of accepted values, e.g. - ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. - Each key is sent as ``attr[Key]`` with its list of values. - offset: Integer page offset from a previous :class:`GiftsResult`. - Pass ``next_offset`` to fetch the next page. - - Returns: - :class:`GiftsResult` with ``items`` (parsed list of item dicts) and - ``next_offset`` (``None`` on the last page). - - Raises: - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - data: dict[str, Any] = {"type": "gifts", "query": query} - if collection is not None: - data["collection"] = collection - if sort is not None: - data["sort"] = sort - if filter is not None: - data["filter"] = filter - if view is not None: - data["view"] = view - if attr is not None: - for trait, values in attr.items(): - data[f"attr[{trait}]"] = values - if offset is not None: - data["offset"] = offset - - try: - result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE) - - if result.get("error"): - raise FragmentAPIError(result["error"]) - - items, next_offset = parse_gift_items(result.get("html") or "") - return GiftsResult(items=items, next_offset=next_offset) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py deleted file mode 100644 index 87b02fa..0000000 --- a/pyfragment/methods/search_numbers.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError -from pyfragment.types.constants import NUMBERS_PAGE -from pyfragment.utils import parse_auction_rows - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def search_numbers( - client: FragmentClient, - query: str = "", - sort: str | None = None, - filter: str | None = None, - offset_id: str | None = None, -) -> NumbersResult: - """Search the Fragment marketplace for anonymous Telegram numbers. - - Args: - client: Authenticated :class:`FragmentClient` instance. - query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. - sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or - ``"ending"``. Omit to use Fragment's default ordering. - filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` - (available items). Omit to return all. - offset_id: Pagination cursor from a previous :class:`NumbersResult`. - Pass ``next_offset_id`` to fetch the next page. - - Returns: - :class:`NumbersResult` with ``items`` (parsed list of item dicts) and - ``next_offset_id`` (``None`` when there are no more pages). - - Raises: - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - data: dict[str, Any] = {"type": "numbers", "query": query} - if sort is not None: - data["sort"] = sort - if filter is not None: - data["filter"] = filter - if offset_id is not None: - data["offset_id"] = offset_id - - try: - result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE) - - if result.get("error"): - raise FragmentAPIError(result["error"]) - - items = parse_auction_rows(result.get("html") or "") - raw_noi = result.get("next_offset_id") - next_offset_id = str(raw_noi) if raw_noi else None - return NumbersResult(items=items, next_offset_id=next_offset_id) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py deleted file mode 100644 index 890dff2..0000000 --- a/pyfragment/methods/search_usernames.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult -from pyfragment.types.constants import FRAGMENT_BASE_URL -from pyfragment.utils import parse_auction_rows - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def search_usernames( - client: FragmentClient, - query: str = "", - sort: str | None = None, - filter: str | None = None, - offset_id: str | None = None, -) -> UsernamesResult: - """Search the Fragment marketplace for Telegram usernames. - - Args: - client: Authenticated :class:`FragmentClient` instance. - query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. - sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or - ``"ending"``. Omit to use Fragment's default ordering. - filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` - (available items). Omit to return all. - offset_id: Pagination cursor from a previous :class:`UsernamesResult`. - Pass ``next_offset_id`` to fetch the next page. - - Returns: - :class:`UsernamesResult` with ``items`` (parsed list of item dicts) and - ``next_offset_id`` (``None`` when there are no more pages). - - Raises: - FragmentAPIError: If the Fragment API returns an error. - UnexpectedError: For any other unexpected failure. - """ - data: dict[str, Any] = {"type": "usernames", "query": query} - if sort is not None: - data["sort"] = sort - if filter is not None: - data["filter"] = filter - if offset_id is not None: - data["offset_id"] = offset_id - - try: - result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL) - - if result.get("error"): - raise FragmentAPIError(result["error"]) - - items = parse_auction_rows(result.get("html") or "") - raw_noi = result.get("next_offset_id") - next_offset_id = str(raw_noi) if raw_noi else None - return UsernamesResult(items=items, next_offset_id=next_offset_id) - - except FragmentError: - raise - except Exception as exc: - raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/models/__init__.py b/pyfragment/models/__init__.py new file mode 100644 index 0000000..fc4a5cb --- /dev/null +++ b/pyfragment/models/__init__.py @@ -0,0 +1,27 @@ +from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult +from pyfragment.models.cookies import CookieResult +from pyfragment.models.enums import PaymentMethod, WalletVersion +from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult +from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult, WalletInfo + +__all__ = [ + "AdsRechargeResult", + "AdsTopupResult", + "CookieResult", + "GiftsResult", + "LoginCodeResult", + "NumbersResult", + "PaymentMethod", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "TonTransferResult", + "UsernamesResult", + "UsdtTransferResult", + "WalletInfo", + "WalletVersion", +] diff --git a/pyfragment/models/anonymous_numbers.py b/pyfragment/models/anonymous_numbers.py new file mode 100644 index 0000000..1d78977 --- /dev/null +++ b/pyfragment/models/anonymous_numbers.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class LoginCodeResult: + number: str + code: str | None + active_sessions: int + + def __repr__(self) -> str: + code_str = f"'{self.code}'" if self.code else "None" + return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" + + +@dataclass +class TerminateSessionsResult: + number: str + message: str | None + + def __repr__(self) -> str: + return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" + + +__all__ = ["LoginCodeResult", "TerminateSessionsResult"] diff --git a/pyfragment/models/cookies.py b/pyfragment/models/cookies.py new file mode 100644 index 0000000..6c218ce --- /dev/null +++ b/pyfragment/models/cookies.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class CookieResult: + cookies: dict[str, str] + expires: str | None + + def __repr__(self) -> str: + return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})" + + +__all__ = ["CookieResult"] diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py new file mode 100644 index 0000000..0c29bcf --- /dev/null +++ b/pyfragment/models/enums.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import Literal + +PaymentMethod = Literal["ton", "usdt_ton"] +WalletVersion = Literal["V4R2", "V5R1"] + +__all__ = ["PaymentMethod", "WalletVersion"] diff --git a/pyfragment/models/giveaways.py b/pyfragment/models/giveaways.py new file mode 100644 index 0000000..3c9c02c --- /dev/null +++ b/pyfragment/models/giveaways.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class StarsGiveawayResult: + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} stars per winner, tx='{self.transaction_id}')" + ) + + +@dataclass +class PremiumGiveawayResult: + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} months per winner, tx='{self.transaction_id}')" + ) + + +__all__ = ["PremiumGiveawayResult", "StarsGiveawayResult"] diff --git a/pyfragment/models/marketplace.py b/pyfragment/models/marketplace.py new file mode 100644 index 0000000..d773095 --- /dev/null +++ b/pyfragment/models/marketplace.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class UsernamesResult: + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class NumbersResult: + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class GiftsResult: + items: list[dict[str, Any]] + next_offset: int | None + + def __repr__(self) -> str: + return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" + + +__all__ = ["GiftsResult", "NumbersResult", "UsernamesResult"] diff --git a/pyfragment/models/payments.py b/pyfragment/models/payments.py new file mode 100644 index 0000000..9eb14f5 --- /dev/null +++ b/pyfragment/models/payments.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class PremiumResult: + transaction_id: str + username: str + amount: int + + def __repr__(self) -> str: + return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')" + + +@dataclass +class StarsResult: + transaction_id: str + username: str + amount: int + + def __repr__(self) -> str: + return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" + + +@dataclass +class AdsTopupResult: + transaction_id: str + username: str + amount: int + + def __repr__(self) -> str: + return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')" + + +@dataclass +class AdsRechargeResult: + transaction_id: str + amount: int + + def __repr__(self) -> str: + return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')" + + +__all__ = ["AdsRechargeResult", "AdsTopupResult", "PremiumResult", "StarsResult"] diff --git a/pyfragment/models/wallet.py b/pyfragment/models/wallet.py new file mode 100644 index 0000000..17d38ff --- /dev/null +++ b/pyfragment/models/wallet.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class WalletInfo: + address: str + state: str + ton_balance: float + usdt_balance: float + + def __repr__(self) -> str: + return ( + f"WalletInfo(address='{self.address}', state='{self.state}', " + f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)" + ) + + +@dataclass +class TonTransferResult: + transaction_id: str + destination: str + amount: int + + def __repr__(self) -> str: + return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')" + + +@dataclass +class UsdtTransferResult: + transaction_id: str + destination: str + amount: int + + def __repr__(self) -> str: + return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')" + + +__all__ = ["TonTransferResult", "UsdtTransferResult", "WalletInfo"] diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py deleted file mode 100644 index c17dfb0..0000000 --- a/pyfragment/types/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -from pyfragment.types.constants import PaymentMethod -from pyfragment.types.exceptions import ( - AnonymousNumberError, - ClientError, - ConfigurationError, - CookieError, - FragmentAPIError, - FragmentError, - FragmentPageError, - OperationError, - ParseError, - TransactionError, - UnexpectedError, - UserNotFoundError, - VerificationError, - WalletError, -) -from pyfragment.types.results import ( - AdsRechargeResult, - AdsTopupResult, - CookieResult, - GiftsResult, - LoginCodeResult, - NumbersResult, - PremiumGiveawayResult, - PremiumResult, - StarsGiveawayResult, - StarsResult, - TerminateSessionsResult, - TonTransferResult, - UsdtTransferResult, - UsernamesResult, - WalletInfo, -) - -__all__ = [ - # client exceptions - "ClientError", - "ConfigurationError", - "CookieError", - # fragment exceptions - "FragmentAPIError", - "FragmentError", - "FragmentPageError", - "AnonymousNumberError", - "OperationError", - "ParseError", - "TransactionError", - "UnexpectedError", - "UserNotFoundError", - "VerificationError", - "WalletError", - # result types - "AdsRechargeResult", - "AdsTopupResult", - "CookieResult", - "GiftsResult", - "LoginCodeResult", - "NumbersResult", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", - "TerminateSessionsResult", - "TonTransferResult", - "UsdtTransferResult", - "UsernamesResult", - "WalletInfo", - # literal types - "PaymentMethod", -] diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py deleted file mode 100644 index 2edd815..0000000 --- a/pyfragment/types/results.py +++ /dev/null @@ -1,248 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass -class CookieResult: - """Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`. - - Attributes: - cookies: Dict with the four required Fragment cookie keys. - expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC), - or ``None`` for session cookies. - """ - - cookies: dict[str, str] - expires: str | None - - def __repr__(self) -> str: - return f"CookieResult(expires={self.expires!r})" - - -@dataclass -class WalletInfo: - """Wallet state returned by :meth:`FragmentClient.get_wallet`.""" - - address: str - state: str - ton_balance: float - usdt_balance: float - - def __repr__(self) -> str: - return ( - f"WalletInfo(address='{self.address}', state='{self.state}', " - f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)" - ) - - -@dataclass -class PremiumResult: - """Result of a successful Telegram Premium gift.""" - - transaction_id: str - username: str - amount: int - - def __repr__(self) -> str: - return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')" - - -@dataclass -class StarsResult: - """Result of a successful Telegram Stars purchase.""" - - transaction_id: str - username: str - amount: int - - def __repr__(self) -> str: - return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" - - -@dataclass -class AdsTopupResult: - """Result of a successful Telegram Ads balance top-up.""" - - transaction_id: str - username: str - amount: int - - def __repr__(self) -> str: - return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')" - - -@dataclass -class StarsGiveawayResult: - """Result of a successful Telegram Stars giveaway.""" - - transaction_id: str - channel: str - winners: int - amount: int - - def __repr__(self) -> str: - return ( - f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, " - f"amount={self.amount} stars per winner, tx='{self.transaction_id}')" - ) - - -@dataclass -class PremiumGiveawayResult: - """Result of a successful Telegram Premium giveaway.""" - - transaction_id: str - channel: str - winners: int - amount: int - - def __repr__(self) -> str: - return ( - f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " - f"amount={self.amount} months per winner, tx='{self.transaction_id}')" - ) - - -@dataclass -class LoginCodeResult: - """Result of :meth:`FragmentClient.get_login_code`.""" - - number: str - code: str | None - active_sessions: int - - def __repr__(self) -> str: - code_str = f"'{self.code}'" if self.code else "None" - return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" - - -@dataclass -class AdsRechargeResult: - """Result of a successful self-recharge of Telegram Ads balance.""" - - transaction_id: str - amount: int - - def __repr__(self) -> str: - return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')" - - -@dataclass -class TerminateSessionsResult: - """Result of :meth:`FragmentClient.terminate_sessions`.""" - - number: str - message: str | None - - def __repr__(self) -> str: - return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" - - -@dataclass -class UsernamesResult: - """Result of :meth:`FragmentClient.search_usernames`. - - Each dict in ``items`` has the keys: - - - ``slug`` — URL path (e.g. ``"username/durov"``). - - ``name`` — display value (e.g. ``"@durov"``). - - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. - - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. - - Use ``next_offset_id`` to paginate to the next page of results. - """ - - items: list[dict[str, Any]] - next_offset_id: str | None - - def __repr__(self) -> str: - return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" - - -@dataclass -class NumbersResult: - """Result of :meth:`FragmentClient.search_numbers`. - - Each dict in ``items`` has the keys: - - - ``slug`` — URL path (e.g. ``"number/8880000111"``). - - ``name`` — display value (e.g. ``"+888 0000 111"``). - - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. - - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. - - Use ``next_offset_id`` to paginate to the next page of results. - """ - - items: list[dict[str, Any]] - next_offset_id: str | None - - def __repr__(self) -> str: - return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" - - -@dataclass -class GiftsResult: - """Result of :meth:`FragmentClient.search_gifts`. - - Each dict in ``items`` has the keys: - - - ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``). - - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). - - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). - - ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``. - - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. - - Use ``next_offset`` to paginate to the next page of results. - """ - - items: list[dict[str, Any]] - next_offset: int | None - - def __repr__(self) -> str: - return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" - - -@dataclass -class TonTransferResult: - """Result of a direct TON transfer via :meth:`FragmentClient.send_ton`.""" - - transaction_id: str - destination: str - amount: int - - def __repr__(self) -> str: - return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')" - - -@dataclass -class UsdtTransferResult: - """Result of a direct USDT transfer via :meth:`FragmentClient.send_usdt`.""" - - transaction_id: str - destination: str - amount: int - - def __repr__(self) -> str: - return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')" - - -__all__ = [ - "AdsRechargeResult", - "AdsTopupResult", - "GiftsResult", - "LoginCodeResult", - "NumbersResult", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", - "TerminateSessionsResult", - "TonTransferResult", - "UsdtTransferResult", - "UsernamesResult", - "WalletInfo", -] diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py deleted file mode 100644 index 9e90669..0000000 --- a/pyfragment/utils/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from pyfragment.utils.api import ( - execute_transaction_request, - fragment_request, - get_fragment_hash, - parse_json_response, -) -from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser -from pyfragment.utils.parser import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount -from pyfragment.utils.wallet import clean_decode, get_account_info, process_transaction, send_ton_transfer, send_usdt_transfer - -__all__ = [ - "clean_decode", - "CookieResult", - "get_cookies_from_browser", - "parse_auction_rows", - "parse_gift_items", - "parse_login_code", - "parse_required_payment_amount", - "execute_transaction_request", - "fragment_request", - "get_account_info", - "get_fragment_hash", - "parse_json_response", - "process_transaction", - "send_ton_transfer", - "send_usdt_transfer", -] diff --git a/pyfragment/utils/parser.py b/pyfragment/utils/parser.py deleted file mode 100644 index d0b98b6..0000000 --- a/pyfragment/utils/parser.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any - -# Matches the login code inside a table-cell-value element. -CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') -# Counts active session rows in the HTML table. -ROW_RE = re.compile(r"]") - -# Auction table row parsing -ROW_BLOCK_RE = re.compile(r']*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)', re.DOTALL) -HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"') -VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<') -PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<") -DATETIME_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>') -DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>') -# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") -NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") - -# Gift grid item parsing -GRID_ITEM_RE = re.compile(r']*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)', re.DOTALL) -GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))') -GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<') -GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<') -GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<') -GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<') -GRID_DATETIME_RE = re.compile(r']+datetime="([^"]+)"') - - -def parse_login_code(html: str) -> tuple[str | None, int]: - """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. - - Args: - html: Raw HTML string returned by the Fragment API. - - Returns: - A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no - pending code is present, and ``active_sessions`` is the number of ```` - rows found (each row represents one active session). - """ - match = CODE_RE.search(html) - code = match.group(1).strip() if match else None - active_sessions = len(ROW_RE.findall(html)) - return code, active_sessions - - -def parse_auction_rows(html: str) -> list[dict[str, Any]]: - """Parse Fragment marketplace HTML into structured item dicts. - - Extracts each ```` and returns a list of dicts - with the following keys: - - - ``slug`` — URL path segment (e.g. ``"username/durov"``). - - ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``) - - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), - or ``None`` if not listed. - - ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``. - - Returns: - List of item dicts, one per table row. - """ - items: list[dict[str, Any]] = [] - for row_match in ROW_BLOCK_RE.finditer(html): - row = row_match.group(1) - - href_m = HREF_RE.search(row) - if not href_m: - continue - slug = href_m.group(1).lstrip("/") # e.g. "username/durov" - - # All tm-value spans in the row — first is the display name - values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] - name = values[0] if values else slug - - # Status: find the human-readable label from subsequent tm-value spans. - # Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888"). - status: str | None = None - for v in values[1:]: - if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v): - status = v - break - - # Price — look for icon-ton pattern, format as two decimal places - price_m = PRICE_RE.search(row) - price: str | None = None - if price_m: - raw_price = price_m.group(1).strip().replace(",", "") - try: - price = f"{float(raw_price):.2f}" - except ValueError: - price = raw_price - - # Datetime (ISO 8601) — auction end, sale date, or listing date. - time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) - date: str | None = time_m.group(1) if time_m else None - - items.append( - { - "slug": slug, - "name": name, - "status": status, - "price": price, - "date": date, - } - ) - return items - - -def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: - """Parse Fragment gifts grid HTML into structured item dicts. - - Extracts each ```` block and returns a list of dicts - with the following keys: - - - ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``). - - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). - - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). - - ``price`` — price in TON formatted to two decimal places, or ``None``. - - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. - - Returns: - Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer - page offset from ``data-next-offset``, or ``None`` on the last page. - """ - items: list[dict[str, Any]] = [] - for item_match in GRID_ITEM_RE.finditer(html): - block = item_match.group(0) - - href_m = GRID_HREF_RE.search(block) - if not href_m: - continue - slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821" - - name_m = GRID_NAME_RE.search(block) - num_m = GRID_NUM_RE.search(block) - item_name = name_m.group(1).strip() if name_m else slug - item_num = f" #{num_m.group(1)}" if num_m else "" - name = f"{item_name}{item_num}" - - status_m = GRID_STATUS_RE.search(block) - status: str | None = status_m.group(1).strip() if status_m else None - - price_m = GRID_PRICE_RE.search(block) - price: str | None = None - if price_m: - raw_price = price_m.group(1).strip().replace(",", "") - try: - price = f"{float(raw_price):.2f}" - except ValueError: - price = raw_price - - time_m = GRID_DATETIME_RE.search(block) - date: str | None = time_m.group(1) if time_m else None - - items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) - - # Pagination offset from data-next-offset attribute - next_offset_m = re.search(r'data-next-offset="(\d+)"', html) - next_offset = int(next_offset_m.group(1)) if next_offset_m else None - - return items, next_offset - - -def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None: - """Extract required payment amount from init*Request response.""" - raw_amount = init_response.get("amount") - try: - return float(str(raw_amount)) - except (TypeError, ValueError): - return None diff --git a/pyfragment/utils/wallet/__init__.py b/pyfragment/utils/wallet/__init__.py deleted file mode 100644 index df80822..0000000 --- a/pyfragment/utils/wallet/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from pyfragment.utils.wallet.balance import get_usdt_balance -from pyfragment.utils.wallet.info import get_account_info, get_wallet_info -from pyfragment.utils.wallet.transaction import clean_decode, process_transaction -from pyfragment.utils.wallet.transfer import send_ton_transfer, send_usdt_transfer - -__all__ = [ - "get_account_info", - "get_usdt_balance", - "get_wallet_info", - "process_transaction", - "clean_decode", - "send_ton_transfer", - "send_usdt_transfer", -] diff --git a/pyfragment/utils/wallet/balance.py b/pyfragment/utils/wallet/balance.py deleted file mode 100644 index 60b7a11..0000000 --- a/pyfragment/utils/wallet/balance.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method -from tonutils.exceptions import ProviderResponseError - -from pyfragment.types import WalletError -from pyfragment.types.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS - - -async def get_usdt_balance(ton: Any, wallet_address: str) -> float: - """Return wallet USDT balance via tonutils jetton get-methods.""" - try: - jetton_wallet_address = await get_wallet_address_get_method( - client=ton, - address=USDT_TON_MASTER_ADDRESS, - owner_address=wallet_address, - ) - wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address) - raw_balance = int(wallet_data[0]) if wallet_data else 0 - return float(raw_balance) / 1_000_000.0 - except ProviderResponseError as exc: - # No jetton wallet deployed yet -> effectively zero USDT balance. - if exc.code == 404: - return 0.0 - raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc - except Exception as exc: - raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc - - -async def check_ton_payment_balance( - balance_ton: float, - amount_ton: float, - required_payment_amount: float | None, -) -> None: - """Validate balance requirements for TON payment method.""" - tx_price_ton = amount_ton - if required_payment_amount is not None and required_payment_amount > 0: - tx_price_ton = max(tx_price_ton, required_payment_amount) - - required_ton = max(tx_price_ton, MIN_TON_BALANCE) - if balance_ton < required_ton: - raise WalletError( - WalletError.LOW_TON_BALANCE.format( - balance=balance_ton, - required=required_ton, - ) - ) - - -async def check_usdt_payment_balance( - balance_ton: float, - required_payment_amount: float | None, - ton: Any, - wallet_address: str, -) -> None: - """Validate balance requirements for USDT payment method.""" - # USDT payment still needs TON for network fees. - if balance_ton < MIN_TON_BALANCE: - raise WalletError( - WalletError.LOW_TON_BALANCE.format( - balance=balance_ton, - required=MIN_TON_BALANCE, - ) - ) - - usdt_balance = await get_usdt_balance(ton, wallet_address) - required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE - if usdt_balance < required_usdt: - raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) diff --git a/pyfragment/utils/wallet/info.py b/pyfragment/utils/wallet/info.py deleted file mode 100644 index 9de0667..0000000 --- a/pyfragment/utils/wallet/info.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -import base64 -from typing import TYPE_CHECKING, Any - -from ton_core import NetworkGlobalID -from tonutils.clients import TonapiClient - -from pyfragment.types import WalletError, WalletInfo -from pyfragment.types.constants import WALLET_CLASSES -from pyfragment.utils.wallet.balance import get_usdt_balance - -if TYPE_CHECKING: - from pyfragment.client import FragmentClient - - -async def get_account_info(client: FragmentClient) -> dict[str, Any]: - """Fetch wallet address, public key, and state-init for the Fragment API. - - Fragment requires account info to build each transaction payload. The - returned dict is JSON-serialised and passed as the ``account`` field in - ``getBuy*Link`` / ``get*Link`` requests. - - Args: - client: Authenticated :class:`FragmentClient` instance. - - Returns: - Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``. - - Raises: - WalletError: If account info cannot be retrieved. - """ - async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: - try: - wallet_cls = WALLET_CLASSES[client.wallet_version] - wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - boc = wallet.state_init.serialize().to_boc() - return { - "address": wallet.address.to_str(False, False), - "publicKey": pub_key.as_hex, - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode(), - } - except Exception as exc: - raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc - - -async def get_wallet_info(client: FragmentClient) -> WalletInfo: - """Return the address, state and balance of the TON wallet. - - Args: - client: Authenticated :class:`FragmentClient` instance. - - Returns: - :class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON, - and ``usdt_balance`` in USDT. - - Raises: - WalletError: If the wallet state cannot be fetched. - """ - async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: - try: - wallet_cls = WALLET_CLASSES[client.wallet_version] - wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - await wallet.refresh() - wallet_address = wallet.address.to_str(False, False) - usdt_balance = await get_usdt_balance(ton, wallet_address) - return WalletInfo( - address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False), - state=wallet.state.value, - ton_balance=round(wallet.balance / 1_000_000_000, 4), - usdt_balance=round(usdt_balance, 4), - ) - except Exception as exc: - raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc diff --git a/pyproject.toml b/pyproject.toml index b69a7e6..423257f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.2.3" +version = "2026.3.0" description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." readme = "README.md" license = { text = "MIT" } @@ -54,14 +54,16 @@ classifiers = [ "Topic :: Office/Business :: Financial :: Investment", "Typing :: Typed", ] -dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"] +dependencies = ["httpx>=0.25", "tonutils>=2.0.1"] [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"] +browser = ["rookiepy>=0.5.6"] [project.urls] Homepage = "https://github.com/bohd4nx/pyfragment" Repository = "https://github.com/bohd4nx/pyfragment" +Documentation = "https://bohd4nx.gitbook.io/pyfragment/" Issues = "https://github.com/bohd4nx/pyfragment/issues" Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md" diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index d980358..9be867e 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,4 +1,4 @@ -"""Tests for clean_decode() — TON BOC payload decoding.""" +"""Decode Fragment BOC payloads so comments become text and structured messages stay raw.""" import base64 import re @@ -7,8 +7,8 @@ from unittest.mock import patch import pytest from ton_core import Cell -from pyfragment.types import ParseError -from pyfragment.utils.wallet.transaction import clean_decode +from pyfragment import ParseError +from pyfragment.domains.tonapi.transaction import clean_decode PAYLOAD_CASES = [ pytest.param( @@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None: raw = b"\xfb\xef\xff\x00" payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") - with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: + with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: result = clean_decode(payload) mocked.assert_called_once_with(raw) @@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None: return _FakeSlice() payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") - with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()): + with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()): parsed = clean_decode(payload) assert parsed == "Telegram Premium Ref#abc" @@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None: payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") fake_cell: object = _FakeCell() - with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell): + with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell): parsed = clean_decode(payload) assert parsed is fake_cell diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 16ced03..c8692ec 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -1,11 +1,10 @@ -"""Unit tests for FragmentClient — initialization, validation, and cookie parsing.""" +"""Validate FragmentClient setup, cookie parsing, and wallet version checks.""" import json import pytest -from pyfragment import FragmentClient -from pyfragment.types import ConfigurationError, CookieError +from pyfragment import ConfigurationError, CookieError, FragmentClient from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED # Client init tests diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 884a9aa..3f828c9 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -1,4 +1,4 @@ -"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" +"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows.""" from collections.abc import Generator from contextlib import contextmanager @@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from tonutils.exceptions import ProviderResponseError -from pyfragment.types import TransactionError, WalletError -from pyfragment.utils.wallet import process_transaction +from pyfragment import TransactionError, WalletError +from pyfragment.domains.tonapi.transaction import process_transaction from tests.shared import VALID_SEED @@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock: @contextmanager def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: with ( - patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes, + patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi, + patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes, ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) @@ -63,7 +63,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: @pytest.mark.asyncio async def test_sufficient_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold - with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"): + with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" wallet.transfer.assert_called_once() @@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None: @pytest.mark.asyncio async def test_exact_minimum_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold - with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"): + with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @@ -123,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None: async def test_rate_limit_retries_and_succeeds() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) - with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""): + with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" assert wallet.transfer.call_count == 2 @@ -134,7 +134,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) err = _provider_error(406, "Duplicate msg_seqno") wallet.transfer = AsyncMock(side_effect=[err, err, err]) - with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""): + with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""): with pytest.raises(TransactionError, match="seqno"): await process_transaction(_make_client(), TRANSACTION_DATA) assert wallet.transfer.call_count == 3 @@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None: @pytest.mark.asyncio async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE - with _patch_wallet(wallet), patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)): + with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): with pytest.raises(WalletError, match="Insufficient TON balance"): await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") @@ -166,8 +166,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None: with ( _patch_wallet(wallet), - patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""), - patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)), + patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""), + patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)), ): with pytest.raises(WalletError, match="Insufficient USDT balance"): await process_transaction( diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 976ad4c..69428d7 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -1,14 +1,13 @@ -"""Unit tests for Stars methods — purchase_stars and giveaway_stars.""" +"""Cover stars purchase and giveaway flows, including validation and request wiring.""" import importlib from unittest.mock import AsyncMock, patch import pytest -_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars") -_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars") -from pyfragment import FragmentClient -from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError +_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase") +_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") +from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Stars purchase validation tests diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 3d111b1..5ff8caf 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -1,14 +1,13 @@ -"""Unit tests for Premium methods — purchase_premium and giveaway_premium.""" +"""Cover premium purchase and giveaway flows, including validation and request wiring.""" import importlib from unittest.mock import AsyncMock, patch import pytest -_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium") -_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium") -from pyfragment import FragmentClient -from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError +_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase") +_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway") +from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index f1aede7..0fd366d 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -1,13 +1,12 @@ -"""Unit tests for topup_ton — TON Ads balance top-up.""" +"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building.""" import importlib from unittest.mock import AsyncMock, patch import pytest -_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton") -from pyfragment import FragmentClient -from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError +_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup") +from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup TON validation tests diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index 8be255a..2010aa6 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -1,4 +1,4 @@ -"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances.""" +"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi.""" from unittest.mock import AsyncMock, MagicMock, patch @@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: mock_wallet.address.to_str.return_value = FAKE_ADDRESS with ( - patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)), + patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, + patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, + patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) @@ -45,9 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: mock_wallet.address.to_str.return_value = FAKE_ADDRESS with ( - patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)), + patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, + patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, + patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) diff --git a/tests/008_test_call.py b/tests/008_test_call.py index 668794b..2ff6e75 100644 --- a/tests/008_test_call.py +++ b/tests/008_test_call.py @@ -1,13 +1,12 @@ -"""Unit tests for FragmentClient.call() — raw Fragment API access.""" +"""Check raw Fragment API calls and transport error handling.""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest -from pyfragment import FragmentClient -from pyfragment.types import FragmentPageError -from pyfragment.utils.api import fragment_request +from pyfragment import FragmentClient, FragmentPageError +from pyfragment.core.transport import fragment_request from tests.shared import FAKE_HASH, FAKE_RESPONSE # client.call() mocked tests @@ -26,7 +25,6 @@ async def test_call_returns_api_response(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_call_default_page_url(client: FragmentClient) -> None: - """call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL).""" with ( patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), @@ -38,7 +36,6 @@ async def test_call_default_page_url(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_call_no_data(client: FragmentClient) -> None: - """call() with no extra data passes only the method field.""" mock_request = AsyncMock(return_value={}) with ( @@ -53,7 +50,6 @@ async def test_call_no_data(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_call_merges_extra_data(client: FragmentClient) -> None: - """call() merges caller-supplied data with the method field.""" mock_request = AsyncMock(return_value={}) with ( @@ -71,7 +67,6 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_fragment_request_non_200_raises() -> None: - """fragment_request raises FragmentPageError on non-200 HTTP responses.""" response = MagicMock(spec=httpx.Response) response.status_code = 429 diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index f0e090c..9706b48 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -1,13 +1,12 @@ -"""Unit tests for recharge_ads — self-service Telegram Ads recharge.""" +"""Cover Telegram Ads recharge flow, including request preparation and KYC handling.""" import importlib from unittest.mock import AsyncMock, patch import pytest -_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads") -from pyfragment import FragmentClient -from pyfragment.types import AdsRechargeResult, ConfigurationError +_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge") +from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests diff --git a/tests/011_test_usernames.py b/tests/011_test_usernames.py index a7a0816..fe12671 100644 --- a/tests/011_test_usernames.py +++ b/tests/011_test_usernames.py @@ -1,11 +1,10 @@ -"""Unit tests for search_usernames — Fragment marketplace username search.""" +"""Verify username search parsing and query forwarding.""" from unittest.mock import AsyncMock, patch import pytest -from pyfragment import FragmentClient -from pyfragment.types import UsernamesResult +from pyfragment import FragmentClient, UsernamesResult FAKE_HTML = """ diff --git a/tests/012_test_numbers.py b/tests/012_test_numbers.py index 50b25d3..1492b10 100644 --- a/tests/012_test_numbers.py +++ b/tests/012_test_numbers.py @@ -1,11 +1,10 @@ -"""Unit tests for search_numbers — Fragment marketplace number search.""" +"""Verify anonymous number search parsing and query forwarding.""" from unittest.mock import AsyncMock, patch import pytest -from pyfragment import FragmentClient -from pyfragment.types import NumbersResult +from pyfragment import FragmentClient, NumbersResult FAKE_HTML = """ diff --git a/tests/013_test_gifts.py b/tests/013_test_gifts.py index 747dab5..8c0ec0b 100644 --- a/tests/013_test_gifts.py +++ b/tests/013_test_gifts.py @@ -1,11 +1,10 @@ -"""Unit tests for search_gifts — Fragment gifts marketplace search.""" +"""Verify gift search parsing and pagination handling.""" from unittest.mock import AsyncMock, patch import pytest -from pyfragment import FragmentClient -from pyfragment.types import GiftsResult +from pyfragment import FragmentClient, GiftsResult FAKE_GIFTS_HTML = """
diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index 83d6c3d..a390e29 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -1,12 +1,11 @@ -"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper.""" +"""Extract Fragment cookies from browser stores and validate required keys.""" from unittest.mock import MagicMock, patch import pytest -from pyfragment.types import CookieError -from pyfragment.types.constants import REQUIRED_COOKIE_KEYS -from pyfragment.utils import get_cookies_from_browser +from pyfragment import CookieError, get_cookies_from_browser +from pyfragment.core.constants import REQUIRED_COOKIE_KEYS FAKE_JAR = [ {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"}, @@ -23,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock: return mock -PATCH = "pyfragment.utils.cookies.rookiepy" +PATCH = "pyfragment.core.cookies.rookiepy" # unsupported browser tests diff --git a/tests/015_test_payment_amount.py b/tests/015_test_payment_amount.py index 1bc3464..5a89fa9 100644 --- a/tests/015_test_payment_amount.py +++ b/tests/015_test_payment_amount.py @@ -1,6 +1,6 @@ -"""Unit tests for init payment amount parsing.""" +"""Parse Fragment init responses to the payment amount the transaction should cover.""" -from pyfragment.utils.parser import parse_required_payment_amount +from pyfragment.domains.payments import parse_required_payment_amount def test_parse_required_payment_amount_ton_uses_amount() -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 9524b34..94e7f63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,23 @@ +"""Shared pytest fixtures for Fragment client tests.""" + import json import os from typing import cast import pytest -import pyfragment.methods.giveaway_premium # noqa: F401 -import pyfragment.methods.giveaway_stars # noqa: F401 -import pyfragment.methods.purchase_premium # noqa: F401 -import pyfragment.methods.purchase_stars # noqa: F401 -import pyfragment.methods.recharge_ads # noqa: F401 -import pyfragment.methods.topup_ton # noqa: F401 +import pyfragment.domains.ads.recharge # noqa: F401 +import pyfragment.domains.ads.tonup # noqa: F401 +import pyfragment.domains.giveaways.giveaway # noqa: F401 +import pyfragment.domains.purchases.purchase # noqa: F401 +import pyfragment.domains.tonapi.account # noqa: F401 +import pyfragment.domains.tonapi.transaction # noqa: F401 from pyfragment import FragmentClient from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED @pytest.fixture def cookies() -> dict[str, str]: - """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable.""" raw = os.environ.get("COOKIES_JSON") if not raw: pytest.skip("COOKIES_JSON env var not set") @@ -28,5 +29,4 @@ def cookies() -> dict[str, str]: @pytest.fixture def client() -> FragmentClient: - """Pre-built FragmentClient with dummy credentials.""" return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)