From 66bcd22198a9742c651a782d39252118dacbf1e9 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 22:11:03 +0300 Subject: [PATCH 01/15] feat: update constants and validation for stars and payment methods; refactor enums for clarity --- pyfragment/client.py | 9 ++++--- pyfragment/core/constants.py | 25 ++++++------------ pyfragment/core/cookies.py | 7 +++--- pyfragment/domains/giveaways/giveaway.py | 16 ++++++------ pyfragment/domains/purchases/purchase.py | 14 +++++------ pyfragment/exceptions.py | 4 +-- pyfragment/models/enums.py | 32 +++++++++++++++++++++--- 7 files changed, 62 insertions(+), 45 deletions(-) diff --git a/pyfragment/client.py b/pyfragment/client.py index aa2bd11..0597175 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from typing import Any, cast, get_args +from typing import Any, cast from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS from pyfragment.domains.ads.service import AdsService @@ -82,13 +82,14 @@ class FragmentClient: @staticmethod def _normalize_wallet_version(wallet_version: str) -> WalletVersion: version = wallet_version.strip().upper() - if version not in get_args(WalletVersion): + try: + return WalletVersion(version) + except ValueError: raise ConfigurationError( ConfigurationError.UNSUPPORTED_VERSION.format( - version=version, supported=", ".join(sorted(get_args(WalletVersion))) + version=version, supported=", ".join(sorted(m.value for m in WalletVersion)) ) ) - return cast(WalletVersion, version) def __init__( self, diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py index 23a3ef0..4fc2ba7 100644 --- a/pyfragment/core/constants.py +++ b/pyfragment/core/constants.py @@ -7,6 +7,14 @@ from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} +# Stars limits +STARS_PURCHASE_MIN: int = 50 +STARS_PURCHASE_MAX: int = 10_000 +STARS_GIVEAWAY_MIN: int = 500 +STARS_GIVEAWAY_MAX: int = 1_000_000 +STARS_WINNERS_MIN: int = 1 +STARS_WINNERS_MAX: int = 5 + MIN_TON_BALANCE: float = 0.33 USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" MIN_USDT_BALANCE: float = 0.75 @@ -25,23 +33,6 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" -SUPPORTED_BROWSERS: frozenset[str] = frozenset( - { - "arc", - "brave", - "chrome", - "chromium", - "chromium_based", - "edge", - "firefox", - "firefox_based", - "librewolf", - "opera", - "opera_gx", - "safari", - "vivaldi", - } -) DEVICE: str = json.dumps( { diff --git a/pyfragment/core/cookies.py b/pyfragment/core/cookies.py index 12cb6f1..c800673 100644 --- a/pyfragment/core/cookies.py +++ b/pyfragment/core/cookies.py @@ -4,9 +4,10 @@ import importlib from datetime import datetime, timezone from typing import Any -from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS +from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS from pyfragment.exceptions import CookieError from pyfragment.models.cookies import CookieResult +from pyfragment.models.enums import SupportedBrowser try: import rookiepy @@ -18,8 +19,8 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: global rookiepy key = browser.lower() - if key not in SUPPORTED_BROWSERS: - supported = ", ".join(sorted(SUPPORTED_BROWSERS)) + if key not in SupportedBrowser._value2member_map_: + supported = ", ".join(sorted(b.value for b in SupportedBrowser)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) try: diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index e8ab668..ffe8091 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -3,9 +3,9 @@ from __future__ import annotations import json import logging import random -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING -from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE +from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE, STARS_GIVEAWAY_MIN, STARS_GIVEAWAY_MAX, STARS_WINNERS_MIN, STARS_WINNERS_MAX from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -34,15 +34,15 @@ async def giveaway_stars( amount: int, payment_method: PaymentMethod = "ton", ) -> StarsGiveawayResult: - if not isinstance(winners, int) or not (1 <= winners <= 5): + if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) - if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): + if not isinstance(amount, int) or not (STARS_GIVEAWAY_MIN <= amount <= STARS_GIVEAWAY_MAX): raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) - if payment_method not in get_args(PaymentMethod): + if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), + supported=", ".join(sorted(m.value for m in PaymentMethod)), ) ) @@ -128,11 +128,11 @@ async def giveaway_premium( 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): + if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), + supported=", ".join(sorted(PaymentMethod._value2member_map_)), ) ) diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 5d60c5d..b8aaf63 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -3,9 +3,9 @@ from __future__ import annotations import json import logging import time -from typing import TYPE_CHECKING, get_args +from typing import TYPE_CHECKING -from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE +from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE, STARS_PURCHASE_MIN, STARS_PURCHASE_MAX from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -34,13 +34,13 @@ async def purchase_stars( show_sender: bool = True, payment_method: PaymentMethod = "ton", ) -> StarsResult: - if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): + if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) - if payment_method not in get_args(PaymentMethod): + if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), + supported=", ".join(sorted(PaymentMethod._value2member_map_)), ) ) @@ -117,11 +117,11 @@ async def purchase_premium( ) -> PremiumResult: if months not in (3, 6, 12): raise ConfigurationError(ConfigurationError.INVALID_MONTHS) - if payment_method not in get_args(PaymentMethod): + if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(get_args(PaymentMethod))), + supported=", ".join(sorted(PaymentMethod._value2member_map_)), ) ) diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index d1337f6..21c7ca3 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -19,7 +19,7 @@ class ConfigurationError(ClientError): "Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com." ) INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." - INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1,000,000." + INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 10,000." INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000." INVALID_USERNAME = ( "Invalid username '{username}'. " @@ -27,7 +27,7 @@ class ConfigurationError(ClientError): ) INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000." - INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000." + INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000 (max total: 1,000,000)." INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}." diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 0c29bcf..a7f1c52 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -1,8 +1,32 @@ from __future__ import annotations -from typing import Literal +from enum import StrEnum -PaymentMethod = Literal["ton", "usdt_ton"] -WalletVersion = Literal["V4R2", "V5R1"] -__all__ = ["PaymentMethod", "WalletVersion"] +class PaymentMethod(StrEnum): + TON = "ton" + USDT_TON = "usdt_ton" + + +class WalletVersion(StrEnum): + V4R2 = "V4R2" + V5R1 = "V5R1" + + +class SupportedBrowser(StrEnum): + ARC = "arc" + BRAVE = "brave" + CHROME = "chrome" + CHROMIUM = "chromium" + CHROMIUM_BASED = "chromium_based" + EDGE = "edge" + FIREFOX = "firefox" + FIREFOX_BASED = "firefox_based" + LIBREWOLF = "librewolf" + OPERA = "opera" + OPERA_GX = "opera_gx" + SAFARI = "safari" + VIVALDI = "vivaldi" + + +__all__ = ["PaymentMethod", "SupportedBrowser", "WalletVersion"] From 5124af17ef1d7fbb82e08a643211a767411d2c0d Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 22:22:09 +0300 Subject: [PATCH 02/15] feat: reorganize constants into separate modules; update validation logic for mnemonic and API key lengths --- pyfragment/client.py | 12 +++-- pyfragment/core/constants.py | 68 ------------------------ pyfragment/core/constants/__init__.py | 60 +++++++++++++++++++++ pyfragment/core/constants/headers.py | 26 +++++++++ pyfragment/core/constants/limits.py | 30 +++++++++++ pyfragment/core/constants/ton.py | 17 ++++++ pyfragment/core/constants/urls.py | 12 +++++ pyfragment/domains/giveaways/giveaway.py | 17 ++++-- pyfragment/domains/purchases/purchase.py | 11 +++- pyfragment/domains/tonapi/account.py | 3 +- pyfragment/domains/tonapi/transaction.py | 3 +- pyfragment/exceptions.py | 37 ++++++++++--- pyfragment/models/enums.py | 11 +++- tests/002_test_client.py | 5 +- 14 files changed, 223 insertions(+), 89 deletions(-) delete mode 100644 pyfragment/core/constants.py create mode 100644 pyfragment/core/constants/__init__.py create mode 100644 pyfragment/core/constants/headers.py create mode 100644 pyfragment/core/constants/limits.py create mode 100644 pyfragment/core/constants/ton.py create mode 100644 pyfragment/core/constants/urls.py diff --git a/pyfragment/client.py b/pyfragment/client.py index 0597175..d0d5d26 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -3,7 +3,13 @@ from __future__ import annotations import json from typing import Any, cast -from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS +from pyfragment.core.constants import ( + DEFAULT_TIMEOUT, + FRAGMENT_BASE_URL, + MNEMONIC_WORD_COUNTS_VALID, + REQUIRED_COOKIE_KEYS, + TONAPI_KEY_MIN_LENGTH, +) from pyfragment.domains.ads.service import AdsService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService from pyfragment.domains.base import raw_api_call @@ -67,10 +73,10 @@ class FragmentClient: raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) word_count = len(seed.split()) - if word_count not in (12, 18, 24): + if word_count not in MNEMONIC_WORD_COUNTS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count)) - if len(api_key.strip()) < 68: + if len(api_key.strip()) < TONAPI_KEY_MIN_LENGTH: raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip()))) @staticmethod diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py deleted file mode 100644 index 4fc2ba7..0000000 --- a/pyfragment/core/constants.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import json -from typing import Any - -from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 - -WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} - -# Stars limits -STARS_PURCHASE_MIN: int = 50 -STARS_PURCHASE_MAX: int = 10_000 -STARS_GIVEAWAY_MIN: int = 500 -STARS_GIVEAWAY_MAX: int = 1_000_000 -STARS_WINNERS_MIN: int = 1 -STARS_WINNERS_MAX: int = 5 - -MIN_TON_BALANCE: float = 0.33 -USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" -MIN_USDT_BALANCE: float = 0.75 - -DEFAULT_TIMEOUT: float = 30.0 - -REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") - -FRAGMENT_DOMAIN: str = "fragment.com" -FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" -STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" -STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" -PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" -PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" -ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" -NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" -GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" - - -DEVICE: str = json.dumps( - { - "platform": "iphone", - "appName": "Tonkeeper", - "appVersion": "26.04.0", - "maxProtocolVersion": 2, - "features": [ - "SendTransaction", - {"name": "SendTransaction", "maxMessages": 255}, - {"name": "SignData", "types": ["text", "binary", "cell"]}, - ], - } -) - -BASE_HEADERS: dict[str, str] = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": FRAGMENT_BASE_URL, - "priority": "u=1, i", - "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', - "sec-ch-ua-mobile": "?1", - "sec-ch-ua-platform": '"Android"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - "user-agent": ( - "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36" - ), - "x-requested-with": "XMLHttpRequest", -} diff --git a/pyfragment/core/constants/__init__.py b/pyfragment/core/constants/__init__.py new file mode 100644 index 0000000..a49d4a3 --- /dev/null +++ b/pyfragment/core/constants/__init__.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pyfragment.core.constants.headers import BASE_HEADERS, DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS +from pyfragment.core.constants.limits import ( + MIN_TON_BALANCE, + MIN_USDT_BALANCE, + MNEMONIC_WORD_COUNTS_VALID, + PREMIUM_MONTHS_VALID, + PREMIUM_WINNERS_MAX, + PREMIUM_WINNERS_MIN, + STARS_GIVEAWAY_MAX, + STARS_GIVEAWAY_MIN, + STARS_PURCHASE_MAX, + STARS_PURCHASE_MIN, + STARS_WINNERS_MAX, + STARS_WINNERS_MIN, + TONAPI_KEY_MIN_LENGTH, +) +from pyfragment.core.constants.ton import DEVICE_INFO, USDT_TON_MASTER_ADDRESS +from pyfragment.core.constants.urls import ( + ADS_TOPUP_PAGE, + FRAGMENT_BASE_URL, + FRAGMENT_DOMAIN, + GIFTS_PAGE, + NUMBERS_PAGE, + PREMIUM_GIVEAWAY_PAGE, + PREMIUM_PAGE, + STARS_GIVEAWAY_PAGE, + STARS_PAGE, +) + +__all__ = [ + "ADS_TOPUP_PAGE", + "BASE_HEADERS", + "DEFAULT_TIMEOUT", + "DEVICE_INFO", + "FRAGMENT_BASE_URL", + "FRAGMENT_DOMAIN", + "GIFTS_PAGE", + "MIN_TON_BALANCE", + "MIN_USDT_BALANCE", + "MNEMONIC_WORD_COUNTS_VALID", + "NUMBERS_PAGE", + "PREMIUM_GIVEAWAY_PAGE", + "PREMIUM_MONTHS_VALID", + "PREMIUM_PAGE", + "PREMIUM_WINNERS_MAX", + "PREMIUM_WINNERS_MIN", + "REQUIRED_COOKIE_KEYS", + "STARS_GIVEAWAY_MAX", + "STARS_GIVEAWAY_MIN", + "STARS_GIVEAWAY_PAGE", + "STARS_PAGE", + "STARS_PURCHASE_MAX", + "STARS_PURCHASE_MIN", + "STARS_WINNERS_MAX", + "STARS_WINNERS_MIN", + "TONAPI_KEY_MIN_LENGTH", + "USDT_TON_MASTER_ADDRESS", +] diff --git a/pyfragment/core/constants/headers.py b/pyfragment/core/constants/headers.py new file mode 100644 index 0000000..f0462fd --- /dev/null +++ b/pyfragment/core/constants/headers.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pyfragment.core.constants.urls import FRAGMENT_BASE_URL + +DEFAULT_TIMEOUT: float = 30.0 + +REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") + +BASE_HEADERS: dict[str, str] = { + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": FRAGMENT_BASE_URL, + "priority": "u=1, i", + "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', + "sec-ch-ua-mobile": "?1", + "sec-ch-ua-platform": '"Android"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": ( + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36" + ), + "x-requested-with": "XMLHttpRequest", +} diff --git a/pyfragment/core/constants/limits.py b/pyfragment/core/constants/limits.py new file mode 100644 index 0000000..1b71c3e --- /dev/null +++ b/pyfragment/core/constants/limits.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +# Stars: direct purchase +STARS_PURCHASE_MIN: int = 50 +STARS_PURCHASE_MAX: int = 10_000 + +# Stars: giveaway per winner +STARS_GIVEAWAY_MIN: int = 500 +STARS_GIVEAWAY_MAX: int = 1_000_000 + +# Stars giveaway winners count +STARS_WINNERS_MIN: int = 1 +STARS_WINNERS_MAX: int = 5 + +# Premium giveaway winners count +PREMIUM_WINNERS_MIN: int = 1 +PREMIUM_WINNERS_MAX: int = 24_000 + +# Premium subscription durations (months) +PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12}) + +# Mnemonic phrase valid word counts +MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24}) + +# Tonapi API key minimum length +TONAPI_KEY_MIN_LENGTH: int = 68 + +# Wallet minimum balances +MIN_TON_BALANCE: float = 0.33 +MIN_USDT_BALANCE: float = 0.75 diff --git a/pyfragment/core/constants/ton.py b/pyfragment/core/constants/ton.py new file mode 100644 index 0000000..e76d645 --- /dev/null +++ b/pyfragment/core/constants/ton.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from typing import Any + +USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" + +DEVICE_INFO: dict[str, Any] = { + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "26.04.0", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], +} diff --git a/pyfragment/core/constants/urls.py b/pyfragment/core/constants/urls.py new file mode 100644 index 0000000..cba12b0 --- /dev/null +++ b/pyfragment/core/constants/urls.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +FRAGMENT_DOMAIN: str = "fragment.com" +FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" + +STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" +STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" +PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" +ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" +GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index ffe8091..642133f 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -5,7 +5,18 @@ import logging import random from typing import TYPE_CHECKING -from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE, STARS_GIVEAWAY_MIN, STARS_GIVEAWAY_MAX, STARS_WINNERS_MIN, STARS_WINNERS_MAX +from pyfragment.core.constants import ( + DEVICE, + PREMIUM_GIVEAWAY_PAGE, + PREMIUM_MONTHS_VALID, + PREMIUM_WINNERS_MAX, + PREMIUM_WINNERS_MIN, + STARS_GIVEAWAY_MAX, + STARS_GIVEAWAY_MIN, + STARS_GIVEAWAY_PAGE, + STARS_WINNERS_MAX, + STARS_WINNERS_MIN, +) from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -124,9 +135,9 @@ async def giveaway_premium( months: int = 3, payment_method: PaymentMethod = "ton", ) -> PremiumGiveawayResult: - if not isinstance(winners, int) or not (1 <= winners <= 24_000): + if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) - if months not in (3, 6, 12): + if months not in PREMIUM_MONTHS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MONTHS) if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index b8aaf63..7779269 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -5,7 +5,14 @@ import logging import time from typing import TYPE_CHECKING -from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE, STARS_PURCHASE_MIN, STARS_PURCHASE_MAX +from pyfragment.core.constants import ( + DEVICE, + PREMIUM_MONTHS_VALID, + PREMIUM_PAGE, + STARS_PAGE, + STARS_PURCHASE_MAX, + STARS_PURCHASE_MIN, +) from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -115,7 +122,7 @@ async def purchase_premium( show_sender: bool = True, payment_method: PaymentMethod = "ton", ) -> PremiumResult: - if months not in (3, 6, 12): + if months not in PREMIUM_MONTHS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MONTHS) if payment_method not in PaymentMethod._value2member_map_: raise ConfigurationError( diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index c532d35..52aab22 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -9,8 +9,9 @@ from tonutils.clients import TonapiClient from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method from tonutils.exceptions import ProviderResponseError -from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES +from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS from pyfragment.exceptions import WalletError +from pyfragment.models.enums import WALLET_CLASSES from pyfragment.models.wallet import WalletInfo if TYPE_CHECKING: diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 7cb5263..1740ca2 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -11,10 +11,9 @@ from ton_core import Cell, NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from pyfragment.core.constants import WALLET_CLASSES from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance from pyfragment.exceptions import ParseError, TransactionError, WalletError -from pyfragment.models.enums import PaymentMethod +from pyfragment.models.enums import WALLET_CLASSES, PaymentMethod if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 21c7ca3..75f4892 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -1,5 +1,19 @@ from __future__ import annotations +from pyfragment.core.constants.limits import ( + MNEMONIC_WORD_COUNTS_VALID, + PREMIUM_MONTHS_VALID, + PREMIUM_WINNERS_MAX, + PREMIUM_WINNERS_MIN, + STARS_GIVEAWAY_MAX, + STARS_GIVEAWAY_MIN, + STARS_PURCHASE_MAX, + STARS_PURCHASE_MIN, + STARS_WINNERS_MAX, + STARS_WINNERS_MIN, + TONAPI_KEY_MIN_LENGTH, +) + class FragmentError(Exception): """Base exception for all pyfragment errors.""" @@ -14,20 +28,29 @@ class ConfigurationError(ClientError): MISSING_VARS = "Missing required parameter(s): {keys}." UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}." - INVALID_MNEMONIC = "Invalid mnemonic phrase: expected 12, 18, or 24 words, got {count}." + INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}." INVALID_API_KEY = ( - "Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com." + f"Invalid Tonapi API key: expected at least {TONAPI_KEY_MIN_LENGTH} characters, got {{length}}. " + "Get a key at https://tonconsole.com." + ) + INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months." + INVALID_STARS_AMOUNT = ( + f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}." ) - INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." - INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 10,000." INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000." INVALID_USERNAME = ( "Invalid username '{username}'. " "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)." ) - INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." - INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000." - INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000 (max total: 1,000,000)." + INVALID_WINNERS_STARS = ( + f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}." + ) + INVALID_WINNERS_PREMIUM = ( + f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}." + ) + INVALID_STARS_PER_WINNER = ( + f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}." + ) INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}." diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index a7f1c52..6322b22 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -1,6 +1,9 @@ from __future__ import annotations from enum import StrEnum +from typing import Any + +from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 class PaymentMethod(StrEnum): @@ -13,6 +16,12 @@ class WalletVersion(StrEnum): V5R1 = "V5R1" +WALLET_CLASSES: dict[WalletVersion, Any] = { + WalletVersion.V4R2: WalletV4R2, + WalletVersion.V5R1: WalletV5R1, +} + + class SupportedBrowser(StrEnum): ARC = "arc" BRAVE = "brave" @@ -29,4 +38,4 @@ class SupportedBrowser(StrEnum): VIVALDI = "vivaldi" -__all__ = ["PaymentMethod", "SupportedBrowser", "WalletVersion"] +__all__ = ["PaymentMethod", "SupportedBrowser", "WALLET_CLASSES", "WalletVersion"] diff --git a/tests/002_test_client.py b/tests/002_test_client.py index c8692ec..c813236 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -5,6 +5,7 @@ import json import pytest from pyfragment import ConfigurationError, CookieError, FragmentClient +from pyfragment.core.constants.limits import MNEMONIC_WORD_COUNTS_VALID, TONAPI_KEY_MIN_LENGTH from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED # Client init tests @@ -55,7 +56,7 @@ def test_invalid_mnemonic_length_raises() -> None: def test_valid_mnemonic_lengths() -> None: - for length in (12, 18, 24): + for length in sorted(MNEMONIC_WORD_COUNTS_VALID): seed = " ".join(["abandon"] * (length - 1) + ["about"]) client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) assert len(client.seed.split()) == length @@ -71,7 +72,7 @@ def test_missing_api_key_raises() -> None: def test_short_api_key_raises() -> None: with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES) + FragmentClient(seed=VALID_SEED, api_key="A" * (TONAPI_KEY_MIN_LENGTH - 1), cookies=VALID_COOKIES) # Cookie validation tests From 6aa703738062f6583cfd18877cb67a7d41cc98a0 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 22:31:30 +0300 Subject: [PATCH 03/15] feat: update payment method defaults to use PaymentMethod enum; refactor related validation checks --- pyfragment/client.py | 8 ++++---- pyfragment/core/cookies.py | 2 +- pyfragment/domains/ads/recharge.py | 4 ++-- pyfragment/domains/ads/tonup.py | 4 ++-- pyfragment/domains/giveaways/giveaway.py | 16 ++++++++-------- pyfragment/domains/giveaways/service.py | 4 ++-- pyfragment/domains/purchases/purchase.py | 18 +++++++++--------- pyfragment/domains/purchases/service.py | 4 ++-- pyfragment/domains/tonapi/transaction.py | 2 +- pyfragment/models/enums.py | 10 +++++++++- tests/003_test_balance.py | 5 +++-- tests/004_test_stars.py | 5 +++-- tests/005_test_premium.py | 5 +++-- 13 files changed, 49 insertions(+), 38 deletions(-) diff --git a/pyfragment/client.py b/pyfragment/client.py index d0d5d26..7d7963a 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -136,7 +136,7 @@ class FragmentClient: username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumResult: """Gift Telegram Premium to a user. @@ -156,7 +156,7 @@ class FragmentClient: username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsResult: """Send Telegram Stars to a user. @@ -212,7 +212,7 @@ class FragmentClient: channel: str, winners: int, amount: int, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsGiveawayResult: """Run a Telegram Stars giveaway for a channel. @@ -233,7 +233,7 @@ class FragmentClient: channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumGiveawayResult: """Run a Telegram Premium giveaway for a channel. diff --git a/pyfragment/core/cookies.py b/pyfragment/core/cookies.py index c800673..f52769e 100644 --- a/pyfragment/core/cookies.py +++ b/pyfragment/core/cookies.py @@ -19,7 +19,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: global rookiepy key = browser.lower() - if key not in SupportedBrowser._value2member_map_: + if not any(key == m for m in SupportedBrowser): supported = ", ".join(sorted(b.value for b in SupportedBrowser)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index 4b0163e..b79d988 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError @@ -34,7 +34,7 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads "getAdsRechargeLink", { "account": json.dumps(account_info), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, }, diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index 0b30c26..df3d8c0 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -48,7 +48,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen "getAdsTopupLink", { "account": json.dumps(account), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, "show_sender": int(show_sender), diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index 642133f..d3ca352 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -6,7 +6,7 @@ import random from typing import TYPE_CHECKING from pyfragment.core.constants import ( - DEVICE, + DEVICE_INFO, PREMIUM_GIVEAWAY_PAGE, PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, @@ -43,13 +43,13 @@ async def giveaway_stars( channel: str, winners: int, amount: int, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsGiveawayResult: if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) if not isinstance(amount, int) or not (STARS_GIVEAWAY_MIN <= amount <= STARS_GIVEAWAY_MAX): raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) - if payment_method not in PaymentMethod._value2member_map_: + if not any(payment_method == m for m in PaymentMethod): raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, @@ -89,7 +89,7 @@ async def giveaway_stars( "getGiveawayStarsLink", { "account": json.dumps(account), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, }, @@ -133,17 +133,17 @@ async def giveaway_premium( channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumGiveawayResult: if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) if months not in PREMIUM_MONTHS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MONTHS) - if payment_method not in PaymentMethod._value2member_map_: + if not any(payment_method == m for m in PaymentMethod): raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(PaymentMethod._value2member_map_)), + supported=", ".join(sorted(m.value for m in PaymentMethod)), ) ) @@ -188,7 +188,7 @@ async def giveaway_premium( "getGiveawayPremiumLink", { "account": json.dumps(account), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, }, diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py index abbaefa..db70b05 100644 --- a/pyfragment/domains/giveaways/service.py +++ b/pyfragment/domains/giveaways/service.py @@ -17,7 +17,7 @@ class GiveawaysService(BaseService): channel: str, winners: int, amount: int, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsGiveawayResult: return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method) @@ -26,6 +26,6 @@ class GiveawaysService(BaseService): channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumGiveawayResult: return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method) diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 7779269..cf7b11d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -6,7 +6,7 @@ import time from typing import TYPE_CHECKING from pyfragment.core.constants import ( - DEVICE, + DEVICE_INFO, PREMIUM_MONTHS_VALID, PREMIUM_PAGE, STARS_PAGE, @@ -39,15 +39,15 @@ async def purchase_stars( username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsResult: if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) - if payment_method not in PaymentMethod._value2member_map_: + if not any(payment_method == m for m in PaymentMethod): raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(PaymentMethod._value2member_map_)), + supported=", ".join(sorted(m.value for m in PaymentMethod)), ) ) @@ -77,7 +77,7 @@ async def purchase_stars( "getBuyStarsLink", { "account": json.dumps(account), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, "show_sender": int(show_sender), @@ -120,15 +120,15 @@ async def purchase_premium( username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumResult: if months not in PREMIUM_MONTHS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MONTHS) - if payment_method not in PaymentMethod._value2member_map_: + if not any(payment_method == m for m in PaymentMethod): raise ConfigurationError( ConfigurationError.INVALID_PAYMENT_METHOD.format( method=payment_method, - supported=", ".join(sorted(PaymentMethod._value2member_map_)), + supported=", ".join(sorted(m.value for m in PaymentMethod)), ) ) @@ -158,7 +158,7 @@ async def purchase_premium( "getGiftPremiumLink", { "account": json.dumps(account), - "device": DEVICE, + "device": json.dumps(DEVICE_INFO), "transaction": 1, "id": req_id, "show_sender": int(show_sender), diff --git a/pyfragment/domains/purchases/service.py b/pyfragment/domains/purchases/service.py index 811a21b..638d3db 100644 --- a/pyfragment/domains/purchases/service.py +++ b/pyfragment/domains/purchases/service.py @@ -17,7 +17,7 @@ class PurchasesService(BaseService): username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> StarsResult: return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method) @@ -26,6 +26,6 @@ class PurchasesService(BaseService): username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, ) -> PremiumResult: return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method) diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 1740ca2..731c1c5 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -124,7 +124,7 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s async def process_transaction( client: FragmentClient, transaction_data: dict[str, Any], - payment_method: PaymentMethod = "ton", + payment_method: PaymentMethod = PaymentMethod.TON, required_payment_amount: float | None = None, ) -> str: """Sign and broadcast a Fragment transaction with the seeded TON wallet. diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 6322b22..6f2ec8e 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -1,10 +1,18 @@ from __future__ import annotations -from enum import StrEnum +import sys +from enum import Enum from typing import Any from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 +if sys.version_info >= (3, 11): + from enum import StrEnum +else: + + class StrEnum(str, Enum): # noqa: F811 + pass + class PaymentMethod(StrEnum): TON = "ton" diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 3f828c9..cedd4b5 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -9,6 +9,7 @@ from tonutils.exceptions import ProviderResponseError from pyfragment import TransactionError, WalletError from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.models.enums import PaymentMethod from tests.shared import VALID_SEED @@ -145,7 +146,7 @@ async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): with pytest.raises(WalletError, match="Insufficient TON balance"): - await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") + await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_TON) @pytest.mark.asyncio @@ -173,6 +174,6 @@ async def test_usdt_payment_checks_usdt_balance() -> None: await process_transaction( _make_client(), transaction, - payment_method="usdt_ton", + payment_method=PaymentMethod.USDT_TON, required_payment_amount=12.5, ) diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index e6c583a..49ec4a8 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -7,6 +7,7 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod import pyfragment.domains.purchases.purchase as _purchase_stars_mod from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError +from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Stars purchase validation tests @@ -78,7 +79,7 @@ async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> N patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_purchase_stars_mod, "process_transaction", proc_mock), ): - await client.purchase_stars("@user", amount=500, payment_method="usdt_ton") + await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_TON) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initBuyStarsRequest" @@ -198,7 +199,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_giveaway_stars_mod, "process_transaction", proc_mock), ): - await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton") + await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_TON) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayStarsRequest" diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index df07816..6667602 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -7,6 +7,7 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError +from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests @@ -75,7 +76,7 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_purchase_premium_mod, "process_transaction", proc_mock), ): - await client.purchase_premium("@user", months=6, payment_method="usdt_ton") + await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_TON) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiftPremiumRequest" @@ -183,7 +184,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_giveaway_premium_mod, "process_transaction", proc_mock), ): - await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton") + await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_TON) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiveawayPremiumRequest" From ed54d1f1a05c4d5199428c9b23b76e5426ff350b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 22:51:27 +0300 Subject: [PATCH 04/15] feat: enhance FragmentClient to support custom headers; update limits for TON topup and stars purchases --- pyfragment/client.py | 6 +++++- pyfragment/core/constants/__init__.py | 4 ++++ pyfragment/core/constants/limits.py | 16 ++++++++++------ pyfragment/domains/ads/recharge.py | 4 ++-- pyfragment/domains/ads/tonup.py | 4 ++-- pyfragment/domains/base.py | 8 +++++--- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/pyfragment/client.py b/pyfragment/client.py index 7d7963a..36b7f84 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -4,6 +4,7 @@ import json from typing import Any, cast from pyfragment.core.constants import ( + BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, MNEMONIC_WORD_COUNTS_VALID, @@ -40,6 +41,7 @@ class FragmentClient: cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). timeout: HTTP request timeout in seconds. Defaults to ``30.0``. + headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used. Raises: ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. @@ -104,6 +106,7 @@ class FragmentClient: cookies: dict[str, Any] | str, wallet_version: str = "V5R1", timeout: float = DEFAULT_TIMEOUT, + headers: dict[str, str] | None = None, ) -> None: self._validate_required(seed, api_key) parsed_cookies = self._parse_cookies(cookies) @@ -115,6 +118,7 @@ class FragmentClient: self.cookies: dict[str, Any] = parsed_cookies self.wallet_version: WalletVersion = version self.timeout: float = timeout + self.headers: dict[str, str] = headers if headers is not None else BASE_HEADERS self.marketplace = MarketplaceService(self) self.purchases = PurchasesService(self) self.giveaways = GiveawaysService(self) @@ -391,4 +395,4 @@ class FragmentClient: page_url="https://fragment.com/premium/gift", ) """ - return await raw_api_call(self.cookies, self.timeout, method, data, page_url) + return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers) diff --git a/pyfragment/core/constants/__init__.py b/pyfragment/core/constants/__init__.py index a49d4a3..4e5be9a 100644 --- a/pyfragment/core/constants/__init__.py +++ b/pyfragment/core/constants/__init__.py @@ -14,6 +14,8 @@ from pyfragment.core.constants.limits import ( STARS_PURCHASE_MIN, STARS_WINNERS_MAX, STARS_WINNERS_MIN, + TON_TOPUP_MAX, + TON_TOPUP_MIN, TONAPI_KEY_MIN_LENGTH, ) from pyfragment.core.constants.ton import DEVICE_INFO, USDT_TON_MASTER_ADDRESS @@ -56,5 +58,7 @@ __all__ = [ "STARS_WINNERS_MAX", "STARS_WINNERS_MIN", "TONAPI_KEY_MIN_LENGTH", + "TON_TOPUP_MAX", + "TON_TOPUP_MIN", "USDT_TON_MASTER_ADDRESS", ] diff --git a/pyfragment/core/constants/limits.py b/pyfragment/core/constants/limits.py index 1b71c3e..b054c80 100644 --- a/pyfragment/core/constants/limits.py +++ b/pyfragment/core/constants/limits.py @@ -2,7 +2,7 @@ from __future__ import annotations # Stars: direct purchase STARS_PURCHASE_MIN: int = 50 -STARS_PURCHASE_MAX: int = 10_000 +STARS_PURCHASE_MAX: int = 10_000_000 # Stars: giveaway per winner STARS_GIVEAWAY_MIN: int = 500 @@ -10,12 +10,20 @@ STARS_GIVEAWAY_MAX: int = 1_000_000 # Stars giveaway winners count STARS_WINNERS_MIN: int = 1 -STARS_WINNERS_MAX: int = 5 +STARS_WINNERS_MAX: int = 15 # Premium giveaway winners count PREMIUM_WINNERS_MIN: int = 1 PREMIUM_WINNERS_MAX: int = 24_000 +# TON topup / Ads recharge amount +TON_TOPUP_MIN: int = 1 +TON_TOPUP_MAX: int = 1_000_000_000 + +# Wallet minimum balances +MIN_TON_BALANCE: float = 0.33 +MIN_USDT_BALANCE: float = 0.75 + # Premium subscription durations (months) PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12}) @@ -24,7 +32,3 @@ MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24}) # Tonapi API key minimum length TONAPI_KEY_MIN_LENGTH: int = 68 - -# Wallet minimum balances -MIN_TON_BALANCE: float = 0.33 -MIN_USDT_BALANCE: float = 0.75 diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index b79d988..c79806a 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: - if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): + if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index df3d8c0..40d999b 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): + if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py index 6dce538..2e02aa7 100644 --- a/pyfragment/domains/base.py +++ b/pyfragment/domains/base.py @@ -20,14 +20,16 @@ async def raw_api_call( method: str, data: dict[str, Any] | None, page_url: str, + headers: dict[str, str] | None = None, ) -> dict[str, Any]: + base = headers if headers is not None else BASE_HEADERS payload = {"method": method, **(data or {})} - headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} + call_headers = {**base, "referer": page_url, "x-aj-referer": page_url} logger.debug("Starting Fragment API call '%s' on %s", method, page_url) try: async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: - fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout) - response = await fragment_request(session, fragment_hash, headers, payload) + fragment_hash = await get_fragment_hash(cookies, call_headers, page_url, timeout) + response = await fragment_request(session, fragment_hash, call_headers, payload) logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys())) return response except Exception: From afaa42776bbe0d29066544ff6c3523bb564e7785 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 22:59:41 +0300 Subject: [PATCH 05/15] feat: update payment method handling and validation across giveaway examples; adjust limits for winners and amounts --- examples/purchase/run_premium_giveaway.py | 4 ++-- examples/purchase/run_stars_giveaway.py | 8 ++++---- examples/purchase/send_premium.py | 4 ++-- examples/purchase/send_stars.py | 8 ++++---- pyfragment/client.py | 6 +++--- pyfragment/core/constants/ton.py | 2 +- pyfragment/exceptions.py | 4 +++- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 63526c5..1aefd83 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -3,13 +3,13 @@ Example: run a Telegram Premium giveaway for a channel. winners must be an integer between 1 and 24 000. months (Premium duration per winner) must be 3, 6, or 12. -payment_method can be "ton" or "usdt_ton". Channel can be "@channel", "channel", or "https://t.me/channel". """ import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.models.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 10 # 1–24 000 MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = "ton" # "ton" or "usdt_ton" +PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON async def main() -> None: diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 83dfc7a..47b124c 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -1,15 +1,15 @@ """ Example: run a Telegram Stars giveaway for a channel. -winners must be an integer between 1 and 5. +winners must be an integer between 1 and 15. amount (stars per winner) must be an integer between 500 and 1 000 000. -payment_method can be "ton" or "usdt_ton". Channel can be "@channel", "channel", or "https://t.me/channel". """ import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.models.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -26,9 +26,9 @@ COOKIES = { } CHANNEL = "https://t.me/channel" -WINNERS = 3 # 1–5 +WINNERS = 3 # 1–15 AMOUNT = 1000 # 500–1 000 000 stars per winner -PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton" +PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON async def main() -> None: diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index dab0d48..9cd1f98 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -3,13 +3,13 @@ Example: purchase Telegram Premium for a user. Supported durations: 3, 6, or 12 months. Set show_sender=False to send anonymously. -payment_method can be "ton" or "usdt_ton". Username can be "@username", "username", or "https://t.me/username". """ import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.models.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = "ton" # "ton" or "usdt_ton" +PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON async def main() -> None: diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index a155fc5..e8181c3 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -1,15 +1,15 @@ """ Example: purchase Telegram Stars for a user. -Amount must be an integer between 50 and 1 000 000. +Amount must be an integer between 50 and 10 000 000. Set show_sender=False to send anonymously. -payment_method can be "ton" or "usdt_ton". Username can be "@username", "username", or "https://t.me/username". """ import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.models.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -26,8 +26,8 @@ COOKIES = { } USERNAME = "https://t.me/username" -AMOUNT = 500 # 50–1 000 000 stars -PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton" +AMOUNT = 500 # 50–10 000 000 stars +PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON async def main() -> None: diff --git a/pyfragment/client.py b/pyfragment/client.py index 36b7f84..d47e5d0 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -166,7 +166,7 @@ class FragmentClient: Args: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. - amount: Number of stars — integer from ``50`` to ``1 000 000``. + amount: Number of stars — integer from ``50`` to ``10 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. @@ -222,7 +222,7 @@ class FragmentClient: Args: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. - winners: Number of winners — integer from ``1`` to ``5``. + winners: Number of winners — integer from ``1`` to ``15``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. @@ -243,7 +243,7 @@ class FragmentClient: Args: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. - winners: Number of winners — positive integer. + 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"``. diff --git a/pyfragment/core/constants/ton.py b/pyfragment/core/constants/ton.py index e76d645..9f6cbd2 100644 --- a/pyfragment/core/constants/ton.py +++ b/pyfragment/core/constants/ton.py @@ -7,7 +7,7 @@ USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs DEVICE_INFO: dict[str, Any] = { "platform": "iphone", "appName": "Tonkeeper", - "appVersion": "26.04.0", + "appVersion": "26.05.0", "maxProtocolVersion": 2, "features": [ "SendTransaction", diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 75f4892..ac872e9 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -11,6 +11,8 @@ from pyfragment.core.constants.limits import ( STARS_PURCHASE_MIN, STARS_WINNERS_MAX, STARS_WINNERS_MIN, + TON_TOPUP_MAX, + TON_TOPUP_MIN, TONAPI_KEY_MIN_LENGTH, ) @@ -37,7 +39,7 @@ class ConfigurationError(ClientError): INVALID_STARS_AMOUNT = ( f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}." ) - INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000." + INVALID_TON_AMOUNT = f"Invalid TON amount: must be an integer between {TON_TOPUP_MIN:,} and {TON_TOPUP_MAX:,}." INVALID_USERNAME = ( "Invalid username '{username}'. " "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)." From 695430744a3708e3c7eb9df7defafbf0c4a5bd19 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 23:14:59 +0300 Subject: [PATCH 06/15] feat: update payment method options and validation across purchase and giveaway examples; add AlreadySubscribedError exception handling --- examples/purchase/run_premium_giveaway.py | 2 +- examples/purchase/run_stars_giveaway.py | 2 +- examples/purchase/send_premium.py | 2 +- examples/purchase/send_stars.py | 2 +- pyfragment/__init__.py | 2 ++ pyfragment/client.py | 16 +++++++++--- pyfragment/domains/giveaways/giveaway.py | 19 ++++++++++++-- pyfragment/domains/purchases/purchase.py | 15 ++++++++--- pyfragment/exceptions.py | 7 +++++ pyfragment/models/enums.py | 5 ++++ tests/004_test_stars.py | 24 +++++++++++++----- tests/005_test_premium.py | 31 ++++++++++++++++++++--- tests/006_test_topup.py | 5 ++-- tests/010_test_recharge_ads.py | 5 ++-- 14 files changed, 109 insertions(+), 28 deletions(-) diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 1aefd83..115fa1d 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 10 # 1–24 000 MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 47b124c..ea4d886 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 3 # 1–15 AMOUNT = 1000 # 500–1 000 000 stars per winner -PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index 9cd1f98..f215f45 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index e8181c3..f490e65 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" AMOUNT = 500 # 50–10 000 000 stars -PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index fc9d194..afdedfd 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -4,6 +4,7 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.core.cookies import get_cookies_from_browser from pyfragment.exceptions import ( + AlreadySubscribedError, AnonymousNumberError, ClientError, ConfigurationError, @@ -53,6 +54,7 @@ __all__ = [ "FragmentAPIError", "FragmentPageError", "ConfigurationError", + "AlreadySubscribedError", "UserNotFoundError", "WalletError", "VerificationError", diff --git a/pyfragment/client.py b/pyfragment/client.py index d47e5d0..2a90711 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -148,7 +148,9 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. months: Duration — ``3``, ``6``, or ``12``. show_sender: Show your name as the sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. @@ -168,7 +170,9 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of stars — integer from ``50`` to ``10 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. @@ -224,7 +228,9 @@ class FragmentClient: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``15``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, @@ -245,7 +251,9 @@ class FragmentClient: 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"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index d3ca352..b92adfb 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -38,6 +38,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _state_nonce() -> str: + # Fragment expects a pseudo-random nonce-like dh value in giveaway state updates. + return str(random.randint(100_000_000, 2_147_483_647)) + + async def giveaway_stars( client: FragmentClient, channel: str, @@ -65,7 +70,12 @@ async def giveaway_stars( await client.call( "updateStarsGiveawayState", - {"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, + page_url=STARS_GIVEAWAY_PAGE, + ) + await client.call( + "updateStarsGiveawayPrices", + {"quantity": winners, "stars": amount}, page_url=STARS_GIVEAWAY_PAGE, ) @@ -162,11 +172,16 @@ async def giveaway_premium( { "mode": "new", "lv": "false", - "dh": str(random.randint(100_000_000, 999_999_999)), + "dh": _state_nonce(), "quantity": "", }, page_url=PREMIUM_GIVEAWAY_PAGE, ) + await client.call( + "updatePremiumGiveawayPrices", + {"quantity": winners}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) result = await client.call( "initGiveawayPremiumRequest", diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index cf7b11d..20a8b4d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import logging -import time +import random from typing import TYPE_CHECKING from pyfragment.core.constants import ( @@ -17,6 +17,7 @@ from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ( + AlreadySubscribedError, ConfigurationError, FragmentAPIError, FragmentError, @@ -34,6 +35,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _state_nonce() -> str: + # Fragment accepts a pseudo-random request nonce in state update methods. + return str(random.randint(100_000_000, 2_147_483_647)) + + async def purchase_stars( client: FragmentClient, username: str, @@ -59,7 +65,7 @@ async def purchase_stars( await client.call( "updateStarsBuyState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, page_url=STARS_PAGE, ) result = await client.call( @@ -140,7 +146,7 @@ async def purchase_premium( await client.call( "updatePremiumState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, page_url=PREMIUM_PAGE, ) result = await client.call( @@ -148,6 +154,9 @@ async def purchase_premium( {"recipient": recipient, "months": months, "payment_method": payment_method}, page_url=PREMIUM_PAGE, ) + error_text = str(result.get("error", "")).strip().lower() + if "already subscribed to telegram premium" in error_text: + raise AlreadySubscribedError(AlreadySubscribedError.PREMIUM_ACTIVE) required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index ac872e9..2b98d64 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -96,6 +96,12 @@ class UserNotFoundError(FragmentAPIError): ) +class AlreadySubscribedError(FragmentAPIError): + """Raised when trying to gift Premium to a user who already has an active subscription.""" + + PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium." + + class AnonymousNumberError(FragmentAPIError): """Raised for Fragment anonymous number API failures.""" @@ -167,6 +173,7 @@ __all__ = [ "FragmentAPIError", "FragmentPageError", "AnonymousNumberError", + "AlreadySubscribedError", "UserNotFoundError", "TransactionError", "ParseError", diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 6f2ec8e..b9794b2 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -17,6 +17,11 @@ else: class PaymentMethod(StrEnum): TON = "ton" USDT_TON = "usdt_ton" + USDT_ETH = "usdt_eth" + USDT_POL = "usdt_pol" + USDC_ETH = "usdc_eth" + USDC_BASE = "usdc_base" + USDC_POL = "usdc_pol" class WalletVersion(StrEnum): diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 49ec4a8..1dc6b08 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -7,6 +7,14 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod import pyfragment.domains.purchases.purchase as _purchase_stars_mod from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError +from pyfragment.core.constants.limits import ( + STARS_GIVEAWAY_MAX, + STARS_GIVEAWAY_MIN, + STARS_PURCHASE_MAX, + STARS_PURCHASE_MIN, + STARS_WINNERS_MAX, + STARS_WINNERS_MIN, +) from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -16,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC @pytest.mark.asyncio async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=49) + await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1) @pytest.mark.asyncio async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=1_000_001) + await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1) @pytest.mark.asyncio @@ -114,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=0, amount=500) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN) @pytest.mark.asyncio async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=6, amount=500) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN) @pytest.mark.asyncio async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=1, amount=499) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1) @pytest.mark.asyncio async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=1, amount=1_000_001) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1) @pytest.mark.asyncio @@ -166,6 +174,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -189,6 +198,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -201,7 +211,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N ): await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_TON) - init_call = call_mock.await_args_list[2] + init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayStarsRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 6667602..8ecae0c 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -7,6 +7,8 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError +from pyfragment.core.constants.limits import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN +from pyfragment.exceptions import AlreadySubscribedError from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -22,7 +24,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_premium_months_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_premium("@user", months=0) + await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1) @pytest.mark.asyncio @@ -85,6 +87,25 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" +@pytest.mark.asyncio +async def test_purchase_premium_already_subscribed_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"error": "This account is already subscribed to Telegram Premium."}, + ] + ), + ), + ): + with pytest.raises(AlreadySubscribedError): + await client.purchase_premium("@user", months=6) + + @pytest.mark.asyncio @pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"]) async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None: @@ -111,13 +132,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_premium("@channel", winners=0, months=3) + await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3) @pytest.mark.asyncio async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_premium("@channel", winners=24_001, months=3) + await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3) @pytest.mark.asyncio @@ -151,6 +172,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -174,6 +196,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -186,7 +209,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> ): await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_TON) - init_call = call_mock.await_args_list[2] + init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayPremiumRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index f860ecc..e1807da 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -6,6 +6,7 @@ import pytest import pyfragment.domains.ads.tonup as _topup_ton_mod from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup TON validation tests @@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC @pytest.mark.asyncio async def test_topup_ton_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=0) + await client.topup_ton("@user", amount=TON_TOPUP_MIN - 1) @pytest.mark.asyncio async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=1_000_000_001) + await client.topup_ton("@user", amount=TON_TOPUP_MAX + 1) @pytest.mark.asyncio diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 5feff7a..51f56a2 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -6,6 +6,7 @@ import pytest import pyfragment.domains.ads.recharge as _recharge_ads_mod from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient +from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests @@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS @pytest.mark.asyncio async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MIN - 1) @pytest.mark.asyncio async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MAX + 1) @pytest.mark.asyncio From d01c45d85b097e13a078ec6e77c77bedb96d18b6 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 29 May 2026 23:16:19 +0300 Subject: [PATCH 07/15] feat: update version to 2026.3.2; enhance changelog with new payment methods and updated client documentation --- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edf17e..9421ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.3.2] — 2026-05-29 + +### Added + +- New payment methods for purchases and giveaways: `usdt_eth`, `usdt_pol`, `usdc_eth`, `usdc_base`, `usdc_pol`. +- New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.` + +### Changed + +- Updated purchase and giveaway flow state nonces (`dh`) to use nonce-like dynamic values with a wider integer range. +- Stars and Premium giveaway flows now include explicit price update steps before init requests: + - `updateStarsGiveawayPrices` + - `updatePremiumGiveawayPrices` +- Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`. +- Updated client docstrings and purchase examples to document all supported payment methods. + +--- + ## [2026.3.1] — 2026-05-29 ### Added diff --git a/pyproject.toml b/pyproject.toml index dcda488..40d0f7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.3.1" +version = "2026.3.2" description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." readme = "README.md" license = { text = "MIT" } From b85b34f2b1c68d3334d6979bd2ffed65cb1ea44a Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 30 May 2026 00:17:06 +0300 Subject: [PATCH 08/15] feat: update changelog for new payment methods; add UserNotFoundError handling in purchase flows --- CHANGELOG.md | 4 ++-- README.md | 15 +++++++++++---- pyfragment/domains/purchases/purchase.py | 4 ++++ pyfragment/exceptions.py | 1 + pyfragment/models/enums.py | 2 ++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9421ab6..5987e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,12 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- -## [2026.3.2] — 2026-05-29 +## [2026.3.2] — Unreleased ### Added -- New payment methods for purchases and giveaways: `usdt_eth`, `usdt_pol`, `usdc_eth`, `usdc_base`, `usdc_pol`. - New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.` +- New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot). ### Changed diff --git a/README.md b/README.md index 95ee4b5..5d8fdb9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ pip install git+https://github.com/bohd4nx/pyfragment.git@dev ```python import asyncio from pyfragment import FragmentClient - +from pyfragment.models.enums import PaymentMethod async def main() -> None: async with FragmentClient( @@ -57,10 +57,17 @@ async def main() -> None: recipient = "https://t.me/username" # also: @username, username - stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton") - print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") + stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_TON) + if stars.auto_paid: + print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") + else: + print(f"Manual payment required for {stars.username}") + print(f"Address: {stars.payment_address}") + print(f"Amount: {stars.payment_amount}") + print(f"Expires: {stars.payment_expires_at}") + print(f"QR link: {stars.payment_qr_link}") - premium = await client.purchase_premium(recipient, months=6, payment_method="ton") + premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.TON) print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 20a8b4d..8449dc6 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -59,6 +59,8 @@ async def purchase_stars( try: result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) + if "assigned to a user" in str(result.get("error", "")).lower(): + raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username)) recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -140,6 +142,8 @@ async def purchase_premium( try: result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) + if "assigned to a user" in str(result.get("error", "")).lower(): + raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username)) recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 2b98d64..898396c 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -94,6 +94,7 @@ class UserNotFoundError(FragmentAPIError): NOT_FOUND = ( "Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists." ) + NOT_A_USER = "'{username}' does not belong to a user account. Make sure the username is assigned to a personal Telegram account, not a channel or bot." class AlreadySubscribedError(FragmentAPIError): diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index b9794b2..02b21ab 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -17,6 +17,8 @@ else: class PaymentMethod(StrEnum): TON = "ton" USDT_TON = "usdt_ton" + + # Not supported yet USDT_ETH = "usdt_eth" USDT_POL = "usdt_pol" USDC_ETH = "usdc_eth" From 34817697574c61a24d7aee13e397da79bbe7653e Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 5 Jun 2026 02:04:44 +0300 Subject: [PATCH 09/15] feat: add Highload wallet versions and update mypy exclusion patterns --- CHANGELOG.md | 1 + pyfragment/models/enums.py | 6 +++++- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5987e73..07e5604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.` - New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot). +- Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion` ### Changed diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 02b21ab..5b0a4a7 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -4,7 +4,7 @@ import sys from enum import Enum from typing import Any -from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 +from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1 if sys.version_info >= (3, 11): from enum import StrEnum @@ -29,11 +29,15 @@ class PaymentMethod(StrEnum): class WalletVersion(StrEnum): V4R2 = "V4R2" V5R1 = "V5R1" + HighloadV2 = "HighloadV2" + HighloadV3R1 = "HighloadV3R1" WALLET_CLASSES: dict[WalletVersion, Any] = { WalletVersion.V4R2: WalletV4R2, WalletVersion.V5R1: WalletV5R1, + WalletVersion.HighloadV2: WalletHighloadV2, + WalletVersion.HighloadV3R1: WalletHighloadV3R1, } diff --git a/pyproject.toml b/pyproject.toml index 40d0f7d..82974a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,7 @@ ignore = ["E501", "UP017"] [tool.mypy] python_version = "3.10" strict = true -exclude = ["^examples/"] +exclude = ["^examples/", "^logs/"] [[tool.mypy.overrides]] module = "rookiepy" From 8f865f4911baca5109b47c71c3c8273796b2425f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 00:47:49 +0300 Subject: [PATCH 10/15] Refactor for GRAM (ex TON) integration - Updated imports and references from TON to GRAM across multiple modules. - Renamed functions and variables to reflect the change from TON to GRAM. - Adjusted validation checks for top-up and recharge amounts to use GRAM limits. - Modified error messages and exceptions to indicate GRAM instead of TON. - Updated tests to ensure they reflect the new GRAM terminology and functionality. - Introduced new constants for GRAM-related configurations. --- .gitignore | 4 ++ CHANGELOG.md | 30 ++++++++++++ CONTRIBUTING.md | 2 +- README.md | 18 ++++---- SECURITY.md | 2 +- examples/client/wallet_info.py | 4 +- examples/purchase/recharge_ads_balance.py | 10 ++-- examples/purchase/run_premium_giveaway.py | 2 +- examples/purchase/run_stars_giveaway.py | 2 +- examples/purchase/send_premium.py | 2 +- examples/purchase/send_stars.py | 2 +- examples/purchase/topup_ton_balance.py | 14 +++--- pyfragment/client.py | 30 ++++++------ pyfragment/core/constants/__init__.py | 16 +++---- pyfragment/core/constants/{ton.py => gram.py} | 2 +- pyfragment/core/constants/limits.py | 8 ++-- pyfragment/domains/ads/__init__.py | 4 +- pyfragment/domains/ads/recharge.py | 10 ++-- pyfragment/domains/ads/service.py | 6 +-- pyfragment/domains/ads/tonup.py | 18 +++++--- pyfragment/domains/giveaways/giveaway.py | 4 +- pyfragment/domains/giveaways/service.py | 4 +- pyfragment/domains/purchases/purchase.py | 4 +- pyfragment/domains/purchases/service.py | 4 +- pyfragment/domains/tonapi/account.py | 46 +++++++++---------- pyfragment/domains/tonapi/transaction.py | 28 +++++------ pyfragment/exceptions.py | 24 +++++----- pyfragment/models/enums.py | 4 +- pyfragment/models/payments.py | 4 +- pyfragment/models/wallet.py | 4 +- pyproject.toml | 12 +++-- tests/003_test_balance.py | 20 ++++---- tests/004_test_stars.py | 4 +- tests/005_test_premium.py | 4 +- tests/006_test_topup.py | 34 +++++++------- tests/007_test_wallet.py | 12 ++--- tests/010_test_recharge_ads.py | 6 +-- tests/shared.py | 4 +- 38 files changed, 227 insertions(+), 181 deletions(-) rename pyfragment/core/constants/{ton.py => gram.py} (82%) diff --git a/.gitignore b/.gitignore index 7e968df..9abeaf6 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,7 @@ systests/ dist/ build/ *.egg-info/ +uv.lock + +#docs +*-docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e5604..71b9d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,34 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`. - Updated client docstrings and purchase examples to document all supported payment methods. +### Renamed — TON -> GRAM (ex TON) + +The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, messages, and documentation have been updated accordingly. + +**Public API** + +- `FragmentClient.topup_ton()` → `topup_gram()` +- `PaymentMethod.TON` → `PaymentMethod.GRAM` +- `PaymentMethod.USDT_TON` → `PaymentMethod.USDT_GRAM` +- `WalletInfo.ton_balance` → `WalletInfo.gram_balance` + +**Constants** + +- `TON_TOPUP_MIN` / `TON_TOPUP_MAX` → `GRAM_TOPUP_MIN` / `GRAM_TOPUP_MAX` +- `MIN_TON_BALANCE` → `MIN_GRAM_BALANCE` +- `USDT_TON_MASTER_ADDRESS` → `USDT_GRAM_MASTER_ADDRESS` + +**Exceptions** + +- `ConfigurationError.INVALID_TON_AMOUNT` → `INVALID_GRAM_AMOUNT` +- `WalletError.LOW_TON_BALANCE` → `LOW_GRAM_BALANCE` +- `WalletError.TON_BALANCE_CHECK_FAILED` → `GRAM_BALANCE_CHECK_FAILED` + +**Internals** + +- `pyfragment/core/constants/ton.py` → `gram.py` +- `check_ton_payment_balance()` → `check_gram_payment_balance()` + --- ## [2026.3.1] — 2026-05-29 @@ -239,6 +267,8 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2 +[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1 [2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0 [2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3 [2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 581a776..991a561 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ pyfragment/ client.py — FragmentClient (public entry point) core/ — transport, cookies, constants domains/ — one package per feature domain - ads/ — recharge_ads, topup_ton + ads/ — recharge_ads, topup_gram anonymous_numbers/— buy_number, manage_number giveaways/ — giveaway_stars, giveaway_premium marketplace/ — search_usernames, search_numbers, search_gifts diff --git a/README.md b/README.md index 5d8fdb9..e2be12e 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ [![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE) Async Python client for the **[Fragment](https://fragment.com)** marketplace API. -Buy Stars & Premium, run giveaways, top up TON and Ads balances, +Buy Stars & Premium, run giveaways, top up GRAM (ex TON) and Ads balances, manage anonymous numbers, and search Fragment listings. -**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)** +**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate GRAM](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)** @@ -53,11 +53,11 @@ async def main() -> None: }, ) as client: wallet = await client.get_wallet() - print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") + print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}") recipient = "https://t.me/username" # also: @username, username - stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_TON) + stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM) if stars.auto_paid: print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") else: @@ -67,7 +67,7 @@ async def main() -> None: print(f"Expires: {stars.payment_expires_at}") print(f"QR link: {stars.payment_qr_link}") - premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.TON) + premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM) print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") @@ -80,7 +80,7 @@ asyncio.run(main()) | Parameter | Type | Default | Description | | ---------------- | ------------- | -------- | ----------------------------------------------------------- | -| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic | +| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic | | `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | | `cookies` | `dict \| str` | — | Fragment session cookies | | `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | @@ -92,7 +92,7 @@ asyncio.run(main()) ### Fragment cookies -Log in to [fragment.com](https://fragment.com) and connect your TON wallet. +Log in to [fragment.com](https://fragment.com) and connect your GRAM (ex TON) wallet. **Automatically** (recommended) — reads directly from your browser, no extension needed: @@ -118,7 +118,7 @@ Generate at [tonconsole.com](https://tonconsole.com). ### Seed phrase -12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it. +12- or 24-word mnemonic from your GRAM (ex TON) wallet (**Tonkeeper → Settings → Backup**). Never share it. --- @@ -143,7 +143,7 @@ All exceptions inherit from `FragmentError`: from pyfragment import ( ConfigurationError, # invalid arguments (amount, months, payment_method…) UserNotFoundError, # recipient not found on Fragment - WalletError, # insufficient TON or USDT balance + WalletError, # insufficient GRAM (ex TON) or USDT balance TransactionError, # broadcast failed, duplicate seqno, invalid payload FragmentAPIError, # Fragment API returned an error response FragmentPageError, # page fetch or hash extraction failed diff --git a/SECURITY.md b/SECURITY.md index 475c2e7..1800853 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,4 +16,4 @@ You will receive a response within 72 hours. Once the fix is released, the advis ## Scope -This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity. +This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity. diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 7631288..1b0de85 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -1,5 +1,5 @@ """ -Example: fetch wallet address, state, and separate TON/USDT balances. +Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances. Cookies can be passed as a dict or as a JSON string. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. @@ -34,7 +34,7 @@ async def main() -> None: wallet = await client.get_wallet() print(f"Address: {wallet.address}") print(f"State: {wallet.state}") - print(f"Balance: {wallet.ton_balance} TON") + print(f"Balance: {wallet.gram_balance} GRAM (ex TON)") print(f"Balance: {wallet.usdt_balance} USDT") diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index 7dc0162..a3fc005 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -1,8 +1,8 @@ """ -Example: recharge your own Telegram Ads account with TON. +Example: recharge your own Telegram Ads account with GRAM (ex TON). -Amount must be an integer between 1 and 1 000 000 000 TON. -Your wallet must satisfy the current minimum TON threshold and transaction cost. +Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). +Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. """ import asyncio @@ -29,7 +29,7 @@ COOKIES = { } ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account -AMOUNT = 10 # 1–1 000 000 000 TON +AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON) async def main() -> None: @@ -43,7 +43,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") + print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 115fa1d..184b518 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 10 # 1–24 000 MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL +PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index ea4d886..f685069 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 3 # 1–15 AMOUNT = 1000 # 500–1 000 000 stars per winner -PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL +PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index f215f45..c02d02b 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL +PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index f490e65..2dacdc6 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" AMOUNT = 500 # 50–10 000 000 stars -PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL +PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index e44731c..ee26860 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -1,10 +1,10 @@ """ -Example: top up TON to a recipient's Telegram balance. +Example: top up GRAM (ex TON) to a recipient's Telegram balance. -For adding TON to a Telegram Ads account, use recharge_ads() instead. +For adding GRAM (ex TON) to a Telegram Ads account, use recharge_ads() instead. -Amount must be an integer between 1 and 1 000 000 000 TON. -Your wallet must satisfy the current minimum TON threshold and transaction cost. +Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). +Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. """ import asyncio @@ -31,13 +31,13 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 10 # 1–1 000 000 000 TON +AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON) async def main() -> None: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: try: - result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) + result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") return @@ -48,7 +48,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyfragment/client.py b/pyfragment/client.py index 2a90711..3b81841 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -36,7 +36,7 @@ class FragmentClient: connected with Fragment or Telegram. Args: - seed: 12- or 24-word mnemonic phrase for the TON wallet. + seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet. api_key: Tonapi API key — get one at https://tonconsole.com. cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). @@ -140,7 +140,7 @@ class FragmentClient: username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumResult: """Gift Telegram Premium to a user. @@ -148,7 +148,7 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. months: Duration — ``3``, ``6``, or ``12``. show_sender: Show your name as the sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, or ``"usdc_pol"``. @@ -162,7 +162,7 @@ class FragmentClient: username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsResult: """Send Telegram Stars to a user. @@ -170,7 +170,7 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of stars — integer from ``50`` to ``10 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, or ``"usdc_pol"``. @@ -179,18 +179,18 @@ class FragmentClient: """ return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method) - async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up TON to a recipient's Telegram balance. + async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + """Top up GRAM (ex TON) to a recipient's Telegram balance. Args: username: Recipient's Telegram username (with or without ``@``). - amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + amount: Amount in GRAM (ex 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``. """ - return await self.ads.topup_ton(username, amount, show_sender=show_sender) + return await self.ads.topup_gram(username, amount, show_sender=show_sender) async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: """Add funds to your own Telegram Ads account. @@ -198,7 +198,7 @@ class FragmentClient: Args: 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``. + amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. Returns: :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. @@ -211,7 +211,7 @@ class FragmentClient: Returns: :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), - ``ton_balance`` in TON, and ``usdt_balance`` in USDT. + ``gram_balance`` in GRAM (ex TON), and ``usdt_balance`` in USDT. """ return await self.tonapi.get_wallet() @@ -220,7 +220,7 @@ class FragmentClient: channel: str, winners: int, amount: int, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsGiveawayResult: """Run a Telegram Stars giveaway for a channel. @@ -228,7 +228,7 @@ class FragmentClient: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``15``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. - payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, or ``"usdc_pol"``. @@ -243,7 +243,7 @@ class FragmentClient: channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumGiveawayResult: """Run a Telegram Premium giveaway for a channel. @@ -251,7 +251,7 @@ class FragmentClient: 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), ``"usdt_ton"``, + payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, or ``"usdc_pol"``. diff --git a/pyfragment/core/constants/__init__.py b/pyfragment/core/constants/__init__.py index 4e5be9a..7dd2c93 100644 --- a/pyfragment/core/constants/__init__.py +++ b/pyfragment/core/constants/__init__.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pyfragment.core.constants.gram import DEVICE_INFO, USDT_GRAM_MASTER_ADDRESS from pyfragment.core.constants.headers import BASE_HEADERS, DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS from pyfragment.core.constants.limits import ( - MIN_TON_BALANCE, + GRAM_TOPUP_MAX, + GRAM_TOPUP_MIN, + MIN_GRAM_BALANCE, MIN_USDT_BALANCE, MNEMONIC_WORD_COUNTS_VALID, PREMIUM_MONTHS_VALID, @@ -14,11 +17,8 @@ from pyfragment.core.constants.limits import ( STARS_PURCHASE_MIN, STARS_WINNERS_MAX, STARS_WINNERS_MIN, - TON_TOPUP_MAX, - TON_TOPUP_MIN, TONAPI_KEY_MIN_LENGTH, ) -from pyfragment.core.constants.ton import DEVICE_INFO, USDT_TON_MASTER_ADDRESS from pyfragment.core.constants.urls import ( ADS_TOPUP_PAGE, FRAGMENT_BASE_URL, @@ -39,7 +39,9 @@ __all__ = [ "FRAGMENT_BASE_URL", "FRAGMENT_DOMAIN", "GIFTS_PAGE", - "MIN_TON_BALANCE", + "GRAM_TOPUP_MAX", + "GRAM_TOPUP_MIN", + "MIN_GRAM_BALANCE", "MIN_USDT_BALANCE", "MNEMONIC_WORD_COUNTS_VALID", "NUMBERS_PAGE", @@ -58,7 +60,5 @@ __all__ = [ "STARS_WINNERS_MAX", "STARS_WINNERS_MIN", "TONAPI_KEY_MIN_LENGTH", - "TON_TOPUP_MAX", - "TON_TOPUP_MIN", - "USDT_TON_MASTER_ADDRESS", + "USDT_GRAM_MASTER_ADDRESS", ] diff --git a/pyfragment/core/constants/ton.py b/pyfragment/core/constants/gram.py similarity index 82% rename from pyfragment/core/constants/ton.py rename to pyfragment/core/constants/gram.py index 9f6cbd2..f1cc6cc 100644 --- a/pyfragment/core/constants/ton.py +++ b/pyfragment/core/constants/gram.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" +USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" DEVICE_INFO: dict[str, Any] = { "platform": "iphone", diff --git a/pyfragment/core/constants/limits.py b/pyfragment/core/constants/limits.py index b054c80..bb35890 100644 --- a/pyfragment/core/constants/limits.py +++ b/pyfragment/core/constants/limits.py @@ -16,12 +16,12 @@ STARS_WINNERS_MAX: int = 15 PREMIUM_WINNERS_MIN: int = 1 PREMIUM_WINNERS_MAX: int = 24_000 -# TON topup / Ads recharge amount -TON_TOPUP_MIN: int = 1 -TON_TOPUP_MAX: int = 1_000_000_000 +# GRAM (ex TON) topup / Ads recharge amount +GRAM_TOPUP_MIN: int = 1 +GRAM_TOPUP_MAX: int = 1_000_000_000 # Wallet minimum balances -MIN_TON_BALANCE: float = 0.33 +MIN_GRAM_BALANCE: float = 0.33 MIN_USDT_BALANCE: float = 0.75 # Premium subscription durations (months) diff --git a/pyfragment/domains/ads/__init__.py b/pyfragment/domains/ads/__init__.py index 50fd95a..682fbb5 100644 --- a/pyfragment/domains/ads/__init__.py +++ b/pyfragment/domains/ads/__init__.py @@ -1,5 +1,5 @@ from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.service import AdsService -from pyfragment.domains.ads.tonup import topup_ton +from pyfragment.domains.ads.tonup import topup_gram -__all__ = ["AdsService", "recharge_ads", "topup_ton"] +__all__ = ["AdsService", "recharge_ads", "topup_gram"] diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index c79806a..ac7bf7c 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError @@ -18,8 +18,8 @@ logger = logging.getLogger(__name__) async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: - if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX): - raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) + if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX): + raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT) try: await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) @@ -47,8 +47,8 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads return AdsRechargeResult(transaction_id=tx_hash, amount=amount) except FragmentError as exc: - logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True) + logger.error("Failed to recharge Ads account '%s' for %s GRAM (ex TON): %s", account, amount, exc, exc_info=True) raise except Exception as exc: - logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount) + logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py index ce310cc..35f3640 100644 --- a/pyfragment/domains/ads/service.py +++ b/pyfragment/domains/ads/service.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from pyfragment.domains.ads.recharge import recharge_ads -from pyfragment.domains.ads.tonup import topup_ton +from pyfragment.domains.ads.tonup import topup_gram from pyfragment.domains.base import BaseService from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult @@ -15,5 +15,5 @@ class AdsService(BaseService): async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: return await recharge_ads(self._client, account, amount) - async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - return await topup_ton(self._client, username, amount, show_sender=show_sender) + async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + return await topup_gram(self._client, username, amount, show_sender=show_sender) diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index 40d999b..f04bb56 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -4,7 +4,7 @@ import json import logging from typing import TYPE_CHECKING -from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, TON_TOPUP_MAX, TON_TOPUP_MIN +from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -25,9 +25,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - if not isinstance(amount, int) or not (TON_TOPUP_MIN <= amount <= TON_TOPUP_MAX): - raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) +async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX): + raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT) try: await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) @@ -41,7 +41,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="GRAM (ex TON) topup")) account = await get_account_info(client) transaction = await client.call( @@ -62,8 +62,12 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) except FragmentError as exc: - logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True) + logger.error( + "Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON): %s", username, amount, exc, exc_info=True + ) raise except Exception as exc: - logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount) + logger.exception( + "Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount + ) raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index b92adfb..3ca2245 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -48,7 +48,7 @@ async def giveaway_stars( channel: str, winners: int, amount: int, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsGiveawayResult: if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) @@ -143,7 +143,7 @@ async def giveaway_premium( channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumGiveawayResult: if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX): raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py index db70b05..fd85eb6 100644 --- a/pyfragment/domains/giveaways/service.py +++ b/pyfragment/domains/giveaways/service.py @@ -17,7 +17,7 @@ class GiveawaysService(BaseService): channel: str, winners: int, amount: int, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsGiveawayResult: return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method) @@ -26,6 +26,6 @@ class GiveawaysService(BaseService): channel: str, winners: int, months: int = 3, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumGiveawayResult: return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method) diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 8449dc6..515de8d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -45,7 +45,7 @@ async def purchase_stars( username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsResult: if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) @@ -128,7 +128,7 @@ async def purchase_premium( username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumResult: if months not in PREMIUM_MONTHS_VALID: raise ConfigurationError(ConfigurationError.INVALID_MONTHS) diff --git a/pyfragment/domains/purchases/service.py b/pyfragment/domains/purchases/service.py index 638d3db..cc3c18d 100644 --- a/pyfragment/domains/purchases/service.py +++ b/pyfragment/domains/purchases/service.py @@ -17,7 +17,7 @@ class PurchasesService(BaseService): username: str, amount: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> StarsResult: return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method) @@ -26,6 +26,6 @@ class PurchasesService(BaseService): username: str, months: int, show_sender: bool = True, - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, ) -> PremiumResult: return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method) diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index 52aab22..c386c74 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -9,7 +9,7 @@ from tonutils.clients import TonapiClient from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method from tonutils.exceptions import ProviderResponseError -from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS +from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS from pyfragment.exceptions import WalletError from pyfragment.models.enums import WALLET_CLASSES from pyfragment.models.wallet import WalletInfo @@ -22,11 +22,11 @@ logger = logging.getLogger(__name__) async def get_usdt_balance(ton: Any, wallet_address: str) -> float: - """Return the USDT balance for a Fragment-linked TON wallet.""" + """Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet.""" try: jetton_wallet_address = await get_wallet_address_get_method( client=ton, - address=USDT_TON_MASTER_ADDRESS, + address=USDT_GRAM_MASTER_ADDRESS, owner_address=wallet_address, ) wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address) @@ -43,40 +43,40 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float: raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc -async def check_ton_payment_balance( - balance_ton: float, - amount_ton: float, +async def check_gram_payment_balance( + balance_gram: float, + amount_gram: float, required_payment_amount: float | None, ) -> None: - """Validate that the TON wallet can cover a TON-denominated payment.""" - tx_price_ton = amount_ton + """Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment.""" + tx_price_gram = amount_gram if required_payment_amount is not None and required_payment_amount > 0: - tx_price_ton = max(tx_price_ton, required_payment_amount) + tx_price_gram = max(tx_price_gram, required_payment_amount) - required_ton = max(tx_price_ton, MIN_TON_BALANCE) - if balance_ton < required_ton: + required_gram = max(tx_price_gram, MIN_GRAM_BALANCE) + if balance_gram < required_gram: logger.error( - "Failed TON balance check: balance=%s TON, required=%s TON", - round(balance_ton, 6), - round(required_ton, 6), + "Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)", + round(balance_gram, 6), + round(required_gram, 6), ) - raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton)) + raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=required_gram)) async def check_usdt_payment_balance( - balance_ton: float, + balance_gram: float, required_payment_amount: float | None, ton: Any, wallet_address: str, ) -> None: """Validate that the wallet can cover a USDT-denominated payment.""" - if balance_ton < MIN_TON_BALANCE: + if balance_gram < MIN_GRAM_BALANCE: logger.error( - "Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON", - round(balance_ton, 6), - MIN_TON_BALANCE, + "Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)", + round(balance_gram, 6), + MIN_GRAM_BALANCE, ) - raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE)) + raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=MIN_GRAM_BALANCE)) usdt_balance = await get_usdt_balance(ton, wallet_address) required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE @@ -109,7 +109,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]: async def get_wallet_info(client: FragmentClient) -> WalletInfo: - """Fetch the wallet address, chain state, and TON/USDT balances.""" + """Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances.""" async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: try: wallet_cls = WALLET_CLASSES[client.wallet_version] @@ -120,7 +120,7 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo: return WalletInfo( address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False), state=wallet.state.value, - ton_balance=round(wallet.balance / 1_000_000_000, 4), + gram_balance=round(wallet.balance / 1_000_000_000, 4), usdt_balance=round(usdt_balance, 4), ) except Exception as exc: diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 731c1c5..49910a8 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -11,7 +11,7 @@ from ton_core import Cell, NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance +from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance from pyfragment.exceptions import ParseError, TransactionError, WalletError from pyfragment.models.enums import WALLET_CLASSES, PaymentMethod @@ -26,7 +26,7 @@ def clean_decode(payload: str) -> str | Cell: """Decode a base64 BOC comment from Fragment into text when possible. Some Fragment payloads are plain text comments, while others are structured - TON messages such as jetton transfers. Non-text payloads are returned as a + GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a `Cell` so the caller can keep the raw binary structure. """ s = payload.strip() @@ -39,7 +39,7 @@ def clean_decode(payload: str) -> str | Cell: sl = cell.begin_parse() op = sl.load_uint(32) if op != 0: - # Non-zero op code means this is a structured TON message, not a plain text comment. + # Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment. return cell try: return sl.load_snake_string().strip() @@ -62,7 +62,7 @@ def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]: async def _check_payment_balances( wallet: Any, payment_method: PaymentMethod, - amount_ton: float, + amount_gram: float, required_payment_amount: float | None, transaction_data: dict[str, Any], ton: Any, @@ -70,18 +70,18 @@ async def _check_payment_balances( """Refresh wallet and verify sufficient balance before broadcasting.""" try: await wallet.refresh() - balance_ton = wallet.balance / 1_000_000_000 + balance_gram = wallet.balance / 1_000_000_000 if payment_method == "ton": - await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount) + await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount) else: # USDT is paid from the Fragment-linked wallet, not the signing wallet. fragment_wallet_address = transaction_data["transaction"].get("from", "") - await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) + await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address) except WalletError: raise except Exception as exc: logger.exception("Failed to validate balances before broadcasting transaction") - raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc + raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str: @@ -90,7 +90,7 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s try: result = await wallet.transfer( destination=message["address"], - amount=int(message["amount"]), # nanotons, not TON + amount=int(message["amount"]), # nanograms, not GRAM (ex TON) body=payload, ) return str(result.normalized_hash) @@ -124,10 +124,10 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s async def process_transaction( client: FragmentClient, transaction_data: dict[str, Any], - payment_method: PaymentMethod = PaymentMethod.TON, + payment_method: PaymentMethod = PaymentMethod.GRAM, required_payment_amount: float | None = None, ) -> str: - """Sign and broadcast a Fragment transaction with the seeded TON wallet. + """Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet. Args: client: Authenticated `FragmentClient` instance. @@ -139,13 +139,13 @@ async def process_transaction( Normalized transaction hash string. """ message = _extract_message(transaction_data) - amount_ton = int(message["amount"]) / 1_000_000_000 + amount_gram = int(message["amount"]) / 1_000_000_000 async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) - await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton) + await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton) payload = clean_decode(str(message.get("payload", ""))) @@ -161,7 +161,7 @@ async def process_transaction( raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc cause = cause.__cause__ or cause.__context__ logger.exception( - "Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'", + "Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'", message["address"], message["amount"], payment_method, diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 898396c..e49a7b1 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -1,6 +1,8 @@ from __future__ import annotations from pyfragment.core.constants.limits import ( + GRAM_TOPUP_MAX, + GRAM_TOPUP_MIN, MNEMONIC_WORD_COUNTS_VALID, PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, @@ -11,8 +13,6 @@ from pyfragment.core.constants.limits import ( STARS_PURCHASE_MIN, STARS_WINNERS_MAX, STARS_WINNERS_MIN, - TON_TOPUP_MAX, - TON_TOPUP_MIN, TONAPI_KEY_MIN_LENGTH, ) @@ -39,7 +39,7 @@ class ConfigurationError(ClientError): INVALID_STARS_AMOUNT = ( f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}." ) - INVALID_TON_AMOUNT = f"Invalid TON amount: must be an integer between {TON_TOPUP_MIN:,} and {TON_TOPUP_MAX:,}." + INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}." INVALID_USERNAME = ( "Invalid username '{username}'. " "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)." @@ -70,7 +70,7 @@ class CookieError(ClientError): ) MISSING_BROWSER_KEYS = ( "Fragment cookies not found in {browser}: {keys}. " - "Make sure you are logged in to {url} and have connected your TON wallet in {browser}." + "Make sure you are logged in to {url} and have connected your GRAM (ex TON) wallet in {browser}." ) EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies." @@ -111,7 +111,7 @@ class AnonymousNumberError(FragmentAPIError): class TransactionError(FragmentAPIError): - """Raised when a TON transaction fails to build or broadcast.""" + """Raised when a GRAM (ex TON) transaction fails to build or broadcast.""" INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty." BROADCAST_FAILED = "Transaction broadcast failed: {exc}" @@ -123,7 +123,7 @@ class TransactionError(FragmentAPIError): "located in your Python installation folder." ) DUPLICATE_SEQNO = ( - "Transaction broadcast failed: the TON wallet rejected the message " + "Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message " "because a previous transaction with the same sequence number (seqno) " "is still pending confirmation on-chain.\n" "Wait a few seconds for the previous transaction to confirm, then retry." @@ -150,14 +150,16 @@ class OperationError(FragmentError): class WalletError(OperationError): - """Raised for TON wallet issues (connection, balance, account info).""" + """Raised for GRAM (ex TON) wallet issues (connection, balance, account info).""" - LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required." + LOW_GRAM_BALANCE = ( + "Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex TON) required." + ) LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required." - TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}" + GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}" USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}" - ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}" - WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}" + ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}" + WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}" class UnexpectedError(OperationError): diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 5b0a4a7..3d7a94f 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -15,8 +15,8 @@ else: class PaymentMethod(StrEnum): - TON = "ton" - USDT_TON = "usdt_ton" + GRAM = "ton" + USDT_GRAM = "usdt_ton" # Not supported yet USDT_ETH = "usdt_eth" diff --git a/pyfragment/models/payments.py b/pyfragment/models/payments.py index 9eb14f5..408b005 100644 --- a/pyfragment/models/payments.py +++ b/pyfragment/models/payments.py @@ -30,7 +30,7 @@ class AdsTopupResult: amount: int def __repr__(self) -> str: - return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')" + return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" @dataclass @@ -39,7 +39,7 @@ class AdsRechargeResult: amount: int def __repr__(self) -> str: - return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')" + return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" __all__ = ["AdsRechargeResult", "AdsTopupResult", "PremiumResult", "StarsResult"] diff --git a/pyfragment/models/wallet.py b/pyfragment/models/wallet.py index c52fdac..5f15a51 100644 --- a/pyfragment/models/wallet.py +++ b/pyfragment/models/wallet.py @@ -7,13 +7,13 @@ from dataclasses import dataclass class WalletInfo: address: str state: str - ton_balance: float + gram_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)" + f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)" ) diff --git a/pyproject.toml b/pyproject.toml index 82974a4..615706b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" version = "2026.3.2" -description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." +description = "Async Python client for the Fragment API. Buy Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" @@ -91,10 +91,16 @@ ignore = ["E501", "UP017"] [tool.mypy] python_version = "3.10" strict = true -exclude = ["^examples/", "^logs/"] +exclude = ["^examples/", "^logs/", "^tests/"] [[tool.mypy.overrides]] -module = "rookiepy" +module = [ + "rookiepy", + "httpx", + "ton_core", + "tonutils", + "tonutils.*", +] ignore_missing_imports = true [[tool.mypy.overrides]] diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index cedd4b5..fbe0d73 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -1,4 +1,4 @@ -"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows.""" +"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows.""" from collections.abc import Generator from contextlib import contextmanager @@ -22,7 +22,7 @@ TRANSACTION_DATA = { "messages": [ { "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", - "amount": "500000000", # 0.5 TON + "amount": "500000000", # 0.5 GRAM "payload": "", } ] @@ -63,7 +63,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: @pytest.mark.asyncio async def test_sufficient_balance_broadcasts() -> None: - wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold + wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @@ -72,7 +72,7 @@ async def test_sufficient_balance_broadcasts() -> None: @pytest.mark.asyncio async def test_insufficient_balance_raises() -> None: - wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold + wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): await process_transaction(_make_client(), TRANSACTION_DATA) @@ -89,7 +89,7 @@ async def test_exact_minimum_balance_broadcasts() -> None: @pytest.mark.asyncio async def test_one_nanoton_below_minimum_raises() -> None: - wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold + wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): await process_transaction(_make_client(), TRANSACTION_DATA) @@ -142,11 +142,11 @@ async def test_duplicate_seqno_raises_after_retries() -> None: @pytest.mark.asyncio -async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: - wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE +async def test_usdt_payment_requires_min_gram_gas_reserve() -> None: + wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): - with pytest.raises(WalletError, match="Insufficient TON balance"): - await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_TON) + with pytest.raises(WalletError, match="Insufficient GRAM"): + await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM) @pytest.mark.asyncio @@ -174,6 +174,6 @@ async def test_usdt_payment_checks_usdt_balance() -> None: await process_transaction( _make_client(), transaction, - payment_method=PaymentMethod.USDT_TON, + payment_method=PaymentMethod.USDT_GRAM, required_payment_amount=12.5, ) diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 1dc6b08..4a6d7df 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -87,7 +87,7 @@ async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> N patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_purchase_stars_mod, "process_transaction", proc_mock), ): - await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_TON) + await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_GRAM) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initBuyStarsRequest" @@ -209,7 +209,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_giveaway_stars_mod, "process_transaction", proc_mock), ): - await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_TON) + await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_GRAM) init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayStarsRequest" diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 8ecae0c..babf5f7 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -78,7 +78,7 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_purchase_premium_mod, "process_transaction", proc_mock), ): - await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_TON) + await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_GRAM) init_call = call_mock.await_args_list[2] assert init_call.args[0] == "initGiftPremiumRequest" @@ -207,7 +207,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_giveaway_premium_mod, "process_transaction", proc_mock), ): - await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_TON) + await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_GRAM) init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayPremiumRequest" diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index e1807da..0b06b7a 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -1,40 +1,40 @@ -"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building.""" +"""Cover GRAM (ex TON) top-up through Telegram Ads, including recipient lookup and transaction building.""" from unittest.mock import AsyncMock, patch import pytest -import pyfragment.domains.ads.tonup as _topup_ton_mod +import pyfragment.domains.ads.tonup as _topup_gram_mod from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN +from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH -# Topup TON validation tests +# Topup GRAM (ex TON) validation tests @pytest.mark.asyncio -async def test_topup_ton_amount_zero(client: FragmentClient) -> None: +async def test_topup_gram_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=TON_TOPUP_MIN - 1) + await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1) @pytest.mark.asyncio -async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: +async def test_topup_gram_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=TON_TOPUP_MAX + 1) + await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1) @pytest.mark.asyncio -async def test_topup_ton_float_amount(client: FragmentClient) -> None: +async def test_topup_gram_float_amount(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type] + await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type] -# Topup TON mocked tests +# Topup GRAM (ex TON) mocked tests @pytest.mark.asyncio -async def test_topup_ton_success(client: FragmentClient) -> None: +async def test_topup_gram_success(client: FragmentClient) -> None: with ( patch.object( client, @@ -48,10 +48,10 @@ async def test_topup_ton_success(client: FragmentClient) -> None: ] ), ), - patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): - result = await client.topup_ton("@user", amount=10) + result = await client.topup_gram("@user", amount=10) assert isinstance(result, AdsTopupResult) assert result.transaction_id == FAKE_TX_HASH @@ -60,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None: @pytest.mark.asyncio -async def test_topup_ton_user_not_found(client: FragmentClient) -> None: +async def test_topup_gram_user_not_found(client: FragmentClient) -> None: with patch.object( client, "call", @@ -72,4 +72,4 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None: ), ): with pytest.raises(UserNotFoundError): - await client.topup_ton("@ghost", amount=10) + await client.topup_gram("@ghost", amount=10) diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index 2010aa6..9f699c3 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -1,20 +1,20 @@ -"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi.""" +"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from pyfragment import FragmentClient, WalletInfo -from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON +from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM -# Wallet mocked tests (TON and USDT balances are returned separately) +# Wallet mocked tests (GRAM and USDT balances are returned separately) @pytest.mark.asyncio async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: mock_wallet = MagicMock() mock_wallet.refresh = AsyncMock() - mock_wallet.balance = FAKE_BALANCE_NANOTON + mock_wallet.balance = FAKE_BALANCE_NANOGRAM mock_wallet.state = MagicMock(value="active") mock_wallet.address.to_str.return_value = FAKE_ADDRESS @@ -32,7 +32,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: assert isinstance(result, WalletInfo) assert result.address == FAKE_ADDRESS assert result.state == "active" - assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) + assert result.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4) assert result.usdt_balance == 12.3456 @@ -55,6 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: result = await client.get_wallet() - assert result.ton_balance == 0.0 + assert result.gram_balance == 0.0 assert result.usdt_balance == 0.0 assert result.state == "uninit" diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 51f56a2..283e1de 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -6,7 +6,7 @@ import pytest import pyfragment.domains.ads.recharge as _recharge_ads_mod from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient -from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN +from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests @@ -15,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS @pytest.mark.asyncio async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MIN - 1) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1) @pytest.mark.asyncio async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MAX + 1) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1) @pytest.mark.asyncio diff --git a/tests/shared.py b/tests/shared.py index 1ce6be4..161cf9e 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,7 +1,7 @@ """Shared test constants for the pyfragment test suite. pyfragment is an async Python client for the Fragment API — a unified toolkit -to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, +to manage Telegram assets: purchase Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts. """ @@ -31,7 +31,7 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}} # get_wallet() FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" -FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON +FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON) # recharge_ads FAKE_ADS_ACCOUNT: str = "@mychannel" From 9f11ccb3ee835e3bfe10c737f9d7ed3d8c2862f2 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:02:28 +0300 Subject: [PATCH 11/15] Refactor pyfragment structure: Move models to domain-specific modules - Moved PaymentMethod and other enums from models to enums.py for better organization. - Refactored imports in various files to reflect the new structure. - Created new model files for ads, anonymous numbers, giveaways, marketplace, purchases, and tonapi. - Removed the old models.py file to clean up the codebase. - Updated all relevant imports in the codebase to use the new model locations. --- examples/purchase/run_premium_giveaway.py | 2 +- examples/purchase/run_stars_giveaway.py | 2 +- examples/purchase/send_premium.py | 2 +- examples/purchase/send_stars.py | 2 +- pyfragment/__init__.py | 15 +++++------ pyfragment/client.py | 13 +++++----- pyfragment/core/cookies.py | 4 +-- .../{models/cookies.py => core/models.py} | 0 pyfragment/domains/ads/models.py | 25 +++++++++++++++++++ pyfragment/domains/ads/recharge.py | 2 +- pyfragment/domains/ads/service.py | 2 +- pyfragment/domains/ads/tonup.py | 2 +- .../domains/anonymous_numbers/__init__.py | 2 +- .../anonymous_numbers/models.py} | 0 .../domains/anonymous_numbers/number.py | 2 +- .../domains/anonymous_numbers/service.py | 2 +- pyfragment/domains/giveaways/__init__.py | 2 +- pyfragment/domains/giveaways/giveaway.py | 4 +-- .../giveaways/models.py} | 0 pyfragment/domains/giveaways/service.py | 4 +-- pyfragment/domains/marketplace/__init__.py | 2 +- .../marketplace/models.py} | 0 pyfragment/domains/marketplace/search.py | 2 +- pyfragment/domains/marketplace/service.py | 2 +- pyfragment/domains/purchases/__init__.py | 2 +- .../purchases/models.py} | 21 +--------------- pyfragment/domains/purchases/purchase.py | 4 +-- pyfragment/domains/purchases/service.py | 4 +-- pyfragment/domains/tonapi/account.py | 4 +-- .../wallet.py => domains/tonapi/models.py} | 0 pyfragment/domains/tonapi/service.py | 2 +- pyfragment/domains/tonapi/transaction.py | 2 +- pyfragment/{models => }/enums.py | 0 pyfragment/models/__init__.py | 25 ------------------- tests/003_test_balance.py | 2 +- tests/004_test_stars.py | 2 +- tests/005_test_premium.py | 2 +- 37 files changed, 73 insertions(+), 90 deletions(-) rename pyfragment/{models/cookies.py => core/models.py} (100%) create mode 100644 pyfragment/domains/ads/models.py rename pyfragment/{models/anonymous_numbers.py => domains/anonymous_numbers/models.py} (100%) rename pyfragment/{models/giveaways.py => domains/giveaways/models.py} (100%) rename pyfragment/{models/marketplace.py => domains/marketplace/models.py} (100%) rename pyfragment/{models/payments.py => domains/purchases/models.py} (50%) rename pyfragment/{models/wallet.py => domains/tonapi/models.py} (100%) rename pyfragment/{models => }/enums.py (100%) delete mode 100644 pyfragment/models/__init__.py diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 184b518..82cd91f 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -9,7 +9,7 @@ Channel can be "@channel", "channel", or "https://t.me/channel". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index f685069..338e973 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -9,7 +9,7 @@ Channel can be "@channel", "channel", or "https://t.me/channel". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index c02d02b..a245c55 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -9,7 +9,7 @@ Username can be "@username", "username", or "https://t.me/username". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index 2dacdc6..ce9dc10 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -9,7 +9,7 @@ Username can be "@username", "username", or "https://t.me/username". import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index afdedfd..44162ce 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -20,13 +20,14 @@ from pyfragment.exceptions import ( VerificationError, WalletError, ) -from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult -from pyfragment.models.cookies import CookieResult -from pyfragment.models.enums import PaymentMethod, WalletVersion -from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult -from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult -from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult -from pyfragment.models.wallet import WalletInfo +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult +from pyfragment.core.models import CookieResult +from pyfragment.enums import PaymentMethod, WalletVersion +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult +from pyfragment.domains.purchases.models import PremiumResult, StarsResult +from pyfragment.domains.tonapi.models import WalletInfo logging.getLogger("pyfragment").addHandler(logging.NullHandler()) diff --git a/pyfragment/client.py b/pyfragment/client.py index 3b81841..59aaf7a 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -19,12 +19,13 @@ from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.tonapi.service import TonapiService from pyfragment.exceptions import ConfigurationError, CookieError -from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult -from pyfragment.models.enums import PaymentMethod, WalletVersion -from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult -from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult -from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult -from pyfragment.models.wallet import WalletInfo +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult +from pyfragment.enums import PaymentMethod, WalletVersion +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult +from pyfragment.domains.purchases.models import PremiumResult, StarsResult +from pyfragment.domains.tonapi.models import WalletInfo class FragmentClient: diff --git a/pyfragment/core/cookies.py b/pyfragment/core/cookies.py index f52769e..2676909 100644 --- a/pyfragment/core/cookies.py +++ b/pyfragment/core/cookies.py @@ -6,8 +6,8 @@ from typing import Any from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS from pyfragment.exceptions import CookieError -from pyfragment.models.cookies import CookieResult -from pyfragment.models.enums import SupportedBrowser +from pyfragment.core.models import CookieResult +from pyfragment.enums import SupportedBrowser try: import rookiepy diff --git a/pyfragment/models/cookies.py b/pyfragment/core/models.py similarity index 100% rename from pyfragment/models/cookies.py rename to pyfragment/core/models.py diff --git a/pyfragment/domains/ads/models.py b/pyfragment/domains/ads/models.py new file mode 100644 index 0000000..d6a0ef4 --- /dev/null +++ b/pyfragment/domains/ads/models.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class AdsTopupResult: + transaction_id: str + username: str + amount: int + + def __repr__(self) -> str: + return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" + + +@dataclass +class AdsRechargeResult: + transaction_id: str + amount: int + + def __repr__(self) -> str: + return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" + + +__all__ = ["AdsRechargeResult", "AdsTopupResult"] diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index ac7bf7c..e7fcee5 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -8,7 +8,7 @@ from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MA from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError -from pyfragment.models.payments import AdsRechargeResult +from pyfragment.domains.ads.models import AdsRechargeResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py index 35f3640..66a47ac 100644 --- a/pyfragment/domains/ads/service.py +++ b/pyfragment/domains/ads/service.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.tonup import topup_gram from pyfragment.domains.base import BaseService -from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index f04bb56..192a60f 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -16,7 +16,7 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.models.payments import AdsTopupResult +from pyfragment.domains.ads.models import AdsTopupResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py index 9b7bb67..d104f4d 100644 --- a/pyfragment/domains/anonymous_numbers/__init__.py +++ b/pyfragment/domains/anonymous_numbers/__init__.py @@ -1,6 +1,6 @@ from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService -from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult __all__ = [ "AnonymousNumbersService", diff --git a/pyfragment/models/anonymous_numbers.py b/pyfragment/domains/anonymous_numbers/models.py similarity index 100% rename from pyfragment/models/anonymous_numbers.py rename to pyfragment/domains/anonymous_numbers/models.py diff --git a/pyfragment/domains/anonymous_numbers/number.py b/pyfragment/domains/anonymous_numbers/number.py index 8a432be..3f43da2 100644 --- a/pyfragment/domains/anonymous_numbers/number.py +++ b/pyfragment/domains/anonymous_numbers/number.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from pyfragment.core.constants import NUMBERS_PAGE from pyfragment.domains.anonymous_numbers.parser import parse_login_code from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError -from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/anonymous_numbers/service.py b/pyfragment/domains/anonymous_numbers/service.py index 28c7ec5..96ec5e5 100644 --- a/pyfragment/domains/anonymous_numbers/service.py +++ b/pyfragment/domains/anonymous_numbers/service.py @@ -4,7 +4,7 @@ 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 +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py index eaec868..aeadd05 100644 --- a/pyfragment/domains/giveaways/__init__.py +++ b/pyfragment/domains/giveaways/__init__.py @@ -1,6 +1,6 @@ from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars from pyfragment.domains.giveaways.service import GiveawaysService -from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult __all__ = [ "GiveawaysService", diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index 3ca2245..4c6f3b0 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -28,8 +28,8 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.models.enums import PaymentMethod -from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.enums import PaymentMethod +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/models/giveaways.py b/pyfragment/domains/giveaways/models.py similarity index 100% rename from pyfragment/models/giveaways.py rename to pyfragment/domains/giveaways/models.py diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py index fd85eb6..6166769 100644 --- a/pyfragment/domains/giveaways/service.py +++ b/pyfragment/domains/giveaways/service.py @@ -4,8 +4,8 @@ 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 +from pyfragment.enums import PaymentMethod +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py index ed298ed..90dfcb6 100644 --- a/pyfragment/domains/marketplace/__init__.py +++ b/pyfragment/domains/marketplace/__init__.py @@ -1,6 +1,6 @@ from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames from pyfragment.domains.marketplace.service import MarketplaceService -from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult __all__ = [ "GiftsResult", diff --git a/pyfragment/models/marketplace.py b/pyfragment/domains/marketplace/models.py similarity index 100% rename from pyfragment/models/marketplace.py rename to pyfragment/domains/marketplace/models.py diff --git a/pyfragment/domains/marketplace/search.py b/pyfragment/domains/marketplace/search.py index 2f5b6d5..7d28876 100644 --- a/pyfragment/domains/marketplace/search.py +++ b/pyfragment/domains/marketplace/search.py @@ -6,7 +6,7 @@ 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 +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/marketplace/service.py b/pyfragment/domains/marketplace/service.py index 885e17a..b8510a2 100644 --- a/pyfragment/domains/marketplace/service.py +++ b/pyfragment/domains/marketplace/service.py @@ -4,7 +4,7 @@ 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 +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/purchases/__init__.py b/pyfragment/domains/purchases/__init__.py index baa5df2..9950f0a 100644 --- a/pyfragment/domains/purchases/__init__.py +++ b/pyfragment/domains/purchases/__init__.py @@ -1,5 +1,5 @@ from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars from pyfragment.domains.purchases.service import PurchasesService -from pyfragment.models.payments import PremiumResult, StarsResult +from pyfragment.domains.purchases.models import PremiumResult, StarsResult __all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"] diff --git a/pyfragment/models/payments.py b/pyfragment/domains/purchases/models.py similarity index 50% rename from pyfragment/models/payments.py rename to pyfragment/domains/purchases/models.py index 408b005..1ac4a9b 100644 --- a/pyfragment/models/payments.py +++ b/pyfragment/domains/purchases/models.py @@ -23,23 +23,4 @@ class StarsResult: 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} GRAM (ex TON), tx='{self.transaction_id}')" - - -@dataclass -class AdsRechargeResult: - transaction_id: str - amount: int - - def __repr__(self) -> str: - return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" - - -__all__ = ["AdsRechargeResult", "AdsTopupResult", "PremiumResult", "StarsResult"] +__all__ = ["PremiumResult", "StarsResult"] diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 515de8d..b60a868 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -25,8 +25,8 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.models.enums import PaymentMethod -from pyfragment.models.payments import PremiumResult, StarsResult +from pyfragment.enums import PaymentMethod +from pyfragment.domains.purchases.models import PremiumResult, StarsResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/purchases/service.py b/pyfragment/domains/purchases/service.py index cc3c18d..1650280 100644 --- a/pyfragment/domains/purchases/service.py +++ b/pyfragment/domains/purchases/service.py @@ -4,8 +4,8 @@ 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 +from pyfragment.enums import PaymentMethod +from pyfragment.domains.purchases.models import PremiumResult, StarsResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index c386c74..52cd42b 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -11,8 +11,8 @@ from tonutils.exceptions import ProviderResponseError from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS from pyfragment.exceptions import WalletError -from pyfragment.models.enums import WALLET_CLASSES -from pyfragment.models.wallet import WalletInfo +from pyfragment.enums import WALLET_CLASSES +from pyfragment.domains.tonapi.models import WalletInfo if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/models/wallet.py b/pyfragment/domains/tonapi/models.py similarity index 100% rename from pyfragment/models/wallet.py rename to pyfragment/domains/tonapi/models.py diff --git a/pyfragment/domains/tonapi/service.py b/pyfragment/domains/tonapi/service.py index 9914b64..3de0067 100644 --- a/pyfragment/domains/tonapi/service.py +++ b/pyfragment/domains/tonapi/service.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from pyfragment.domains.base import BaseService from pyfragment.domains.tonapi.account import get_wallet_info -from pyfragment.models.wallet import WalletInfo +from pyfragment.domains.tonapi.models import WalletInfo if TYPE_CHECKING: pass diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 49910a8..2abf824 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -13,7 +13,7 @@ from tonutils.exceptions import ProviderResponseError from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance from pyfragment.exceptions import ParseError, TransactionError, WalletError -from pyfragment.models.enums import WALLET_CLASSES, PaymentMethod +from pyfragment.enums import WALLET_CLASSES, PaymentMethod if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/models/enums.py b/pyfragment/enums.py similarity index 100% rename from pyfragment/models/enums.py rename to pyfragment/enums.py diff --git a/pyfragment/models/__init__.py b/pyfragment/models/__init__.py deleted file mode 100644 index 21b9b7e..0000000 --- a/pyfragment/models/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult -from pyfragment.models.cookies import CookieResult -from pyfragment.models.enums import PaymentMethod, WalletVersion -from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult -from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult -from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult -from pyfragment.models.wallet import WalletInfo - -__all__ = [ - "AdsRechargeResult", - "AdsTopupResult", - "CookieResult", - "GiftsResult", - "LoginCodeResult", - "NumbersResult", - "PaymentMethod", - "PremiumGiveawayResult", - "PremiumResult", - "StarsGiveawayResult", - "StarsResult", - "TerminateSessionsResult", - "UsernamesResult", - "WalletInfo", - "WalletVersion", -] diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index fbe0d73..6ac5f0b 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -9,7 +9,7 @@ from tonutils.exceptions import ProviderResponseError from pyfragment import TransactionError, WalletError from pyfragment.domains.tonapi.transaction import process_transaction -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod from tests.shared import VALID_SEED diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 4a6d7df..18dd193 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -15,7 +15,7 @@ from pyfragment.core.constants.limits import ( STARS_WINNERS_MAX, STARS_WINNERS_MIN, ) -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Stars purchase validation tests diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index babf5f7..235b282 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -9,7 +9,7 @@ import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError from pyfragment.core.constants.limits import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN from pyfragment.exceptions import AlreadySubscribedError -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests From dc9661134d595dd45f84ce4e286c2f0d58fcd82e Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:19:33 +0300 Subject: [PATCH 12/15] Refactor cookie handling and service structure - Removed the old CookieResult model from pyfragment.core.models and created a new one in pyfragment.services.cookies.models. - Implemented get_cookies_from_browser function in pyfragment.services.cookies.service to extract cookies directly from supported browsers. - Updated imports across various modules to reflect the new structure and removed unused imports. - Cleaned up models in ads, anonymous_numbers, giveaways, marketplace, and purchases domains by removing unnecessary __all__ declarations. - Added new example scripts for cookie extraction and various purchase functionalities. - Consolidated constants into a new pyfragment.core.constants module for better organization. --- README.md | 13 +-- examples/client/get_cookies.py | 33 +++++++ ...remium_giveaway.py => premium_giveaway.py} | 0 .../{send_premium.py => purchase_premium.py} | 0 .../{send_stars.py => purchase_stars.py} | 0 ...un_stars_giveaway.py => stars_giveaway.py} | 0 ...p_ton_balance.py => topup_gram_balance.py} | 0 pyfragment/__init__.py | 17 ++-- pyfragment/client.py | 16 ++-- pyfragment/core/constants.py | 87 +++++++++++++++++++ pyfragment/core/constants/__init__.py | 64 -------------- pyfragment/core/constants/gram.py | 17 ---- pyfragment/core/constants/headers.py | 26 ------ pyfragment/core/constants/limits.py | 34 -------- pyfragment/core/constants/urls.py | 12 --- pyfragment/domains/ads/models.py | 3 - pyfragment/domains/ads/recharge.py | 2 +- pyfragment/domains/ads/service.py | 2 +- pyfragment/domains/ads/tonup.py | 2 +- .../domains/anonymous_numbers/__init__.py | 2 +- .../domains/anonymous_numbers/models.py | 3 - .../domains/anonymous_numbers/number.py | 2 +- .../domains/anonymous_numbers/service.py | 2 +- pyfragment/domains/giveaways/__init__.py | 2 +- pyfragment/domains/giveaways/giveaway.py | 4 +- pyfragment/domains/giveaways/models.py | 3 - pyfragment/domains/giveaways/service.py | 2 +- pyfragment/domains/marketplace/__init__.py | 2 +- pyfragment/domains/marketplace/models.py | 3 - pyfragment/domains/marketplace/search.py | 2 +- pyfragment/domains/marketplace/service.py | 2 +- pyfragment/domains/purchases/__init__.py | 2 +- pyfragment/domains/purchases/models.py | 3 - pyfragment/domains/purchases/purchase.py | 4 +- pyfragment/domains/purchases/service.py | 2 +- pyfragment/domains/tonapi/account.py | 4 +- pyfragment/domains/tonapi/models.py | 3 - pyfragment/domains/tonapi/transaction.py | 2 +- pyfragment/enums.py | 3 - pyfragment/exceptions.py | 2 +- pyfragment/services/__init__.py | 3 + pyfragment/services/cookies/__init__.py | 4 + .../{core => services/cookies}/models.py | 3 - .../cookies/service.py} | 4 +- pyproject.toml | 2 +- tests/002_test_client.py | 2 +- tests/004_test_stars.py | 2 +- tests/005_test_premium.py | 4 +- tests/006_test_topup.py | 2 +- tests/010_test_recharge_ads.py | 2 +- tests/014_test_cookies.py | 2 +- 51 files changed, 178 insertions(+), 234 deletions(-) create mode 100644 examples/client/get_cookies.py rename examples/purchase/{run_premium_giveaway.py => premium_giveaway.py} (100%) rename examples/purchase/{send_premium.py => purchase_premium.py} (100%) rename examples/purchase/{send_stars.py => purchase_stars.py} (100%) rename examples/purchase/{run_stars_giveaway.py => stars_giveaway.py} (100%) rename examples/purchase/{topup_ton_balance.py => topup_gram_balance.py} (100%) create mode 100644 pyfragment/core/constants.py delete mode 100644 pyfragment/core/constants/__init__.py delete mode 100644 pyfragment/core/constants/gram.py delete mode 100644 pyfragment/core/constants/headers.py delete mode 100644 pyfragment/core/constants/limits.py delete mode 100644 pyfragment/core/constants/urls.py create mode 100644 pyfragment/services/__init__.py create mode 100644 pyfragment/services/cookies/__init__.py rename pyfragment/{core => services/cookies}/models.py (90%) rename pyfragment/{core/cookies.py => services/cookies/service.py} (97%) diff --git a/README.md b/README.md index e2be12e..3bd9419 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,10 @@ pip install git+https://github.com/bohd4nx/pyfragment.git@dev ```python import asyncio + from pyfragment import FragmentClient -from pyfragment.models.enums import PaymentMethod +from pyfragment.enums import PaymentMethod + async def main() -> None: async with FragmentClient( @@ -58,14 +60,7 @@ async def main() -> None: recipient = "https://t.me/username" # also: @username, username stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM) - if stars.auto_paid: - print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") - else: - print(f"Manual payment required for {stars.username}") - print(f"Address: {stars.payment_address}") - print(f"Amount: {stars.payment_amount}") - print(f"Expires: {stars.payment_expires_at}") - print(f"QR link: {stars.payment_qr_link}") + print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM) print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") diff --git a/examples/client/get_cookies.py b/examples/client/get_cookies.py new file mode 100644 index 0000000..a01c659 --- /dev/null +++ b/examples/client/get_cookies.py @@ -0,0 +1,33 @@ +""" +Example: extract Fragment cookies directly from your browser. + +get_cookies_from_browser() reads the Fragment session cookies from a locally +installed browser — no manual copy-paste required. + +Supported browsers: arc, brave, chrome, chromium, chromium_based, edge, + firefox, firefox_based, librewolf, opera, opera_gx, + safari, vivaldi. + +The returned CookieResult.cookies dict can be passed directly to FragmentClient. +""" + +from pyfragment import CookieError, get_cookies_from_browser + + +def main() -> None: + try: + result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + except CookieError as e: + print(f"Could not read cookies: {e}") + return + + print(f"Cookies expire: {result.expires}") + print(f"Keys found: {list(result.cookies.keys())}") + + # Pass the extracted cookies directly to FragmentClient + # async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=result.cookies) as client: + # ... + + +if __name__ == "__main__": + main() diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/premium_giveaway.py similarity index 100% rename from examples/purchase/run_premium_giveaway.py rename to examples/purchase/premium_giveaway.py diff --git a/examples/purchase/send_premium.py b/examples/purchase/purchase_premium.py similarity index 100% rename from examples/purchase/send_premium.py rename to examples/purchase/purchase_premium.py diff --git a/examples/purchase/send_stars.py b/examples/purchase/purchase_stars.py similarity index 100% rename from examples/purchase/send_stars.py rename to examples/purchase/purchase_stars.py diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/stars_giveaway.py similarity index 100% rename from examples/purchase/run_stars_giveaway.py rename to examples/purchase/stars_giveaway.py diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_gram_balance.py similarity index 100% rename from examples/purchase/topup_ton_balance.py rename to examples/purchase/topup_gram_balance.py diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 44162ce..d30e5f3 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -2,7 +2,13 @@ import logging from importlib.metadata import version from pyfragment.client import FragmentClient -from pyfragment.core.cookies import get_cookies_from_browser +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.domains.purchases.models import PremiumResult, StarsResult +from pyfragment.domains.tonapi.models import WalletInfo +from pyfragment.enums import PaymentMethod, WalletVersion from pyfragment.exceptions import ( AlreadySubscribedError, AnonymousNumberError, @@ -20,14 +26,7 @@ from pyfragment.exceptions import ( VerificationError, WalletError, ) -from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult -from pyfragment.core.models import CookieResult -from pyfragment.enums import PaymentMethod, WalletVersion -from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult -from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult -from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult -from pyfragment.domains.purchases.models import PremiumResult, StarsResult -from pyfragment.domains.tonapi.models import WalletInfo +from pyfragment.services.cookies import CookieResult, get_cookies_from_browser logging.getLogger("pyfragment").addHandler(logging.NullHandler()) diff --git a/pyfragment/client.py b/pyfragment/client.py index 59aaf7a..3999b15 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -11,21 +11,21 @@ from pyfragment.core.constants import ( REQUIRED_COOKIE_KEYS, TONAPI_KEY_MIN_LENGTH, ) +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.domains.ads.service import AdsService +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService from pyfragment.domains.base import raw_api_call -from pyfragment.domains.giveaways.service import GiveawaysService -from pyfragment.domains.marketplace.service import MarketplaceService -from pyfragment.domains.purchases.service import PurchasesService -from pyfragment.domains.tonapi.service import TonapiService -from pyfragment.exceptions import ConfigurationError, CookieError -from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult -from pyfragment.enums import PaymentMethod, WalletVersion from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult -from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult +from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.purchases.models import PremiumResult, StarsResult +from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.tonapi.models import WalletInfo +from pyfragment.domains.tonapi.service import TonapiService +from pyfragment.enums import PaymentMethod, WalletVersion +from pyfragment.exceptions import ConfigurationError, CookieError class FragmentClient: diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py new file mode 100644 index 0000000..835869e --- /dev/null +++ b/pyfragment/core/constants.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any + +FRAGMENT_DOMAIN: str = "fragment.com" +FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" + +STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" +STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" +PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" +ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" +GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" + +DEFAULT_TIMEOUT: float = 30.0 + +# Fragment cookie keys required for authenticated API calls +REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") + +BASE_HEADERS: dict[str, str] = { + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": FRAGMENT_BASE_URL, + "priority": "u=1, i", + "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', + "sec-ch-ua-mobile": "?1", + "sec-ch-ua-platform": '"Android"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": ( + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36" + ), + "x-requested-with": "XMLHttpRequest", +} + +# USDT-TON jetton master contract address on GRAM (ex TON) mainnet +USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" + +# TON Connect device info sent during wallet connection handshake +DEVICE_INFO: dict[str, Any] = { + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "26.05.0", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], +} + +# Stars: direct purchase per transaction +STARS_PURCHASE_MIN: int = 50 +STARS_PURCHASE_MAX: int = 10_000_000 + +# Stars: giveaway amount per winner +STARS_GIVEAWAY_MIN: int = 500 +STARS_GIVEAWAY_MAX: int = 1_000_000 + +# Stars giveaway winner count +STARS_WINNERS_MIN: int = 1 +STARS_WINNERS_MAX: int = 15 + +# Premium giveaway winner count +PREMIUM_WINNERS_MIN: int = 1 +PREMIUM_WINNERS_MAX: int = 24_000 + +# GRAM (ex TON) topup / Ads recharge amount +GRAM_TOPUP_MIN: int = 1 +GRAM_TOPUP_MAX: int = 1_000_000_000 + +# Minimum wallet balances required before broadcasting a transaction +MIN_GRAM_BALANCE: float = 0.33 +MIN_USDT_BALANCE: float = 0.75 + +# Premium subscription durations (months) +PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12}) + +# Mnemonic phrase valid word counts +MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24}) + +# Tonapi API key minimum length (tonapi.io) +TONAPI_KEY_MIN_LENGTH: int = 68 diff --git a/pyfragment/core/constants/__init__.py b/pyfragment/core/constants/__init__.py deleted file mode 100644 index 7dd2c93..0000000 --- a/pyfragment/core/constants/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from pyfragment.core.constants.gram import DEVICE_INFO, USDT_GRAM_MASTER_ADDRESS -from pyfragment.core.constants.headers import BASE_HEADERS, DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS -from pyfragment.core.constants.limits import ( - GRAM_TOPUP_MAX, - GRAM_TOPUP_MIN, - MIN_GRAM_BALANCE, - MIN_USDT_BALANCE, - MNEMONIC_WORD_COUNTS_VALID, - PREMIUM_MONTHS_VALID, - PREMIUM_WINNERS_MAX, - PREMIUM_WINNERS_MIN, - STARS_GIVEAWAY_MAX, - STARS_GIVEAWAY_MIN, - STARS_PURCHASE_MAX, - STARS_PURCHASE_MIN, - STARS_WINNERS_MAX, - STARS_WINNERS_MIN, - TONAPI_KEY_MIN_LENGTH, -) -from pyfragment.core.constants.urls import ( - ADS_TOPUP_PAGE, - FRAGMENT_BASE_URL, - FRAGMENT_DOMAIN, - GIFTS_PAGE, - NUMBERS_PAGE, - PREMIUM_GIVEAWAY_PAGE, - PREMIUM_PAGE, - STARS_GIVEAWAY_PAGE, - STARS_PAGE, -) - -__all__ = [ - "ADS_TOPUP_PAGE", - "BASE_HEADERS", - "DEFAULT_TIMEOUT", - "DEVICE_INFO", - "FRAGMENT_BASE_URL", - "FRAGMENT_DOMAIN", - "GIFTS_PAGE", - "GRAM_TOPUP_MAX", - "GRAM_TOPUP_MIN", - "MIN_GRAM_BALANCE", - "MIN_USDT_BALANCE", - "MNEMONIC_WORD_COUNTS_VALID", - "NUMBERS_PAGE", - "PREMIUM_GIVEAWAY_PAGE", - "PREMIUM_MONTHS_VALID", - "PREMIUM_PAGE", - "PREMIUM_WINNERS_MAX", - "PREMIUM_WINNERS_MIN", - "REQUIRED_COOKIE_KEYS", - "STARS_GIVEAWAY_MAX", - "STARS_GIVEAWAY_MIN", - "STARS_GIVEAWAY_PAGE", - "STARS_PAGE", - "STARS_PURCHASE_MAX", - "STARS_PURCHASE_MIN", - "STARS_WINNERS_MAX", - "STARS_WINNERS_MIN", - "TONAPI_KEY_MIN_LENGTH", - "USDT_GRAM_MASTER_ADDRESS", -] diff --git a/pyfragment/core/constants/gram.py b/pyfragment/core/constants/gram.py deleted file mode 100644 index f1cc6cc..0000000 --- a/pyfragment/core/constants/gram.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import Any - -USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" - -DEVICE_INFO: dict[str, Any] = { - "platform": "iphone", - "appName": "Tonkeeper", - "appVersion": "26.05.0", - "maxProtocolVersion": 2, - "features": [ - "SendTransaction", - {"name": "SendTransaction", "maxMessages": 255}, - {"name": "SignData", "types": ["text", "binary", "cell"]}, - ], -} diff --git a/pyfragment/core/constants/headers.py b/pyfragment/core/constants/headers.py deleted file mode 100644 index f0462fd..0000000 --- a/pyfragment/core/constants/headers.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from pyfragment.core.constants.urls import FRAGMENT_BASE_URL - -DEFAULT_TIMEOUT: float = 30.0 - -REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") - -BASE_HEADERS: dict[str, str] = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": FRAGMENT_BASE_URL, - "priority": "u=1, i", - "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', - "sec-ch-ua-mobile": "?1", - "sec-ch-ua-platform": '"Android"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - "user-agent": ( - "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36" - ), - "x-requested-with": "XMLHttpRequest", -} diff --git a/pyfragment/core/constants/limits.py b/pyfragment/core/constants/limits.py deleted file mode 100644 index bb35890..0000000 --- a/pyfragment/core/constants/limits.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -# Stars: direct purchase -STARS_PURCHASE_MIN: int = 50 -STARS_PURCHASE_MAX: int = 10_000_000 - -# Stars: giveaway per winner -STARS_GIVEAWAY_MIN: int = 500 -STARS_GIVEAWAY_MAX: int = 1_000_000 - -# Stars giveaway winners count -STARS_WINNERS_MIN: int = 1 -STARS_WINNERS_MAX: int = 15 - -# Premium giveaway winners count -PREMIUM_WINNERS_MIN: int = 1 -PREMIUM_WINNERS_MAX: int = 24_000 - -# GRAM (ex TON) topup / Ads recharge amount -GRAM_TOPUP_MIN: int = 1 -GRAM_TOPUP_MAX: int = 1_000_000_000 - -# Wallet minimum balances -MIN_GRAM_BALANCE: float = 0.33 -MIN_USDT_BALANCE: float = 0.75 - -# Premium subscription durations (months) -PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12}) - -# Mnemonic phrase valid word counts -MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24}) - -# Tonapi API key minimum length -TONAPI_KEY_MIN_LENGTH: int = 68 diff --git a/pyfragment/core/constants/urls.py b/pyfragment/core/constants/urls.py deleted file mode 100644 index cba12b0..0000000 --- a/pyfragment/core/constants/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -FRAGMENT_DOMAIN: str = "fragment.com" -FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" - -STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" -STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" -PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" -PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" -ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" -NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" -GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" diff --git a/pyfragment/domains/ads/models.py b/pyfragment/domains/ads/models.py index d6a0ef4..b73a2f4 100644 --- a/pyfragment/domains/ads/models.py +++ b/pyfragment/domains/ads/models.py @@ -20,6 +20,3 @@ class AdsRechargeResult: def __repr__(self) -> str: return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')" - - -__all__ = ["AdsRechargeResult", "AdsTopupResult"] diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index e7fcee5..ab44e79 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -5,10 +5,10 @@ import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN +from pyfragment.domains.ads.models import AdsRechargeResult from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError -from pyfragment.domains.ads.models import AdsRechargeResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py index 66a47ac..7f26602 100644 --- a/pyfragment/domains/ads/service.py +++ b/pyfragment/domains/ads/service.py @@ -2,10 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING +from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.tonup import topup_gram from pyfragment.domains.base import BaseService -from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index 192a60f..e4f69d8 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN +from pyfragment.domains.ads.models import AdsTopupResult from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction @@ -16,7 +17,6 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.domains.ads.models import AdsTopupResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py index d104f4d..ffbef1e 100644 --- a/pyfragment/domains/anonymous_numbers/__init__.py +++ b/pyfragment/domains/anonymous_numbers/__init__.py @@ -1,6 +1,6 @@ +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService -from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult __all__ = [ "AnonymousNumbersService", diff --git a/pyfragment/domains/anonymous_numbers/models.py b/pyfragment/domains/anonymous_numbers/models.py index 1d78977..1c1c9fc 100644 --- a/pyfragment/domains/anonymous_numbers/models.py +++ b/pyfragment/domains/anonymous_numbers/models.py @@ -21,6 +21,3 @@ class TerminateSessionsResult: def __repr__(self) -> str: return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" - - -__all__ = ["LoginCodeResult", "TerminateSessionsResult"] diff --git a/pyfragment/domains/anonymous_numbers/number.py b/pyfragment/domains/anonymous_numbers/number.py index 3f43da2..d68e031 100644 --- a/pyfragment/domains/anonymous_numbers/number.py +++ b/pyfragment/domains/anonymous_numbers/number.py @@ -5,9 +5,9 @@ import logging from typing import TYPE_CHECKING from pyfragment.core.constants import NUMBERS_PAGE +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult from pyfragment.domains.anonymous_numbers.parser import parse_login_code from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError -from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/anonymous_numbers/service.py b/pyfragment/domains/anonymous_numbers/service.py index 96ec5e5..11b60a2 100644 --- a/pyfragment/domains/anonymous_numbers/service.py +++ b/pyfragment/domains/anonymous_numbers/service.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import TYPE_CHECKING +from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.domains.base import BaseService -from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py index aeadd05..0d814a1 100644 --- a/pyfragment/domains/giveaways/__init__.py +++ b/pyfragment/domains/giveaways/__init__.py @@ -1,6 +1,6 @@ from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars -from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.domains.giveaways.service import GiveawaysService __all__ = [ "GiveawaysService", diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index 4c6f3b0..dc5e9e6 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -17,9 +17,11 @@ from pyfragment.core.constants import ( STARS_WINNERS_MAX, STARS_WINNERS_MIN, ) +from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.enums import PaymentMethod from pyfragment.exceptions import ( ConfigurationError, FragmentAPIError, @@ -28,8 +30,6 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.enums import PaymentMethod -from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/giveaways/models.py b/pyfragment/domains/giveaways/models.py index 3c9c02c..c8e9523 100644 --- a/pyfragment/domains/giveaways/models.py +++ b/pyfragment/domains/giveaways/models.py @@ -29,6 +29,3 @@ class PremiumGiveawayResult: f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " f"amount={self.amount} months per winner, tx='{self.transaction_id}')" ) - - -__all__ = ["PremiumGiveawayResult", "StarsGiveawayResult"] diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py index 6166769..b0e1e27 100644 --- a/pyfragment/domains/giveaways/service.py +++ b/pyfragment/domains/giveaways/service.py @@ -4,8 +4,8 @@ from typing import TYPE_CHECKING from pyfragment.domains.base import BaseService from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars -from pyfragment.enums import PaymentMethod from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.enums import PaymentMethod if TYPE_CHECKING: pass diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py index 90dfcb6..b80db15 100644 --- a/pyfragment/domains/marketplace/__init__.py +++ b/pyfragment/domains/marketplace/__init__.py @@ -1,6 +1,6 @@ +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames from pyfragment.domains.marketplace.service import MarketplaceService -from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult __all__ = [ "GiftsResult", diff --git a/pyfragment/domains/marketplace/models.py b/pyfragment/domains/marketplace/models.py index d773095..794961a 100644 --- a/pyfragment/domains/marketplace/models.py +++ b/pyfragment/domains/marketplace/models.py @@ -29,6 +29,3 @@ class GiftsResult: def __repr__(self) -> str: return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" - - -__all__ = ["GiftsResult", "NumbersResult", "UsernamesResult"] diff --git a/pyfragment/domains/marketplace/search.py b/pyfragment/domains/marketplace/search.py index 7d28876..9c24ddb 100644 --- a/pyfragment/domains/marketplace/search.py +++ b/pyfragment/domains/marketplace/search.py @@ -4,9 +4,9 @@ import logging from typing import TYPE_CHECKING, Any from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE +from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult from pyfragment.domains.marketplace.parser import parse_auction_rows, parse_gift_items from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError -from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/marketplace/service.py b/pyfragment/domains/marketplace/service.py index b8510a2..c65416c 100644 --- a/pyfragment/domains/marketplace/service.py +++ b/pyfragment/domains/marketplace/service.py @@ -3,8 +3,8 @@ 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.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult +from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames if TYPE_CHECKING: pass diff --git a/pyfragment/domains/purchases/__init__.py b/pyfragment/domains/purchases/__init__.py index 9950f0a..767e117 100644 --- a/pyfragment/domains/purchases/__init__.py +++ b/pyfragment/domains/purchases/__init__.py @@ -1,5 +1,5 @@ +from pyfragment.domains.purchases.models import PremiumResult, StarsResult from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars from pyfragment.domains.purchases.service import PurchasesService -from pyfragment.domains.purchases.models import PremiumResult, StarsResult __all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"] diff --git a/pyfragment/domains/purchases/models.py b/pyfragment/domains/purchases/models.py index 1ac4a9b..1e93ce4 100644 --- a/pyfragment/domains/purchases/models.py +++ b/pyfragment/domains/purchases/models.py @@ -21,6 +21,3 @@ class StarsResult: def __repr__(self) -> str: return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" - - -__all__ = ["PremiumResult", "StarsResult"] diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index b60a868..e774718 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -14,8 +14,10 @@ from pyfragment.core.constants import ( STARS_PURCHASE_MIN, ) from pyfragment.domains.payments import parse_required_payment_amount +from pyfragment.domains.purchases.models import PremiumResult, StarsResult from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction +from pyfragment.enums import PaymentMethod from pyfragment.exceptions import ( AlreadySubscribedError, ConfigurationError, @@ -25,8 +27,6 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) -from pyfragment.enums import PaymentMethod -from pyfragment.domains.purchases.models import PremiumResult, StarsResult if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/purchases/service.py b/pyfragment/domains/purchases/service.py index 1650280..8fb1ac9 100644 --- a/pyfragment/domains/purchases/service.py +++ b/pyfragment/domains/purchases/service.py @@ -3,9 +3,9 @@ from __future__ import annotations from typing import TYPE_CHECKING from pyfragment.domains.base import BaseService +from pyfragment.domains.purchases.models import PremiumResult, StarsResult from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars from pyfragment.enums import PaymentMethod -from pyfragment.domains.purchases.models import PremiumResult, StarsResult if TYPE_CHECKING: pass diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/domains/tonapi/account.py index 52cd42b..7e094ae 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/domains/tonapi/account.py @@ -10,9 +10,9 @@ from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_ from tonutils.exceptions import ProviderResponseError from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS -from pyfragment.exceptions import WalletError -from pyfragment.enums import WALLET_CLASSES from pyfragment.domains.tonapi.models import WalletInfo +from pyfragment.enums import WALLET_CLASSES +from pyfragment.exceptions import WalletError if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/tonapi/models.py b/pyfragment/domains/tonapi/models.py index 5f15a51..4b51e72 100644 --- a/pyfragment/domains/tonapi/models.py +++ b/pyfragment/domains/tonapi/models.py @@ -15,6 +15,3 @@ class WalletInfo: f"WalletInfo(address='{self.address}', state='{self.state}', " f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)" ) - - -__all__ = ["WalletInfo"] diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/domains/tonapi/transaction.py index 2abf824..1e8bacf 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -12,8 +12,8 @@ from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance -from pyfragment.exceptions import ParseError, TransactionError, WalletError from pyfragment.enums import WALLET_CLASSES, PaymentMethod +from pyfragment.exceptions import ParseError, TransactionError, WalletError if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/enums.py b/pyfragment/enums.py index 3d7a94f..ac672e5 100644 --- a/pyfragment/enums.py +++ b/pyfragment/enums.py @@ -55,6 +55,3 @@ class SupportedBrowser(StrEnum): OPERA_GX = "opera_gx" SAFARI = "safari" VIVALDI = "vivaldi" - - -__all__ = ["PaymentMethod", "SupportedBrowser", "WALLET_CLASSES", "WalletVersion"] diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index e49a7b1..5c1e765 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -1,6 +1,6 @@ from __future__ import annotations -from pyfragment.core.constants.limits import ( +from pyfragment.core.constants import ( GRAM_TOPUP_MAX, GRAM_TOPUP_MIN, MNEMONIC_WORD_COUNTS_VALID, diff --git a/pyfragment/services/__init__.py b/pyfragment/services/__init__.py new file mode 100644 index 0000000..fef3009 --- /dev/null +++ b/pyfragment/services/__init__.py @@ -0,0 +1,3 @@ +from pyfragment.services.cookies import CookieResult, get_cookies_from_browser + +__all__ = ["CookieResult", "get_cookies_from_browser"] diff --git a/pyfragment/services/cookies/__init__.py b/pyfragment/services/cookies/__init__.py new file mode 100644 index 0000000..c14fdcd --- /dev/null +++ b/pyfragment/services/cookies/__init__.py @@ -0,0 +1,4 @@ +from pyfragment.services.cookies.models import CookieResult +from pyfragment.services.cookies.service import get_cookies_from_browser + +__all__ = ["CookieResult", "get_cookies_from_browser"] diff --git a/pyfragment/core/models.py b/pyfragment/services/cookies/models.py similarity index 90% rename from pyfragment/core/models.py rename to pyfragment/services/cookies/models.py index 6c218ce..aec4314 100644 --- a/pyfragment/core/models.py +++ b/pyfragment/services/cookies/models.py @@ -10,6 +10,3 @@ class CookieResult: def __repr__(self) -> str: return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})" - - -__all__ = ["CookieResult"] diff --git a/pyfragment/core/cookies.py b/pyfragment/services/cookies/service.py similarity index 97% rename from pyfragment/core/cookies.py rename to pyfragment/services/cookies/service.py index 2676909..c88ed40 100644 --- a/pyfragment/core/cookies.py +++ b/pyfragment/services/cookies/service.py @@ -5,9 +5,9 @@ from datetime import datetime, timezone from typing import Any from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS -from pyfragment.exceptions import CookieError -from pyfragment.core.models import CookieResult from pyfragment.enums import SupportedBrowser +from pyfragment.exceptions import CookieError +from pyfragment.services.cookies.models import CookieResult try: import rookiepy diff --git a/pyproject.toml b/pyproject.toml index 615706b..dbc5a69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,5 +106,5 @@ ignore_missing_imports = true [[tool.mypy.overrides]] # rookiepy = None assignment is valid when the package is installed (type: ModuleType) # but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused. -module = "pyfragment.core.cookies" +module = "pyfragment.services.cookies.service" warn_unused_ignores = false diff --git a/tests/002_test_client.py b/tests/002_test_client.py index c813236..91660ab 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -5,7 +5,7 @@ import json import pytest from pyfragment import ConfigurationError, CookieError, FragmentClient -from pyfragment.core.constants.limits import MNEMONIC_WORD_COUNTS_VALID, TONAPI_KEY_MIN_LENGTH +from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, TONAPI_KEY_MIN_LENGTH from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED # Client init tests diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 18dd193..97f73b0 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -7,7 +7,7 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod import pyfragment.domains.purchases.purchase as _purchase_stars_mod from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError -from pyfragment.core.constants.limits import ( +from pyfragment.core.constants import ( STARS_GIVEAWAY_MAX, STARS_GIVEAWAY_MIN, STARS_PURCHASE_MAX, diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 235b282..64af05b 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -7,9 +7,9 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError -from pyfragment.core.constants.limits import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN -from pyfragment.exceptions import AlreadySubscribedError +from pyfragment.core.constants import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN from pyfragment.enums import PaymentMethod +from pyfragment.exceptions import AlreadySubscribedError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index 0b06b7a..eea5bdc 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -6,7 +6,7 @@ import pytest import pyfragment.domains.ads.tonup as _topup_gram_mod from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError -from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN +from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup GRAM (ex TON) validation tests diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 283e1de..bb31094 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -6,7 +6,7 @@ import pytest import pyfragment.domains.ads.recharge as _recharge_ads_mod from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient -from pyfragment.core.constants.limits import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN +from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index a390e29..1041de5 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -22,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock: return mock -PATCH = "pyfragment.core.cookies.rookiepy" +PATCH = "pyfragment.services.cookies.service.rookiepy" # unsupported browser tests From 228fbfcd1cfe4327405da1a9fb3660e4320f38fd Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:37:26 +0300 Subject: [PATCH 13/15] Refactor tonapi module: Move account and transaction logic to services - Moved account-related functions and classes from `pyfragment.domains.tonapi.account` to `pyfragment.services.tonapi.account`. - Moved transaction-related functions and classes from `pyfragment.domains.tonapi.transaction` to `pyfragment.services.tonapi.transaction`. - Updated imports across the codebase to reflect the new structure. - Removed unused `tonapi` module files and cleaned up related code. - Introduced `ApiProvider` enum to manage API provider types. - Added validation functions for cookies and wallet versions in a new `validation.py` module. --- CHANGELOG.md | 4 + README.md | 19 +- examples/auctions/search_gifts.py | 10 +- examples/auctions/search_numbers.py | 10 +- examples/auctions/search_usernames.py | 10 +- examples/client/raw_api_call.py | 10 +- examples/client/wallet_info.py | 6 +- examples/numbers/manage_number.py | 10 +- examples/purchase/premium_giveaway.py | 10 +- examples/purchase/purchase_premium.py | 10 +- examples/purchase/purchase_stars.py | 10 +- examples/purchase/recharge_ads_balance.py | 10 +- examples/purchase/stars_giveaway.py | 10 +- examples/purchase/topup_gram_balance.py | 10 +- pyfragment/__init__.py | 5 +- pyfragment/client.py | 183 ++++++------------ pyfragment/core/constants.py | 3 - pyfragment/core/validation.py | 58 ++++++ pyfragment/domains/ads/recharge.py | 4 +- pyfragment/domains/ads/tonup.py | 4 +- pyfragment/domains/giveaways/giveaway.py | 4 +- pyfragment/domains/purchases/purchase.py | 4 +- pyfragment/domains/tonapi/__init__.py | 5 - pyfragment/enums.py | 5 + pyfragment/exceptions.py | 6 +- pyfragment/services/tonapi/__init__.py | 5 + .../{domains => services}/tonapi/account.py | 17 +- .../{domains => services}/tonapi/models.py | 0 .../{domains => services}/tonapi/service.py | 4 +- .../tonapi/transaction.py | 7 +- tests/001_test_decode.py | 8 +- tests/002_test_client.py | 7 +- tests/003_test_balance.py | 25 +-- tests/007_test_wallet.py | 16 +- tests/conftest.py | 4 +- 35 files changed, 286 insertions(+), 227 deletions(-) create mode 100644 pyfragment/core/validation.py delete mode 100644 pyfragment/domains/tonapi/__init__.py create mode 100644 pyfragment/services/tonapi/__init__.py rename pyfragment/{domains => services}/tonapi/account.py (89%) rename pyfragment/{domains => services}/tonapi/models.py (100%) rename pyfragment/{domains => services}/tonapi/service.py (69%) rename pyfragment/{domains => services}/tonapi/transaction.py (96%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b9d2e..20d531d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ### Added +- Added `ApiProvider` enum with `TONAPI` (tonconsole.com, default) and `TONCENTER` (t.me/toncenter) values. +- Added `api_provider` parameter to `FragmentClient` — select the blockchain API provider at init time (`"tonapi"` or `"toncenter"`). +- Both providers accept `api_key` with the same interface; the correct `tonutils` client is selected automatically. + - New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.` - New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot). - Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion` diff --git a/README.md b/README.md index 3bd9419..767377a 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,15 @@ from pyfragment.enums import PaymentMethod async def main() -> None: async with FragmentClient( seed="word1 word2 ... word24", - api_key="YOUR_TONAPI_KEY", + api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter cookies={ "stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "...", }, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" ) as client: wallet = await client.get_wallet() print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}") @@ -73,13 +75,14 @@ asyncio.run(main()) ## Configuration -| Parameter | Type | Default | Description | -| ---------------- | ------------- | -------- | ----------------------------------------------------------- | -| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic | -| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | -| `cookies` | `dict \| str` | — | Fragment session cookies | -| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | -| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | +| Parameter | Type | Default | Description | +| ---------------- | ------------- | ----------- | ------------------------------------------------------------------------------ | +| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic | +| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) | +| `cookies` | `dict \| str` | — | Fragment session cookies | +| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | +| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) | +| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | --- diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index e0c78df..872f077 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -13,7 +13,7 @@ import json from pyfragment import FragmentClient, GiftsResult SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -33,7 +33,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index 9cc40e2..acc652c 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -12,7 +12,7 @@ import json from pyfragment import FragmentClient, NumbersResult SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -31,7 +31,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index b8c4ce3..15fe62a 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -12,7 +12,7 @@ import json from pyfragment import FragmentClient, UsernamesResult SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -31,7 +31,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) print(f"Found {len(result.items)} result(s):") diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py index 066e755..04dbe36 100644 --- a/examples/client/raw_api_call.py +++ b/examples/client/raw_api_call.py @@ -14,7 +14,7 @@ import asyncio from pyfragment import FragmentClient SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -33,7 +33,13 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: result = await client.call(METHOD, DATA, page_url=PAGE_URL) print(result) diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 1b0de85..d14094a 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -3,6 +3,7 @@ Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances. Cookies can be passed as a dict or as a JSON string. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. +api_provider defaults to "tonapi" (tonconsole.com) — pass "toncenter" to use t.me/toncenter instead. """ import asyncio @@ -10,7 +11,7 @@ import asyncio from pyfragment import FragmentClient SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -29,7 +30,8 @@ async def main() -> None: seed=SEED, api_key=API_KEY, cookies=COOKIES, - wallet_version="V5R1", # or "V4R2" + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" ) as client: wallet = await client.get_wallet() print(f"Address: {wallet.address}") diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index b4b9702..632ed27 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -11,7 +11,7 @@ import asyncio from pyfragment import AnonymousNumberError, FragmentClient SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -28,7 +28,13 @@ NUMBER = "+88888888888" async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: # Fetch the latest login code result = await client.get_login_code(NUMBER) if result.code: diff --git a/examples/purchase/premium_giveaway.py b/examples/purchase/premium_giveaway.py index 82cd91f..4869887 100644 --- a/examples/purchase/premium_giveaway.py +++ b/examples/purchase/premium_giveaway.py @@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -32,7 +32,13 @@ PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result = await client.giveaway_premium( CHANNEL, diff --git a/examples/purchase/purchase_premium.py b/examples/purchase/purchase_premium.py index a245c55..d4327d5 100644 --- a/examples/purchase/purchase_premium.py +++ b/examples/purchase/purchase_premium.py @@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -31,7 +31,13 @@ PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result = await client.purchase_premium( USERNAME, diff --git a/examples/purchase/purchase_stars.py b/examples/purchase/purchase_stars.py index ce9dc10..36ca65a 100644 --- a/examples/purchase/purchase_stars.py +++ b/examples/purchase/purchase_stars.py @@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -31,7 +31,13 @@ PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result = await client.purchase_stars( USERNAME, diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index a3fc005..d2a556e 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -15,7 +15,7 @@ from pyfragment import ( ) SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -33,7 +33,13 @@ AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON) async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) except WalletError as e: diff --git a/examples/purchase/stars_giveaway.py b/examples/purchase/stars_giveaway.py index 338e973..5443023 100644 --- a/examples/purchase/stars_giveaway.py +++ b/examples/purchase/stars_giveaway.py @@ -12,7 +12,7 @@ from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment.enums import PaymentMethod SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -32,7 +32,13 @@ PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result = await client.giveaway_stars( CHANNEL, diff --git a/examples/purchase/topup_gram_balance.py b/examples/purchase/topup_gram_balance.py index ee26860..0680dc8 100644 --- a/examples/purchase/topup_gram_balance.py +++ b/examples/purchase/topup_gram_balance.py @@ -17,7 +17,7 @@ from pyfragment import ( ) SEED = "word1 word2 ... word24" -API_KEY = "YOUR_TONAPI_KEY" +API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter # Option A: extract cookies directly from your browser (no manual copy-paste needed) # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... @@ -35,7 +35,13 @@ AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON) async def main() -> None: - async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + async with FragmentClient( + seed=SEED, + api_key=API_KEY, + cookies=COOKIES, + wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" + api_provider="tonapi", # or "toncenter" + ) as client: try: result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index d30e5f3..449af9e 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -7,8 +7,7 @@ from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, Termina from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult from pyfragment.domains.purchases.models import PremiumResult, StarsResult -from pyfragment.domains.tonapi.models import WalletInfo -from pyfragment.enums import PaymentMethod, WalletVersion +from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion from pyfragment.exceptions import ( AlreadySubscribedError, AnonymousNumberError, @@ -27,6 +26,7 @@ from pyfragment.exceptions import ( WalletError, ) from pyfragment.services.cookies import CookieResult, get_cookies_from_browser +from pyfragment.services.tonapi.models import WalletInfo logging.getLogger("pyfragment").addHandler(logging.NullHandler()) @@ -66,6 +66,7 @@ __all__ = [ "ParseError", "UnexpectedError", # literal types + "ApiProvider", "PaymentMethod", "WalletVersion", "get_cookies_from_browser", diff --git a/pyfragment/client.py b/pyfragment/client.py index 3999b15..b8abe17 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,15 +1,14 @@ from __future__ import annotations -import json -from typing import Any, cast +from typing import Any -from pyfragment.core.constants import ( - BASE_HEADERS, - DEFAULT_TIMEOUT, - FRAGMENT_BASE_URL, - MNEMONIC_WORD_COUNTS_VALID, - REQUIRED_COOKIE_KEYS, - TONAPI_KEY_MIN_LENGTH, +from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL +from pyfragment.core.validation import ( + normalize_provider, + normalize_wallet_version, + parse_cookies, + validate_cookie_keys, + validate_credentials, ) from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.domains.ads.service import AdsService @@ -22,10 +21,9 @@ from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, Us from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.purchases.models import PremiumResult, StarsResult from pyfragment.domains.purchases.service import PurchasesService -from pyfragment.domains.tonapi.models import WalletInfo -from pyfragment.domains.tonapi.service import TonapiService -from pyfragment.enums import PaymentMethod, WalletVersion -from pyfragment.exceptions import ConfigurationError, CookieError +from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion +from pyfragment.services.tonapi.models import WalletInfo +from pyfragment.services.tonapi.service import TonapiService class FragmentClient: @@ -38,14 +36,17 @@ class FragmentClient: Args: seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet. - api_key: Tonapi API key — get one at https://tonconsole.com. + api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter. cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). + api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default) + or ``"toncenter"`` (t.me/toncenter). timeout: HTTP request timeout in seconds. Defaults to ``30.0``. headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used. Raises: - ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. + ConfigurationError: If ``seed``, ``api_key``, ``wallet_version``, or ``api_provider`` + are missing or invalid. CookieError: If ``cookies`` cannot be parsed or are missing required keys. Example:: @@ -60,62 +61,25 @@ class FragmentClient: print(result.transaction_id) """ - @staticmethod - def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]: - if isinstance(cookies, str): - try: - cookies = json.loads(cookies) - except Exception as exc: - raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc - return cast(dict[str, Any], cookies) - - @staticmethod - def _validate_required(seed: str, api_key: str) -> None: - missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] - if missing: - raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) - - word_count = len(seed.split()) - if word_count not in MNEMONIC_WORD_COUNTS_VALID: - raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count)) - - if len(api_key.strip()) < TONAPI_KEY_MIN_LENGTH: - raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip()))) - - @staticmethod - def _validate_cookie_keys(cookies: dict[str, Any]) -> None: - missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()] - if missing_keys: - raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) - - @staticmethod - def _normalize_wallet_version(wallet_version: str) -> WalletVersion: - version = wallet_version.strip().upper() - try: - return WalletVersion(version) - except ValueError: - raise ConfigurationError( - ConfigurationError.UNSUPPORTED_VERSION.format( - version=version, supported=", ".join(sorted(m.value for m in WalletVersion)) - ) - ) - def __init__( self, seed: str, api_key: str, cookies: dict[str, Any] | str, wallet_version: str = "V5R1", + api_provider: str = "tonapi", timeout: float = DEFAULT_TIMEOUT, headers: dict[str, str] | None = None, ) -> None: - self._validate_required(seed, api_key) - parsed_cookies = self._parse_cookies(cookies) - self._validate_cookie_keys(parsed_cookies) - version = self._normalize_wallet_version(wallet_version) + validate_credentials(seed, api_key) + provider = normalize_provider(api_provider) + parsed_cookies = parse_cookies(cookies) + validate_cookie_keys(parsed_cookies) + version = normalize_wallet_version(wallet_version) self.seed: str = seed.strip() self.api_key: str = api_key.strip() + self.api_provider: ApiProvider = provider self.cookies: dict[str, Any] = parsed_cookies self.wallet_version: WalletVersion = version self.timeout: float = timeout @@ -134,7 +98,7 @@ class FragmentClient: pass def __repr__(self) -> str: - return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)" + return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)" async def purchase_premium( self, @@ -149,9 +113,7 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. months: Duration — ``3``, ``6``, or ``12``. show_sender: Show your name as the sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), - ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, - or ``"usdc_pol"``. + payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. @@ -171,9 +133,7 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of stars — integer from ``50`` to ``10 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), - ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, - or ``"usdc_pol"``. + payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``. Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. @@ -197,8 +157,7 @@ class FragmentClient: """Add funds to your own Telegram Ads account. Args: - account: Your Fragment Ads account identifier — the channel or bot username - the Ads account is linked to (e.g. ``"@mychannel"``). + account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``). amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. Returns: @@ -210,9 +169,7 @@ class FragmentClient: """Return the address, state, and balances of the wallet. Returns: - :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` - (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), - ``gram_balance`` in GRAM (ex TON), and ``usdt_balance`` in USDT. + :class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``. """ return await self.tonapi.get_wallet() @@ -229,13 +186,10 @@ class FragmentClient: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``15``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. - payment_method: Payment currency — ``"ton"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), - ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, - or ``"usdc_pol"``. + payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``. Returns: - :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``amount``. + :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method) @@ -252,13 +206,10 @@ class FragmentClient: 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"`` (GRAM (ex TON), default), ``"usdt_ton"`` (USDT on GRAM (ex TON)), - ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, - or ``"usdc_pol"``. + payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``. Returns: - :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``amount``. + :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. """ return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method) @@ -266,7 +217,7 @@ class FragmentClient: """Fetch the current pending login code for an anonymous number. Args: - number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + number: Phone number with or without leading ``+``. Returns: :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), @@ -293,7 +244,7 @@ class FragmentClient: :class:`TerminateSessionsResult` with ``number`` and ``message``. Raises: - AnonymousNumberError: If the number is not owned by this account or has no active sessions. + AnonymousNumberError: If the number is not owned or has no active sessions. """ return await self.anonymous_numbers.terminate_sessions(number) @@ -307,17 +258,13 @@ class FragmentClient: """Search the Fragment marketplace for Telegram usernames. Args: - 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 — pass :attr:`UsernamesResult.next_offset_id` - from a previous result to fetch the next page. + query: Search text. Omit or pass ``""`` to browse all. + sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. + filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). + offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page. Returns: - :class:`UsernamesResult` with ``items`` (parsed list of item dicts) - and ``next_offset_id`` (``None`` on the last page). + :class:`UsernamesResult` with ``items`` and ``next_offset_id``. """ return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id) @@ -331,17 +278,13 @@ class FragmentClient: """Search the Fragment marketplace for anonymous Telegram numbers. Args: - 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 — pass :attr:`NumbersResult.next_offset_id` - from a previous result to fetch the next page. + query: Search text. Omit or pass ``""`` to browse all. + sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. + filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). + offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page. Returns: - :class:`NumbersResult` with ``items`` (parsed list of item dicts) - and ``next_offset_id`` (``None`` on the last page). + :class:`NumbersResult` with ``items`` and ``next_offset_id``. """ return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id) @@ -358,22 +301,16 @@ class FragmentClient: """Search the Fragment gifts marketplace. Args: - 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 — mapping of trait name to 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. + query: Search text. Omit or pass ``""`` to browse all. + collection: Gift collection slug (e.g. ``"artisanbrick"``). + sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. + filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). + view: Active attribute tab name (e.g. ``"Model"``). + attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``. + offset: Pass :attr:`GiftsResult.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). + :class:`GiftsResult` with ``items`` and ``next_offset``. """ return await self.marketplace.search_gifts( query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset @@ -384,24 +321,12 @@ class FragmentClient: ) -> dict[str, Any]: """Send a raw request to the Fragment API. - Useful for accessing undocumented or future Fragment API methods - without waiting for a library update. - Args: method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. - data: Additional form-data fields to include in the request body. - page_url: Fragment page URL used to derive the API hash and headers. - Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``). + data: Additional form-data fields. + page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``. Returns: Raw parsed JSON response as a dict. - - Example:: - - result = await client.call( - "searchPremiumGiftRecipient", - {"query": "@username", "months": 3}, - page_url="https://fragment.com/premium/gift", - ) """ return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers) diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py index 835869e..40253fb 100644 --- a/pyfragment/core/constants.py +++ b/pyfragment/core/constants.py @@ -82,6 +82,3 @@ PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12}) # Mnemonic phrase valid word counts MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24}) - -# Tonapi API key minimum length (tonapi.io) -TONAPI_KEY_MIN_LENGTH: int = 68 diff --git a/pyfragment/core/validation.py b/pyfragment/core/validation.py new file mode 100644 index 0000000..4e06706 --- /dev/null +++ b/pyfragment/core/validation.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +from typing import Any, cast + +from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS +from pyfragment.enums import ApiProvider, WalletVersion +from pyfragment.exceptions import ConfigurationError, CookieError + + +def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]: + if isinstance(cookies, str): + try: + cookies = json.loads(cookies) + except Exception as exc: + raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc + return cast(dict[str, Any], cookies) + + +def validate_cookie_keys(cookies: dict[str, Any]) -> None: + missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()] + if missing: + raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing))) + + +def normalize_provider(api_provider: str) -> ApiProvider: + try: + return ApiProvider(api_provider.strip().lower()) + except ValueError: + raise ConfigurationError( + ConfigurationError.UNSUPPORTED_PROVIDER.format( + provider=api_provider, + supported=", ".join(sorted(p.value for p in ApiProvider)), + ) + ) + + +def normalize_wallet_version(wallet_version: str) -> WalletVersion: + version = wallet_version.strip().upper() + try: + return WalletVersion(version) + except ValueError: + raise ConfigurationError( + ConfigurationError.UNSUPPORTED_VERSION.format( + version=version, + supported=", ".join(sorted(m.value for m in WalletVersion)), + ) + ) + + +def validate_credentials(seed: str, api_key: str) -> None: + missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] + if missing: + raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) + + word_count = len(seed.split()) + if word_count not in MNEMONIC_WORD_COUNTS_VALID: + raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count)) diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index ab44e79..9a35f56 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -6,9 +6,9 @@ from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.domains.ads.models import AdsRechargeResult -from pyfragment.domains.tonapi.account import get_account_info -from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError +from pyfragment.services.tonapi.account import get_account_info +from pyfragment.services.tonapi.transaction import process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/ads/tonup.py b/pyfragment/domains/ads/tonup.py index e4f69d8..559c60a 100644 --- a/pyfragment/domains/ads/tonup.py +++ b/pyfragment/domains/ads/tonup.py @@ -7,8 +7,6 @@ from typing import TYPE_CHECKING from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.domains.ads.models import AdsTopupResult from pyfragment.domains.payments import parse_required_payment_amount -from pyfragment.domains.tonapi.account import get_account_info -from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ( ConfigurationError, FragmentAPIError, @@ -17,6 +15,8 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) +from pyfragment.services.tonapi.account import get_account_info +from pyfragment.services.tonapi.transaction import process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index dc5e9e6..436f2b2 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -19,8 +19,6 @@ from pyfragment.core.constants import ( ) from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.domains.payments import parse_required_payment_amount -from pyfragment.domains.tonapi.account import get_account_info -from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.enums import PaymentMethod from pyfragment.exceptions import ( ConfigurationError, @@ -30,6 +28,8 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) +from pyfragment.services.tonapi.account import get_account_info +from pyfragment.services.tonapi.transaction import process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index e774718..6061e46 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -15,8 +15,6 @@ from pyfragment.core.constants import ( ) from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.purchases.models import PremiumResult, StarsResult -from pyfragment.domains.tonapi.account import get_account_info -from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.enums import PaymentMethod from pyfragment.exceptions import ( AlreadySubscribedError, @@ -27,6 +25,8 @@ from pyfragment.exceptions import ( UserNotFoundError, VerificationError, ) +from pyfragment.services.tonapi.account import get_account_info +from pyfragment.services.tonapi.transaction import process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient diff --git a/pyfragment/domains/tonapi/__init__.py b/pyfragment/domains/tonapi/__init__.py deleted file mode 100644 index 42b4b85..0000000 --- a/pyfragment/domains/tonapi/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pyfragment.domains.tonapi.service import TonapiService - -__all__ = [ - "TonapiService", -] diff --git a/pyfragment/enums.py b/pyfragment/enums.py index ac672e5..098f17b 100644 --- a/pyfragment/enums.py +++ b/pyfragment/enums.py @@ -41,6 +41,11 @@ WALLET_CLASSES: dict[WalletVersion, Any] = { } +class ApiProvider(StrEnum): + TONAPI = "tonapi" # tonconsole.com — default + TONCENTER = "toncenter" # t.me/toncenter + + class SupportedBrowser(StrEnum): ARC = "arc" BRAVE = "brave" diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index 5c1e765..9153d24 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -13,7 +13,6 @@ from pyfragment.core.constants import ( STARS_PURCHASE_MIN, STARS_WINNERS_MAX, STARS_WINNERS_MIN, - TONAPI_KEY_MIN_LENGTH, ) @@ -31,10 +30,7 @@ class ConfigurationError(ClientError): MISSING_VARS = "Missing required parameter(s): {keys}." UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}." INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}." - INVALID_API_KEY = ( - f"Invalid Tonapi API key: expected at least {TONAPI_KEY_MIN_LENGTH} characters, got {{length}}. " - "Get a key at https://tonconsole.com." - ) + UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}." INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months." INVALID_STARS_AMOUNT = ( f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}." diff --git a/pyfragment/services/tonapi/__init__.py b/pyfragment/services/tonapi/__init__.py new file mode 100644 index 0000000..b79666a --- /dev/null +++ b/pyfragment/services/tonapi/__init__.py @@ -0,0 +1,5 @@ +from pyfragment.services.tonapi.service import TonapiService + +__all__ = [ + "TonapiService", +] diff --git a/pyfragment/domains/tonapi/account.py b/pyfragment/services/tonapi/account.py similarity index 89% rename from pyfragment/domains/tonapi/account.py rename to pyfragment/services/tonapi/account.py index 7e094ae..ddaf9f0 100644 --- a/pyfragment/domains/tonapi/account.py +++ b/pyfragment/services/tonapi/account.py @@ -5,14 +5,14 @@ import logging from typing import TYPE_CHECKING, Any from ton_core import NetworkGlobalID -from tonutils.clients import TonapiClient +from tonutils.clients import TonapiClient, ToncenterClient from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method from tonutils.exceptions import ProviderResponseError from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS -from pyfragment.domains.tonapi.models import WalletInfo -from pyfragment.enums import WALLET_CLASSES +from pyfragment.enums import WALLET_CLASSES, ApiProvider from pyfragment.exceptions import WalletError +from pyfragment.services.tonapi.models import WalletInfo if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -21,6 +21,13 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _make_ton_client(client: FragmentClient) -> Any: + """Return the appropriate tonutils client based on the configured api_provider.""" + if client.api_provider == ApiProvider.TONCENTER: + return ToncenterClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) + return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) + + async def get_usdt_balance(ton: Any, wallet_address: str) -> float: """Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet.""" try: @@ -92,7 +99,7 @@ async def check_usdt_payment_balance( async def get_account_info(client: FragmentClient) -> dict[str, Any]: """Build the wallet payload Fragment needs to prepare a transaction.""" - async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: + async with _make_ton_client(client) as ton: try: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) @@ -110,7 +117,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]: async def get_wallet_info(client: FragmentClient) -> WalletInfo: """Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances.""" - async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: + async with _make_ton_client(client) as ton: try: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) diff --git a/pyfragment/domains/tonapi/models.py b/pyfragment/services/tonapi/models.py similarity index 100% rename from pyfragment/domains/tonapi/models.py rename to pyfragment/services/tonapi/models.py diff --git a/pyfragment/domains/tonapi/service.py b/pyfragment/services/tonapi/service.py similarity index 69% rename from pyfragment/domains/tonapi/service.py rename to pyfragment/services/tonapi/service.py index 3de0067..e7de994 100644 --- a/pyfragment/domains/tonapi/service.py +++ b/pyfragment/services/tonapi/service.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import TYPE_CHECKING from pyfragment.domains.base import BaseService -from pyfragment.domains.tonapi.account import get_wallet_info -from pyfragment.domains.tonapi.models import WalletInfo +from pyfragment.services.tonapi.account import get_wallet_info +from pyfragment.services.tonapi.models import WalletInfo if TYPE_CHECKING: pass diff --git a/pyfragment/domains/tonapi/transaction.py b/pyfragment/services/tonapi/transaction.py similarity index 96% rename from pyfragment/domains/tonapi/transaction.py rename to pyfragment/services/tonapi/transaction.py index 1e8bacf..5d9bec0 100644 --- a/pyfragment/domains/tonapi/transaction.py +++ b/pyfragment/services/tonapi/transaction.py @@ -7,13 +7,12 @@ import random import ssl from typing import TYPE_CHECKING, Any -from ton_core import Cell, NetworkGlobalID -from tonutils.clients import TonapiClient +from ton_core import Cell from tonutils.exceptions import ProviderResponseError -from pyfragment.domains.tonapi.account import check_gram_payment_balance, check_usdt_payment_balance from pyfragment.enums import WALLET_CLASSES, PaymentMethod from pyfragment.exceptions import ParseError, TransactionError, WalletError +from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance if TYPE_CHECKING: from pyfragment.client import FragmentClient @@ -141,7 +140,7 @@ async def process_transaction( message = _extract_message(transaction_data) amount_gram = int(message["amount"]) / 1_000_000_000 - async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: + async with _make_ton_client(client) as ton: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 9be867e..25310a0 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -8,7 +8,7 @@ import pytest from ton_core import Cell from pyfragment import ParseError -from pyfragment.domains.tonapi.transaction import clean_decode +from pyfragment.services.tonapi.transaction import clean_decode PAYLOAD_CASES = [ pytest.param( @@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None: raw = b"\xfb\xef\xff\x00" payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") - with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: + with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: result = clean_decode(payload) mocked.assert_called_once_with(raw) @@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None: return _FakeSlice() payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") - with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()): + with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()): parsed = clean_decode(payload) assert parsed == "Telegram Premium Ref#abc" @@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None: payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") fake_cell: object = _FakeCell() - with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell): + with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell): parsed = clean_decode(payload) assert parsed is fake_cell diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 91660ab..3bb76d5 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -5,7 +5,7 @@ import json import pytest from pyfragment import ConfigurationError, CookieError, FragmentClient -from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, TONAPI_KEY_MIN_LENGTH +from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED # Client init tests @@ -70,11 +70,6 @@ def test_missing_api_key_raises() -> None: FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) -def test_short_api_key_raises() -> None: - with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key="A" * (TONAPI_KEY_MIN_LENGTH - 1), cookies=VALID_COOKIES) - - # Cookie validation tests diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index 6ac5f0b..91ef393 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -8,8 +8,8 @@ import pytest from tonutils.exceptions import ProviderResponseError from pyfragment import TransactionError, WalletError -from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.enums import PaymentMethod +from pyfragment.services.tonapi.transaction import process_transaction from tests.shared import VALID_SEED @@ -48,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock: @contextmanager def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: + mock_ton_ctx = MagicMock() + mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_ton_ctx.__aexit__ = AsyncMock(return_value=False) with ( - patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi, - patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes, + patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx), + patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes, ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) yield @@ -64,7 +65,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: @pytest.mark.asyncio async def test_sufficient_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold - with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): + with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" wallet.transfer.assert_called_once() @@ -82,7 +83,7 @@ async def test_insufficient_balance_raises() -> None: @pytest.mark.asyncio async def test_exact_minimum_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold - with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): + with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @@ -124,7 +125,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None: async def test_rate_limit_retries_and_succeeds() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) - with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""): + with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""): result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" assert wallet.transfer.call_count == 2 @@ -135,7 +136,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) err = _provider_error(406, "Duplicate msg_seqno") wallet.transfer = AsyncMock(side_effect=[err, err, err]) - with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""): + with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""): with pytest.raises(TransactionError, match="seqno"): await process_transaction(_make_client(), TRANSACTION_DATA) assert wallet.transfer.call_count == 3 @@ -144,7 +145,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None: @pytest.mark.asyncio async def test_usdt_payment_requires_min_gram_gas_reserve() -> None: wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE - with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): + with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): with pytest.raises(WalletError, match="Insufficient GRAM"): await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM) @@ -167,8 +168,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None: with ( _patch_wallet(wallet), - patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""), - patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)), + patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""), + patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)), ): with pytest.raises(WalletError, match="Insufficient USDT balance"): await process_transaction( diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index 9f699c3..ea07fdc 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -19,12 +19,12 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: mock_wallet.address.to_str.return_value = FAKE_ADDRESS with ( - patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, - patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, - patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)), + patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, + patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, + patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) result = await client.get_wallet() @@ -45,12 +45,12 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: mock_wallet.address.to_str.return_value = FAKE_ADDRESS with ( - patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, - patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, - patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)), + patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, + patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, + patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) result = await client.get_wallet() diff --git a/tests/conftest.py b/tests/conftest.py index 94e7f63..d284b9a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,8 +10,8 @@ import pyfragment.domains.ads.recharge # noqa: F401 import pyfragment.domains.ads.tonup # noqa: F401 import pyfragment.domains.giveaways.giveaway # noqa: F401 import pyfragment.domains.purchases.purchase # noqa: F401 -import pyfragment.domains.tonapi.account # noqa: F401 -import pyfragment.domains.tonapi.transaction # noqa: F401 +import pyfragment.services.tonapi.account # noqa: F401 +import pyfragment.services.tonapi.transaction # noqa: F401 from pyfragment import FragmentClient from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED From a9663d0b0845dc3654574055dc15b8f21354898b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:41:03 +0300 Subject: [PATCH 14/15] chore: update setup-uv action to v8.2.0 and update changelog date --- .github/workflows/publish.yml | 2 +- CHANGELOG.md | 2 +- CONTRIBUTING.md | 14 ++++++++------ README.md | 16 ++++++++-------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b14613f..f793c24 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -48,7 +48,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v8.1.0 + - uses: astral-sh/setup-uv@v8.2.0 - run: uv build diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d531d..bacc818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- -## [2026.3.2] — Unreleased +## [2026.3.2] — 2026-06-16 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 991a561..1db8218 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,16 +28,18 @@ All three must pass before opening a PR. ``` pyfragment/ client.py — FragmentClient (public entry point) - core/ — transport, cookies, constants + enums.py — ApiProvider, PaymentMethod, WalletVersion + exceptions.py — exception hierarchy + core/ — constants, validation helpers domains/ — one package per feature domain ads/ — recharge_ads, topup_gram - anonymous_numbers/— buy_number, manage_number + anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions giveaways/ — giveaway_stars, giveaway_premium marketplace/ — search_usernames, search_numbers, search_gifts purchases/ — purchase_stars, purchase_premium - tonapi/ — wallet info, transaction signing - models/ — result dataclasses and enums - exceptions.py — exception hierarchy + services/ — shared infrastructure services + cookies/ — browser cookie extraction (models + service) + tonapi/ — wallet info, transaction signing (tonapi/toncenter) tests/ — unit tests (pytest) examples/ — runnable usage examples (excluded from CI) ``` @@ -46,7 +48,7 @@ examples/ — runnable usage examples (excluded from CI) - All public async methods live on `FragmentClient` and delegate to a domain service. - Domain functions receive a `FragmentClient` instance, never raw httpx clients. -- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`. +- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`. - Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`. ## Pull requests diff --git a/README.md b/README.md index 767377a..d4dbf8a 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,14 @@ asyncio.run(main()) ## Configuration -| Parameter | Type | Default | Description | -| ---------------- | ------------- | ----------- | ------------------------------------------------------------------------------ | -| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic | -| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) | -| `cookies` | `dict \| str` | — | Fragment session cookies | -| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | -| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) | -| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | +| Parameter | Type | Default | Description | +| ---------------- | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic | +| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) | +| `cookies` | `dict \| str` | — | Fragment session cookies | +| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | +| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) | +| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | --- From 4539dc980393e4d7fc2eac778b4549a59ea1dddb Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 16 Jun 2026 01:49:11 +0300 Subject: [PATCH 15/15] Refactor tests: Add error handling tests for API provider and recharge flows --- tests/002_test_client.py | 30 ++++++++++ tests/004_test_stars.py | 105 +++++++++++++++++++++++++++++++++ tests/005_test_premium.py | 105 +++++++++++++++++++++++++++++++++ tests/006_test_topup.py | 48 +++++++++++++++ tests/007_test_wallet.py | 94 +++++++++++++++++++++++++++++ tests/010_test_recharge_ads.py | 46 +++++++++++++++ 6 files changed, 428 insertions(+) diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 3bb76d5..2e8b878 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -16,6 +16,30 @@ def test_valid_init() -> None: assert client.seed == VALID_SEED.strip() assert client.api_key == VALID_API_KEY assert client.wallet_version == "V5R1" + assert client.api_provider == "tonapi" + + +# API provider tests + + +def test_api_provider_default_is_tonapi() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + assert client.api_provider == "tonapi" + + +def test_api_provider_toncenter() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="toncenter") + assert client.api_provider == "toncenter" + + +def test_api_provider_is_case_insensitive() -> None: + client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="TONAPI") + assert client.api_provider == "tonapi" + + +def test_unsupported_api_provider_raises() -> None: + with pytest.raises(ConfigurationError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="infura") # Wallet version tests @@ -44,6 +68,11 @@ def test_missing_seed_raises() -> None: FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) +def test_both_seed_and_api_key_missing_raises() -> None: + with pytest.raises(ConfigurationError): + FragmentClient(seed="", api_key="", cookies=VALID_COOKIES) + + def test_whitespace_only_seed_raises() -> None: with pytest.raises(ConfigurationError): FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) @@ -105,6 +134,7 @@ def test_repr() -> None: r = repr(client) assert "FragmentClient" in r assert "V5R1" in r + assert "tonapi" in r assert "4 keys" in r diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 97f73b0..45c0eba 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -236,3 +236,108 @@ async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None: with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.giveaway_stars("@ghost", winners=1, amount=500) + + +# Stars purchase — error branches + + +@pytest.mark.asyncio +async def test_purchase_stars_not_a_user_raises(client: FragmentClient) -> None: + with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})): + with pytest.raises(UserNotFoundError, match="does not belong"): + await client.purchase_stars("@channel", amount=500) + + +@pytest.mark.asyncio +async def test_purchase_stars_missing_req_id_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsBuyState + {"amount": "0.1"}, # initBuyStarsRequest — no req_id + ] + ), + ), + patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + from pyfragment.exceptions import FragmentAPIError + + with pytest.raises(FragmentAPIError): + await client.purchase_stars("@user", amount=500) + + +@pytest.mark.asyncio +async def test_purchase_stars_need_verify_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsBuyState + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getBuyStarsLink + ] + ), + ), + patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + from pyfragment.exceptions import VerificationError + + with pytest.raises(VerificationError): + await client.purchase_stars("@user", amount=500) + + +# Stars giveaway — error branches + + +@pytest.mark.asyncio +async def test_giveaway_stars_missing_req_id_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsGiveawayState + {}, # updateStarsGiveawayPrices + {"amount": "0.1"}, # initGiveawayStarsRequest — no req_id + ] + ), + ), + patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + from pyfragment.exceptions import FragmentAPIError + + with pytest.raises(FragmentAPIError): + await client.giveaway_stars("@channel", winners=3, amount=1000) + + +@pytest.mark.asyncio +async def test_giveaway_stars_need_verify_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updateStarsGiveawayState + {}, # updateStarsGiveawayPrices + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getGiveawayStarsLink + ] + ), + ), + patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + from pyfragment.exceptions import VerificationError + + with pytest.raises(VerificationError): + await client.giveaway_stars("@channel", winners=3, amount=1000) diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 64af05b..1655823 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -234,3 +234,108 @@ async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> Non with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.giveaway_premium("@ghost", winners=1, months=3) + + +# Premium purchase — error branches + + +@pytest.mark.asyncio +async def test_purchase_premium_not_a_user_raises(client: FragmentClient) -> None: + with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})): + with pytest.raises(UserNotFoundError, match="does not belong"): + await client.purchase_premium("@channel", months=3) + + +@pytest.mark.asyncio +async def test_purchase_premium_missing_req_id_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import FragmentAPIError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"amount": "0.1"}, # initGiftPremiumRequest — no req_id + ] + ), + ), + patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(FragmentAPIError): + await client.purchase_premium("@user", months=3) + + +@pytest.mark.asyncio +async def test_purchase_premium_need_verify_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import VerificationError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getGiftPremiumLink + ] + ), + ), + patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(VerificationError): + await client.purchase_premium("@user", months=3) + + +# Premium giveaway — error branches + + +@pytest.mark.asyncio +async def test_giveaway_premium_missing_req_id_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import FragmentAPIError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumGiveawayState + {}, # updatePremiumGiveawayPrices + {"amount": "0.1"}, # initGiveawayPremiumRequest — no req_id + ] + ), + ), + patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(FragmentAPIError): + await client.giveaway_premium("@channel", winners=10, months=3) + + +@pytest.mark.asyncio +async def test_giveaway_premium_need_verify_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import VerificationError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumGiveawayState + {}, # updatePremiumGiveawayPrices + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getGiveawayPremiumLink + ] + ), + ), + patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(VerificationError): + await client.giveaway_premium("@channel", winners=10, months=3) diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index eea5bdc..6f88339 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -73,3 +73,51 @@ async def test_topup_gram_user_not_found(client: FragmentClient) -> None: ): with pytest.raises(UserNotFoundError): await client.topup_gram("@ghost", amount=10) + + +# topup_gram — error branches + + +@pytest.mark.asyncio +async def test_topup_gram_missing_req_id_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import FragmentAPIError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, + {"amount": "0.1"}, # initAdsTopupRequest — no req_id + ] + ), + ), + patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(FragmentAPIError): + await client.topup_gram("@user", amount=10) + + +@pytest.mark.asyncio +async def test_topup_gram_need_verify_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import VerificationError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getAdsTopupLink + ] + ), + ), + patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(VerificationError): + await client.topup_gram("@user", amount=10) diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index ea07fdc..6d54b3d 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -58,3 +58,97 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: assert result.gram_balance == 0.0 assert result.usdt_balance == 0.0 assert result.state == "uninit" + + +# _make_ton_client — provider selection + + +def test_make_ton_client_uses_toncenter_for_toncenter_provider(client: FragmentClient) -> None: + from tonutils.clients import ToncenterClient + + from pyfragment.enums import ApiProvider + from pyfragment.services.tonapi.account import _make_ton_client + + client.api_provider = ApiProvider.TONCENTER + result = _make_ton_client(client) + assert isinstance(result, ToncenterClient) + + +def test_make_ton_client_uses_tonapi_for_tonapi_provider(client: FragmentClient) -> None: + from tonutils.clients import TonapiClient + + from pyfragment.enums import ApiProvider + from pyfragment.services.tonapi.account import _make_ton_client + + client.api_provider = ApiProvider.TONAPI + result = _make_ton_client(client) + assert isinstance(result, TonapiClient) + + +# get_usdt_balance — error paths + + +@pytest.mark.asyncio +async def test_get_usdt_balance_non_404_provider_error_raises() -> None: + from tonutils.exceptions import ProviderResponseError + + from pyfragment import WalletError + from pyfragment.services.tonapi.account import get_usdt_balance + + ton = MagicMock() + err = ProviderResponseError(code=500, message="server error", endpoint="api.tonapi.io") + + with patch("pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=err)): + with pytest.raises(WalletError): + await get_usdt_balance(ton, "0:abc") + + +@pytest.mark.asyncio +async def test_get_usdt_balance_generic_exception_raises() -> None: + from pyfragment import WalletError + from pyfragment.services.tonapi.account import get_usdt_balance + + ton = MagicMock() + + with patch( + "pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=RuntimeError("timeout")) + ): + with pytest.raises(WalletError): + await get_usdt_balance(ton, "0:abc") + + +# get_account_info / get_wallet_info — exception paths + + +@pytest.mark.asyncio +async def test_get_account_info_exception_raises_wallet_error(client: FragmentClient) -> None: + from pyfragment import WalletError + from pyfragment.services.tonapi.account import get_account_info + + with ( + patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, + patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("wallet init failed") + + with pytest.raises(WalletError, match="account info"): + await get_account_info(client) + + +@pytest.mark.asyncio +async def test_get_wallet_info_exception_raises_wallet_error(client: FragmentClient) -> None: + from pyfragment import WalletError + from pyfragment.services.tonapi.account import get_wallet_info + + with ( + patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, + patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("key derivation failed") + + with pytest.raises(WalletError, match="wallet info"): + await get_wallet_info(client) diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index bb31094..664344d 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -55,3 +55,49 @@ async def test_recharge_ads_success(client: FragmentClient) -> None: assert isinstance(result, AdsRechargeResult) assert result.transaction_id == FAKE_TX_HASH assert result.amount == 10 + + +# recharge_ads — error branches + + +@pytest.mark.asyncio +async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import FragmentAPIError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsState + {"amount": "0.1"}, # initAdsRechargeRequest — no req_id + ] + ), + ), + patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(FragmentAPIError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) + + +@pytest.mark.asyncio +async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None: + from pyfragment.exceptions import VerificationError + + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsState + {"req_id": FAKE_REQ_ID}, + {"need_verify": True}, # getAdsRechargeLink + ] + ), + ), + patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + ): + with pytest.raises(VerificationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)