feat: Implement marketplace and purchases services

- Added MarketplaceService for searching usernames, numbers, and gifts.
- Introduced PurchasesService for purchasing stars and premium subscriptions.
- Created WalletService for wallet operations including balance checks and top-ups.
- Developed transaction handling for TON and USDT transfers.
- Added models for payments, marketplace results, and wallet information.
- Implemented error handling for various operations including wallet and transaction errors.
- Established domain structure for purchases, marketplace, and wallet functionalities.
This commit is contained in:
bohd4nx
2026-05-20 23:33:01 +03:00
parent e7dc0e051e
commit ae164d4fe7
83 changed files with 1090 additions and 1550 deletions
+5 -1
View File
@@ -54,15 +54,19 @@ 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: **Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **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: - **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 ```bash
pip install "pyfragment[browser]" pip install "pyfragment[browser]"
``` ```
```python ```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 = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient # result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies # 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. - **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. Refresh when you get authentication errors.
+2 -1
View File
@@ -10,9 +10,10 @@ Use next_offset for pagination.
import asyncio import asyncio
import json import json
from pyfragment import FragmentClient, GiftsResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Use next_offset_id for pagination.
import asyncio import asyncio
import json import json
from pyfragment import FragmentClient, NumbersResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Use next_offset_id for pagination.
import asyncio import asyncio
import json import json
from pyfragment import FragmentClient, UsernamesResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -11,9 +11,10 @@ Defaults to the Fragment base URL.
import asyncio import asyncio
from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -7,9 +7,10 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
import asyncio import asyncio
from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -8,9 +8,10 @@ Use terminate_sessions() to forcefully end all active Telegram sessions.
import asyncio import asyncio
from pyfragment import AnonymousNumberError, FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -7,13 +7,14 @@ Your wallet must satisfy the current minimum TON threshold and transaction cost.
import asyncio import asyncio
from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ( from pyfragment import (
AdsRechargeResult, AdsRechargeResult,
ConfigurationError, ConfigurationError,
FragmentClient, FragmentClient,
WalletError, WalletError,
) )
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,9 +9,10 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401 from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+2 -1
View File
@@ -9,13 +9,14 @@ Your wallet must satisfy the current minimum TON threshold and transaction cost.
import asyncio import asyncio
from pyfragment.utils import get_cookies_from_browser # noqa: F401
from pyfragment import ( from pyfragment import (
ConfigurationError, ConfigurationError,
FragmentClient, FragmentClient,
UserNotFoundError, UserNotFoundError,
WalletError, WalletError,
) )
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
+10 -18
View File
@@ -6,39 +6,30 @@
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
from pyfragment.types import ( from pyfragment.core.cookies import get_cookies_from_browser
AdsRechargeResult, from pyfragment.exceptions import (
AdsTopupResult,
AnonymousNumberError, AnonymousNumberError,
ClientError, ClientError,
ConfigurationError, ConfigurationError,
CookieError, CookieError,
CookieResult,
FragmentAPIError, FragmentAPIError,
# exceptions
FragmentError, FragmentError,
FragmentPageError, FragmentPageError,
GiftsResult,
LoginCodeResult,
NumbersResult,
OperationError, OperationError,
ParseError, ParseError,
# literal types
PaymentMethod,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
# results
StarsResult,
TerminateSessionsResult,
TransactionError, TransactionError,
UnexpectedError, UnexpectedError,
UsernamesResult,
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
WalletError, 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") __version__: str = version("pyfragment")
@@ -76,4 +67,5 @@ __all__ = [
"UnexpectedError", "UnexpectedError",
# literal types # literal types
"PaymentMethod", "PaymentMethod",
"get_cookies_from_browser",
] ]
+35 -53
View File
@@ -5,46 +5,22 @@ from typing import Any, cast, get_args
import httpx import httpx
from pyfragment.methods import ( from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
get_login_code, from pyfragment.core.transport import fragment_request, get_fragment_hash
giveaway_premium, from pyfragment.domains.ads.service import AdsService
giveaway_stars, from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
purchase_premium, from pyfragment.domains.giveaways.service import GiveawaysService
purchase_stars, from pyfragment.domains.marketplace.service import MarketplaceService
recharge_ads, from pyfragment.domains.purchases.service import PurchasesService
search_gifts, from pyfragment.domains.wallet.info import get_wallet_info
search_numbers, from pyfragment.domains.wallet.service import WalletService
search_usernames, from pyfragment.exceptions import ConfigurationError, CookieError
terminate_sessions, from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
toggle_login_codes, from pyfragment.models.enums import PaymentMethod, WalletVersion
topup_ton, from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
) from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.types import ( from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
AdsRechargeResult, from pyfragment.models.wallet import WalletInfo
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
class FragmentClient: class FragmentClient:
@@ -120,6 +96,12 @@ class FragmentClient:
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies) self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment] self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout self.timeout: float = timeout
self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self)
self.wallet = WalletService(self)
self.anonymous_numbers = AnonymousNumbersService(self)
self.ads = AdsService(self)
async def __aenter__(self) -> FragmentClient: async def __aenter__(self) -> FragmentClient:
return self return self
@@ -148,7 +130,7 @@ class FragmentClient:
Returns: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. :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( async def purchase_stars(
self, self,
@@ -168,7 +150,7 @@ class FragmentClient:
Returns: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. :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: async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance. """Top up TON to a recipient's Telegram balance.
@@ -181,7 +163,7 @@ class FragmentClient:
Returns: Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await topup_ton(self, username, amount, show_sender) return await self.wallet.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
@@ -194,7 +176,7 @@ class FragmentClient:
Returns: Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. :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: async def get_wallet(self) -> WalletInfo:
"""Return the address, state, and balances of the wallet. """Return the address, state, and balances of the wallet.
@@ -225,7 +207,7 @@ class FragmentClient:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``. ``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( async def giveaway_premium(
self, self,
@@ -246,7 +228,7 @@ class FragmentClient:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``. ``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: async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number. """Fetch the current pending login code for an anonymous number.
@@ -258,7 +240,7 @@ class FragmentClient:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count. 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: async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number. """Enable or disable login code delivery for an anonymous number.
@@ -267,7 +249,7 @@ class FragmentClient:
number: Phone number with or without leading ``+``. number: Phone number with or without leading ``+``.
can_receive: ``True`` to allow receiving codes, ``False`` to block them. 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: async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number. """Terminate all active Telegram sessions for an anonymous number.
@@ -281,7 +263,7 @@ class FragmentClient:
Raises: Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions. 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( async def search_usernames(
self, self,
@@ -305,7 +287,7 @@ class FragmentClient:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) :class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page). 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( async def search_numbers(
self, self,
@@ -329,7 +311,7 @@ class FragmentClient:
:class:`NumbersResult` with ``items`` (parsed list of item dicts) :class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page). 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( async def search_gifts(
self, self,
@@ -361,8 +343,8 @@ class FragmentClient:
:class:`GiftsResult` with ``items`` (parsed list of item dicts) :class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page). and ``next_offset`` (``None`` on the last page).
""" """
return await search_gifts( return await self.marketplace.search_gifts(
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
) )
async def call( async def call(
+1
View File
@@ -0,0 +1 @@
"""Low-level transport and shared helpers for pyfragment."""
@@ -1,34 +1,21 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Literal from typing import Any
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 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} WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum TON balance threshold required for payment flows.
MIN_TON_BALANCE: float = 0.33 MIN_TON_BALANCE: float = 0.33
# USDT (TON) jetton metadata used for payment-method balance checks.
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75 MIN_USDT_BALANCE: float = 0.75
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0 DEFAULT_TIMEOUT: float = 30.0
# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
# Fragment domain and page URLs FRAGMENT_DOMAIN: str = "fragment.com"
FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" 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" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
# Browsers supported by get_cookies_from_browser()
SUPPORTED_BROWSERS: frozenset[str] = frozenset( SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{ {
"arc", "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( DEVICE: str = json.dumps(
{ {
"platform": "iphone", "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] = { BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01", "accept": "application/json, text/javascript, */*; q=0.01",
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
@@ -3,38 +3,19 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from pyfragment.types import CookieError from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
from pyfragment.types import CookieResult as CookieResult from pyfragment.exceptions import CookieError
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS from pyfragment.models.cookies import CookieResult
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
"""Extract Fragment session cookies directly from an installed browser.
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() key = browser.lower()
if key not in SUPPORTED_BROWSERS: if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS)) supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try: try:
import rookiepy import rookiepy # type: ignore[import-not-found]
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc: except Exception as exc:
@@ -66,7 +47,4 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_dt < datetime.now(timezone.utc): if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso)) raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult( return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso,
)
@@ -7,8 +7,8 @@ from typing import Any, cast
import httpx import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
async def get_fragment_hash( async def get_fragment_hash(
@@ -17,25 +17,6 @@ async def get_fragment_hash(
page_url: str, page_url: str,
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
) -> str: ) -> 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 = { page_headers = {
k: v k: v
for k, v in headers.items() 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]: 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: try:
return cast(dict[str, Any], response.json()) return cast(dict[str, Any], response.json())
except Exception as exc: except Exception as exc:
@@ -89,21 +58,6 @@ async def fragment_request(
headers: dict[str, str], headers: dict[str, str],
data: dict[str, Any], data: dict[str, Any],
) -> 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): for attempt in range(3):
resp = await session.post( resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
@@ -127,21 +81,6 @@ async def execute_transaction_request(
tx_data: dict[str, Any], tx_data: dict[str, Any],
fragment_hash: str, fragment_hash: str,
) -> dict[str, Any]: ) -> 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) transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"): if transaction.get("need_verify"):
+1
View File
@@ -0,0 +1 @@
"""Domain service package for pyfragment."""
+1
View File
@@ -0,0 +1 @@
"""Ads domain services."""
@@ -3,38 +3,17 @@ from __future__ import annotations
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.types import ( from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
AdsRechargeResult, from pyfragment.domains.wallet.info import get_account_info
ConfigurationError, from pyfragment.domains.wallet.transaction import process_transaction
FragmentAPIError, from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
FragmentError, from pyfragment.models.payments import AdsRechargeResult
UnexpectedError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: 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): if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult
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)
@@ -0,0 +1 @@
"""Anonymous numbers domain services."""
@@ -3,16 +3,10 @@ from __future__ import annotations
import html import html
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.types import ( from pyfragment.core.constants import NUMBERS_PAGE
AnonymousNumberError, from pyfragment.domains.anonymous_numbers.parser import parse_login_code
FragmentAPIError, from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
FragmentError, from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
LoginCodeResult,
TerminateSessionsResult,
UnexpectedError,
)
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.utils import parse_login_code
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient 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: 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: try:
clean = _strip_plus(number) clean = _strip_plus(number)
result = await client.call( 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: 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: try:
clean = _strip_plus(number) clean = _strip_plus(number)
result = await client.call( 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: 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: try:
clean = _strip_plus(number) clean = _strip_plus(number)
@@ -0,0 +1,13 @@
from __future__ import annotations
import re
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
ROW_RE = re.compile(r"<tr[\s>]")
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
@@ -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)
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Giveaways domain services."""
+162
View File
@@ -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.wallet.info import get_account_info
from pyfragment.domains.wallet.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
+31
View File
@@ -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)
@@ -0,0 +1 @@
"""Marketplace domain services."""
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import re
from typing import Any
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', 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'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', 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'<time[^>]+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
+112
View File
@@ -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
+44
View File
@@ -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
)
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Purchases domain services."""
+157
View File
@@ -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.wallet.info import get_account_info
from pyfragment.domains.wallet.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
+31
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Wallet domain services."""
@@ -5,8 +5,8 @@ from typing import Any
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment.types import WalletError from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
from pyfragment.types.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS from pyfragment.exceptions import WalletError
async def get_usdt_balance(ton: Any, wallet_address: str) -> float: async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
@@ -6,9 +6,10 @@ from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient from tonutils.clients import TonapiClient
from pyfragment.types import WalletError, WalletInfo from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.types.constants import WALLET_CLASSES from pyfragment.domains.wallet.balance import get_usdt_balance
from pyfragment.utils.wallet.balance import get_usdt_balance from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.wallet.info import get_wallet_info
from pyfragment.domains.wallet.topup import topup_ton
from pyfragment.models.payments import AdsTopupResult
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class WalletService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
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)
@@ -3,8 +3,10 @@ from __future__ import annotations
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.types import ( from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
AdsTopupResult, from pyfragment.domains.wallet.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -12,31 +14,13 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.models.payments import AdsTopupResult
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: 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): if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
@@ -10,9 +10,10 @@ from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment.types import ParseError, TransactionError, WalletError from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.types.constants import WALLET_CLASSES, PaymentMethod from pyfragment.domains.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.utils.wallet.balance 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: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -6,9 +6,9 @@ from ton_core import Address, NetworkGlobalID
from tonutils.clients import ToncenterClient from tonutils.clients import ToncenterClient
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
from pyfragment.types import TransactionError, WalletError from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.types.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES from pyfragment.exceptions import TransactionError, WalletError
from pyfragment.types.results import TonTransferResult, UsdtTransferResult from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
-25
View File
@@ -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",
]
-115
View File
@@ -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 124 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
-111
View File
@@ -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 15 or ``amount`` is not 5001 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
-105
View File
@@ -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
-101
View File
@@ -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
-75
View File
@@ -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
-62
View File
@@ -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
-62
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Domain data models for pyfragment."""
+26
View File
@@ -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"]
+15
View File
@@ -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(expires={self.expires!r})"
__all__ = ["CookieResult"]
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Literal
PaymentMethod = Literal["ton", "usdt_ton"]
WalletVersion = Literal["V4R2", "V5R1"]
__all__ = ["PaymentMethod", "WalletVersion"]
+34
View File
@@ -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"]
+34
View File
@@ -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"]
+45
View File
@@ -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"]
+40
View File
@@ -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"]
-71
View File
@@ -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",
]
-248
View File
@@ -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",
]
-27
View File
@@ -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",
]
-172
View File
@@ -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"<tr[\s>]")
# Auction table row parsing
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', 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'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
DATETIME_SHORT_RE = re.compile(r'<time[^>]+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'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', 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'<time[^>]+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 ``<tr>``
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 ``<tr class="tm-row-selectable">`` 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 ``<a class="tm-grid-item">`` 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
-14
View File
@@ -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",
]
+5 -5
View File
@@ -7,8 +7,8 @@ from unittest.mock import patch
import pytest import pytest
from ton_core import Cell from ton_core import Cell
from pyfragment.types import ParseError from pyfragment import ParseError
from pyfragment.utils.wallet.transaction import clean_decode from pyfragment.domains.wallet.transaction import clean_decode
PAYLOAD_CASES = [ PAYLOAD_CASES = [
pytest.param( pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00" raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") 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.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload) result = clean_decode(payload)
mocked.assert_called_once_with(raw) mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice() return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") 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.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc" 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("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell() fake_cell: object = _FakeCell()
with patch("pyfragment.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell): with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed is fake_cell assert parsed is fake_cell
+1 -2
View File
@@ -4,8 +4,7 @@ import json
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import ConfigurationError, CookieError, FragmentClient
from pyfragment.types import ConfigurationError, CookieError
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests # Client init tests
+11 -11
View File
@@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError from pyfragment import TransactionError, WalletError
from pyfragment.utils.wallet import process_transaction from pyfragment.domains.wallet.transaction import process_transaction
from tests.shared import VALID_SEED from tests.shared import VALID_SEED
@@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with ( with (
patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi, patch("pyfragment.domains.wallet.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.wallet.transaction.WALLET_CLASSES") as mock_classes,
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) 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 @pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None: async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold 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.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None: async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold 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.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" 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: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) 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.wallet.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 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) wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno") err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err]) 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.wallet.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"): with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3 assert wallet.transfer.call_count == 3
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: 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 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.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"): with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") 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 ( with (
_patch_wallet(wallet), _patch_wallet(wallet),
patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""),
patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)), patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
): ):
with pytest.raises(WalletError, match="Insufficient USDT balance"): with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction( await process_transaction(
+3 -4
View File
@@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars") _purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars") _giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
from pyfragment import FragmentClient from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests # Stars purchase validation tests
+3 -4
View File
@@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium") _purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium") _giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
from pyfragment import FragmentClient from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests # Premium purchase validation tests
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton") _topup_ton_mod = importlib.import_module("pyfragment.domains.wallet.topup")
from pyfragment import FragmentClient from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests # Topup TON validation tests
+6 -6
View File
@@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi, patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)), patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) 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 mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi, patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)), patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient, FragmentPageError
from pyfragment.types import FragmentPageError from pyfragment.core.transport import fragment_request
from pyfragment.utils.api import fragment_request
from tests.shared import FAKE_HASH, FAKE_RESPONSE from tests.shared import FAKE_HASH, FAKE_RESPONSE
# client.call() mocked tests # client.call() mocked tests
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads") _recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge")
from pyfragment import FragmentClient from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from pyfragment.types import AdsRechargeResult, ConfigurationError
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests # recharge_ads validation tests
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient, UsernamesResult
from pyfragment.types import UsernamesResult
FAKE_HTML = """ FAKE_HTML = """
<tr class="tm-row-selectable"> <tr class="tm-row-selectable">
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient, NumbersResult
from pyfragment.types import NumbersResult
FAKE_HTML = """ FAKE_HTML = """
<tr class="tm-row-selectable"> <tr class="tm-row-selectable">
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient, GiftsResult
from pyfragment.types import GiftsResult
FAKE_GIFTS_HTML = """ FAKE_GIFTS_HTML = """
<div class="tm-catalog-grid"> <div class="tm-catalog-grid">
+3 -4
View File
@@ -4,9 +4,8 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from pyfragment.types import CookieError from pyfragment import CookieError, get_cookies_from_browser
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS from pyfragment.core.constants import REQUIRED_COOKIE_KEYS
from pyfragment.utils import get_cookies_from_browser
FAKE_JAR = [ FAKE_JAR = [
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"}, {"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 return mock
PATCH = "pyfragment.utils.cookies.rookiepy" PATCH = "pyfragment.core.cookies.rookiepy"
# unsupported browser tests # unsupported browser tests
+1 -1
View File
@@ -1,6 +1,6 @@
"""Unit tests for init payment amount parsing.""" """Unit tests for init payment amount parsing."""
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: def test_parse_required_payment_amount_ton_uses_amount() -> None:
+4 -6
View File
@@ -4,12 +4,10 @@ from typing import cast
import pytest import pytest
import pyfragment.methods.giveaway_premium # noqa: F401 import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.methods.giveaway_stars # noqa: F401 import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.methods.purchase_premium # noqa: F401 import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.methods.purchase_stars # noqa: F401 import pyfragment.domains.wallet.topup # noqa: F401
import pyfragment.methods.recharge_ads # noqa: F401
import pyfragment.methods.topup_ton # noqa: F401
from pyfragment import FragmentClient from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED